diff --git a/ocam/.github/ci/test_aof.sh b/ocam/.github/ci/test_aof.sh new file mode 100755 index 00000000..aef32da4 --- /dev/null +++ b/ocam/.github/ci/test_aof.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -eEuo pipefail + +# Download Zig if it does not yet exist: +if [ ! -f "zig/zig" ]; then + ./zig/download.sh +fi + +./zig/zig build install -Drelease + +# Be careful to use a benchmark-specific filenames so that we don't erase a real data file: +cleanup() { + rm -f aof-test.tigerbeetle + rm -f aof-test.tigerbeetle.aof + rm -f aof.log + rm -f {a1,a2,b1,b2}/aof-test.tigerbeetle{,.aof} + rmdir {a1,a2,b1,b2} 2>/dev/null || true +} +cleanup + +function onerror { + if [ "$?" == "0" ]; then + cleanup + else + echo + echo "=============================================================" + echo "Error running aof test, here are more details (from aof.log):" + echo "=============================================================" + cat aof.log + fi + + kill $(jobs -p) 2> /dev/null || true + wait +} +trap onerror EXIT + +echo "Running benchmark to populate AOF..." +./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 aof-test.tigerbeetle > aof.log 2>&1 +./tigerbeetle start --cache-grid=256MiB --addresses=3000 --aof --experimental aof-test.tigerbeetle >> aof.log 2>&1 & +./tigerbeetle benchmark --addresses=3000 --transfer-count=400000 >> aof.log 2>&1 +kill %1 + +echo "" +echo "Running 'zig build aof -- debug aof-test.tigerbeetle.aof' to check AOF..." +data_checksum_src=$(./zig/zig build aof -- debug aof-test.tigerbeetle.aof 2>&1 | tee -a aof.log | grep 'Data checksum chain:') +echo "${data_checksum_src}" + +mkdir a1 a2 b1 b2 + +echo '' +echo 'Testing recovery...' +./tigerbeetle format --cluster=0 --replica=0 --replica-count=2 a1/aof-test.tigerbeetle >> ./aof.log 2>&1 +./tigerbeetle start --aof-recovery --cache-grid=256MiB --addresses=3001,3002 --aof-file=a1/aof-test.tigerbeetle.aof --experimental a1/aof-test.tigerbeetle >> ./aof.log 2>&1 & +r1=$! +./tigerbeetle format --cluster=0 --replica=1 --replica-count=2 a2/aof-test.tigerbeetle >> ./aof.log 2>&1 +./tigerbeetle start --aof-recovery --cache-grid=256MiB --addresses=3001,3002 --aof --experimental a2/aof-test.tigerbeetle >> ./aof.log 2>&1 & +r2=$! + +sleep 1 +./zig/zig build aof -- recover --cluster=0 --addresses=3001,3002 aof-test.tigerbeetle.aof >> aof.log 2>&1 +sleep 10 # Give replicas time to settle. +kill $r1 $r2 + +echo "" +echo "Recovering a second time, to test determinism." + +./tigerbeetle format --cluster=0 --replica=0 --replica-count=2 b1/aof-test.tigerbeetle >> ./aof.log 2>&1 +./tigerbeetle start --aof-recovery --cache-grid=256MiB --addresses=3001,3002 --experimental b1/aof-test.tigerbeetle >> ./aof.log 2>&1 & +r1=$! +./tigerbeetle format --cluster=0 --replica=1 --replica-count=2 b2/aof-test.tigerbeetle >> ./aof.log 2>&1 +./tigerbeetle start --aof-recovery --cache-grid=256MiB --addresses=3001,3002 --experimental b2/aof-test.tigerbeetle >> ./aof.log 2>&1 & +r2=$! + +./zig/zig build aof -- recover --cluster=0 --addresses=3001,3002 aof-test.tigerbeetle.aof >> aof.log 2>&1 +sleep 10 # Give replicas time to settle. +kill $r1 $r2 + +echo "" +echo "Running 'zig build aof -- debug a{1,2}/aof-test.tigerbeetle.aof' to check recovered AOF..." +data_checksum_recovered_1=$(./zig/zig build aof -- debug a1/aof-test.tigerbeetle.aof 2>&1 | tee -a aof.log | grep 'Data checksum chain:') +echo "1: ${data_checksum_recovered_1}" +data_checksum_recovered_2=$(./zig/zig build aof -- debug a2/aof-test.tigerbeetle.aof 2>&1 | tee -a aof.log | grep 'Data checksum chain:') +echo "2: ${data_checksum_recovered_2}" + +if [ "${data_checksum_src}" != "${data_checksum_recovered_1}" ] || [ "${data_checksum_src}" != "${data_checksum_recovered_2}" ]; then + echo "Mismatch in data checksums!" + exit 1 +fi + +echo +echo 'Running "tigerbeetle inspect" to compare superblocks...' +superblock_a=$(./tigerbeetle inspect superblock ./a1/aof-test.tigerbeetle 2>/dev/null) +superblock_b=$(./tigerbeetle inspect superblock ./b1/aof-test.tigerbeetle 2>/dev/null) +if [ "$superblock_a" != "$superblock_b" ]; then + echo "Mismatch in recovery determinism." + exit 1 +fi + +echo +echo 'Success!' diff --git a/ocam/.github/workflows/ci.yml b/ocam/.github/workflows/ci.yml new file mode 100644 index 00000000..d51e6fcb --- /dev/null +++ b/ocam/.github/workflows/ci.yml @@ -0,0 +1,228 @@ +name: CI +permissions: {} + +concurrency: + group: core-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +on: + merge_group: + pull_request: + push: + branches: ["main"] + +env: + GH_TOKEN: ${{ github.token }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true # Remove once this is the default (June 2nd, 2026). + +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - &checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: { fetch-depth: 2147483647, fetch-tags: true } # Fetch history for "git tag" in build.zig. + - &cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + # actions/cache (as of v5.0.5) routinely flakes under windows -- it just prints + # "Cache hit for: Windows-X64-(hash)" and then exits with no other info. + continue-on-error: ${{ startsWith(runner.os, 'Windows') }} + with: + path: ./zig/cache + key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('./zig/download.sh') }} + - run: shellcheck ./zig/download.sh + - shell: pwsh + run: Invoke-ScriptAnalyzer -Path zig/download.win.ps1 -Severity Error,Warning,Information -EnableExit + - run: ./zig/download.ps1 && ./zig/zig build --summary all ci -- smoke + + test: + strategy: + matrix: + include: + - { os: 'ubuntu-latest' } + - { os: 'ubuntu-latest-arm64' } + - { os: 'windows-latest' } + - { os: 'macos-latest' } + - { os: 'macos-15-intel' } + runs-on: ${{ matrix.os }} + steps: + - run: git config --global core.autocrlf false + - if: matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-latest-arm64' + run: | # Allow unshare for vortex. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_unconfined=0 + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - *checkout + - *cache + - &select_xcode + # TODO(Zig): Xcode >26.3 breaks linking with Zig 0.14.1. + # Pin to Xcode 26.3 when it's available. Remove once we upgrade Zig. + # See https://codeberg.org/ziglang/zig/issues/31658 + if: matrix.os == 'macos-latest' + run: | + if [ -d /Applications/Xcode_26.3.app ]; then + sudo xcode-select -s /Applications/Xcode_26.3.app + fi + xcodebuild -version + - run: ./zig/download.ps1 && ./zig/zig build --summary all ci -- test + + test_aof: + runs-on: ubuntu-latest + steps: + - *checkout + - *cache + - run: ./zig/download.ps1 && ./zig/zig build --summary all ci -- aof + + clients: + strategy: + matrix: + include: + - { os: 'ubuntu-latest', language: 'dotnet', language_version: '8.0.x' } + - { os: 'ubuntu-latest', language: 'go', language_version: '1.21' } + - { os: 'ubuntu-latest', language: 'rust', language_version: '1.71' } + - { os: 'ubuntu-latest', language: 'rust', language_version: 'stable' } + - { os: 'ubuntu-latest', language: 'java', language_version: '11' } + - { os: 'ubuntu-latest', language: 'java', language_version: '21' } + - { os: 'ubuntu-latest', language: 'node', language_version: '18.x' } + - { os: 'ubuntu-latest', language: 'node', language_version: '24.x' } + - { os: 'ubuntu-latest', language: 'ruby', language_version: '3.3' } + - { os: 'ubuntu-latest', language: 'ruby', language_version: '4.0' } + + # Support Python 3.7 explicitly, even though it's EOL. + - { os: 'ubuntu-22.04', language: 'python', language_version: '3.7' } + - { os: 'ubuntu-latest', language: 'python', language_version: '3.13' } + + - { os: 'windows-latest', language: 'dotnet', language_version: '8.0.x' } + - { os: 'windows-latest', language: 'go', language_version: '1.21' } + - { os: 'windows-latest', language: 'rust', language_version: '1.71' } + - { os: 'windows-latest', language: 'rust', language_version: 'stable' } + - { os: 'windows-latest', language: 'java', language_version: '11' } + - { os: 'windows-latest', language: 'java', language_version: '21' } + - { os: 'windows-latest', language: 'node', language_version: '18.x' } + - { os: 'windows-latest', language: 'node', language_version: '20.x' } + - { os: 'windows-latest', language: 'python', language_version: '3.7' } + - { os: 'windows-latest', language: 'python', language_version: '3.13' } + - { os: 'windows-latest', language: 'ruby', language_version: '3.3' } + - { os: 'windows-latest', language: 'ruby', language_version: '4.0' } + + # Limited matrix for macOS - runners are concurrency limited. + - { os: 'macos-latest', language: 'go', language_version: '1.21' } + - { os: 'macos-latest', language: 'node', language_version: '20.x' } + - { os: 'macos-latest', language: 'python', language_version: '3.13' } + - { os: 'macos-latest', language: 'ruby', language_version: '4.0' } + + - { os: 'macos-15-intel', language: 'go', language_version: '1.21' } + - { os: 'macos-15-intel', language: 'node', language_version: '20.x' } + - { os: 'macos-15-intel', language: 'python', language_version: '3.13' } + - { os: 'macos-15-intel', language: 'ruby', language_version: '4.0' } + + # Limited matrix for Ubuntu ARM - runners are paid and we're not sure of the cost yet. + - { os: 'ubuntu-latest-arm64', language: 'dotnet', language_version: '8.0.x' } + - { os: 'ubuntu-latest-arm64', language: 'go', language_version: '1.21' } + - { os: 'ubuntu-latest-arm64', language: 'java', language_version: '21' } + - { os: 'ubuntu-latest-arm64', language: 'node', language_version: '20.x' } + - { os: 'ubuntu-latest-arm64', language: 'python', language_version: '3.13' } + - { os: 'ubuntu-latest-arm64', language: 'ruby', language_version: '4.0' } + + runs-on: ${{ matrix.os }} + steps: + - run: git config --global core.autocrlf false + - *checkout + - *cache + - if: matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-latest-arm64' + run: | # Allow unshare for vortex. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_unconfined=0 + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - *select_xcode + - if: matrix.language == 'dotnet' + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: { dotnet-version: "${{ matrix.language_version }}" } + + - if: matrix.language == 'go' + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: { go-version: "${{ matrix.language_version }}" } + + - if: matrix.language == 'rust' + run: rustup default ${{ matrix.language_version }} && rustup component add clippy rustfmt + + - if: matrix.language == 'java' + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: { java-version: "${{ matrix.language_version }}", distribution: 'temurin'} + + - if: matrix.language == 'java' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + # actions/cache (as of v5.0.5) routinely flakes under windows -- it just prints + # "Cache hit for: Windows-X64-(hash)" and then exits with no other info. + continue-on-error: ${{ startsWith(runner.os, 'Windows') }} + with: + path: ~/.m2/repository + key: setup-java-${{ runner.os }}-${{ runner.arch }}-maven-${{ hashFiles('**/pom.xml') }} + + - if: matrix.language == 'node' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: { node-version: "${{ matrix.language_version }}" } + + - if: matrix.language == 'python' + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: { python-version: "${{ matrix.language_version }}" } + - if: matrix.language == 'python' + run: pip install pytest 'mypy<=1.18.2' + + - if: matrix.language == 'ruby' + uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1.308.0 + with: { ruby-version: "${{ matrix.language_version }}" } + + - run: ./zig/download.ps1 && ./zig/zig build --summary all ci -- ${{ matrix.language }} + + devhub: + runs-on: ubuntu-22.04 + environment: ${{ github.ref == 'refs/heads/main' && 'devhub' || '' }} + permissions: + pages: write + id-token: write + + steps: + - *checkout + - *cache + - run: sudo apt-get update && sudo apt-get install -y kcov + - run: sudo rm -rf /usr/local/lib/android # Free up disk space for benchmarking. + - run: ./zig/download.ps1 + + # Dummy devhub run - checks that all the devhub tests pass in CI. They are run again, in main + # once merged. Kcov is skipped to avoid adding to the pipeline time. + - if: github.ref != 'refs/heads/main' + run: sudo -E ./zig/zig build --summary all ci -- devhub-dry-run + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Not providing DEVHUBDB_PAT and NYRKIO_TOKEN stops devhub from uploading its results, but + # it still runs everything. + + # Run under sudo to enable memory locking for accurate RSS stats. + - if: github.ref == 'refs/heads/main' + run: sudo -E ./zig/zig build --summary all ci -- devhub + env: + DEVHUBDB_PAT: ${{ secrets.DEVHUBDB_PAT }} + NYRKIO_TOKEN: ${{ secrets.NYRKIO_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - if: github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: { path: ./src/devhub } + + - if: github.ref == 'refs/heads/main' + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + + + # Work around GitHub considering Skipped jobs success for "Require status checks before merging" + # See also: + # https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-status-checks-before-merging + # https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks#handling-skipped-but-required-checks + # https://stackoverflow.com/a/75250293 + core-pipeline: + if: always() && github.event_name == 'merge_group' + runs-on: ubuntu-latest + needs: [smoke, test, test_aof, clients, devhub] + steps: + - if: ${{ !(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }} + run: exit 0 + - if: ${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }} + run: exit 1 diff --git a/ocam/.github/workflows/release.yml b/ocam/.github/workflows/release.yml new file mode 100644 index 00000000..5466e018 --- /dev/null +++ b/ocam/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release +permissions: {} + +on: + workflow_dispatch: + +# Don't run release and release_validate simultaneously, to avoid races. +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + environment: release + permissions: + packages: write + contents: write + # Required for OIDC. + # See: https://docs.npmjs.com/trusted-publishers + id-token: write + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 2147483647 + fetch-tags: true # Fetch full history for tidy. + ref: release # Use the 'release' branch even if triggered manually + + - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + dotnet-version: + 8.0.x + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: '1.21' + + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: '11' + distribution: 'temurin' + server-id: central + server-username: MAVEN_USERNAME + server-password: MAVEN_CENTRAL_TOKEN + gpg-private-key: ${{ secrets.MAVEN_GPG_SECRET_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + + # No special setup for Go. + + # Rust: publish with the oldest supported release to ensure compatible lockfiles etc. + - run: rustup default 1.63 && rustup component add clippy rustfmt + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24.x' + registry-url: 'https://registry.npmjs.org' + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: 3.13 + - run: pip install twine + + - uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1.308.0 + with: + ruby-version: '4.0' + + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ./zig/cache + key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('./zig/download.sh') }} + - run: ./zig/download.sh + - run: ./zig/zig build --summary all scripts -- release --build --publish --sha=${{ github.sha }} + env: + NUGET_KEY: ${{ secrets.NUGET_KEY }} + TIGERBEETLE_GO_PAT: ${{ secrets.TIGERBEETLE_GO_PAT }} + TIGERBEETLE_DOCS_PAT: ${{ secrets.TIGERBEETLE_DOCS_PAT }} + MAVEN_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_SECRET_KEY_PASSWORD }} + TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} + TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} + CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} + ACTIONS_ID_TOKEN_REQUEST_TOKEN: ${{ env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }} + ACTIONS_ID_TOKEN_REQUEST_URL: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + alert_failure: + runs-on: ubuntu-latest + needs: [release] + if: ${{ always() && contains(needs.*.result, 'failure') }} + steps: + - name: Alert if anything failed + run: | + export URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" && \ + curl -d "text=Release process for ${{ github.run_number }} failed! See ${URL} for more information." -d "channel=C04RWHT9EP5" -H "Authorization: Bearer ${{ secrets.SLACK_TOKEN }}" -X POST https://slack.com/api/chat.postMessage diff --git a/ocam/.github/workflows/release_validate.yml b/ocam/.github/workflows/release_validate.yml new file mode 100644 index 00000000..814e2cb8 --- /dev/null +++ b/ocam/.github/workflows/release_validate.yml @@ -0,0 +1,72 @@ +name: "Release (validate)" +permissions: {} + +on: + workflow_dispatch: # Manual triggering for debugging + workflow_run: + workflows: ["Release"] + types: + - completed + + schedule: + # Schedule a validation run every six hours to make sure we catch any bugs due to changes + # in systems we do not control. + - cron: 0 */6 * * * + +# Don't run release and release_validate simultaneously, to avoid races. +concurrency: + group: release + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true # Remove once this is the default (June 2nd, 2026). + +jobs: + validate: + runs-on: ubuntu-latest + if: ${{ !(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure') }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 2147483647 + fetch-tags: true # Fetch full history for tidy. + ref: main + + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ./zig/cache + key: ${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('./zig/download.sh') }} + - run: ./zig/download.sh + + - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + dotnet-version: + 8.0.x + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: 'stable' + + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'temurin' + java-version: '21' + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 'latest' + + - run: ./zig/zig build scripts -- ci --validate-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + + alert_failure: + runs-on: ubuntu-latest + needs: [validate] + if: ${{ always() && contains(needs.*.result, 'failure') }} + steps: + - name: Alert if anything failed + run: | + export URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" && \ + curl -d "text=Release validation failed! See ${URL} for more information." -d "channel=C04RWHT9EP5" -H "Authorization: Bearer ${{ secrets.SLACK_TOKEN }}" -X POST https://slack.com/api/chat.postMessage diff --git a/ocam/.gitignore b/ocam/.gitignore new file mode 100644 index 00000000..38198d2e --- /dev/null +++ b/ocam/.gitignore @@ -0,0 +1,15 @@ +src/clients/c/lib +/tigerbeetle +/tigerbeetle.exe +*_*.tigerbeetle.* +*_*.tigerbeetle +.zig-cache/ +zig-cache/ +zig-out/ + +tools/ +# By convention, logs from simulator use .vopr extension. +*.vopr + +# Death to .DS_Store +.DS_Store diff --git a/ocam/CHANGELOG.md b/ocam/CHANGELOG.md new file mode 100644 index 00000000..4a5703bc --- /dev/null +++ b/ocam/CHANGELOG.md @@ -0,0 +1,6914 @@ +# Changelog + +Subscribe to the [announcements issue #2231](https://github.com/tigerbeetle/tigerbeetle/issues/2231) +to receive notifications about breaking changes and critical issues! + +## TigerBeetle 0.17.9 + +Released: 2026-07-03 + +### Safety And Performance + +- [#3810](https://github.com/tigerbeetle/tigerbeetle/pull/3810) + + Revert unique keys, which caused compatibility issues + (see [#3802](https://github.com/tigerbeetle/tigerbeetle/pull/3802)). + +- [#3822](https://github.com/tigerbeetle/tigerbeetle/pull/3822), + [#3844](https://github.com/tigerbeetle/tigerbeetle/pull/3844) + + Extend `inspect constants` to catch schema changes and prints + the `Checkpoint Schedule` section. + +- [#3845](https://github.com/tigerbeetle/tigerbeetle/pull/3845) + + Micro-optimization to the radix sort algorithm to avoid copies when dealing + with large stack-allocated values. + +- [#3840](https://github.com/tigerbeetle/tigerbeetle/pull/3840) + + Online schema validation, ensuring coherent `key_min`/`key_max` when reading data. + +### Features + +- [#3816](https://github.com/tigerbeetle/tigerbeetle/pull/3816) + + Improve the Ruby client migration guide. + +- [#3836](https://github.com/tigerbeetle/tigerbeetle/pull/3836), + [#3832](https://github.com/tigerbeetle/tigerbeetle/pull/3832), + [#3835](https://github.com/tigerbeetle/tigerbeetle/pull/3835), + [#3834](https://github.com/tigerbeetle/tigerbeetle/pull/3834) + + Various docs improvements, including adding the Ruby client and print layout support. + +- [#3830](https://github.com/tigerbeetle/tigerbeetle/pull/3830), + [#3811](https://github.com/tigerbeetle/tigerbeetle/pull/3811) + + Add a u128 bounds check in the Ruby client and make its status return type more idiomatic. + +- [#3827](https://github.com/tigerbeetle/tigerbeetle/pull/3827) + + Improve the Rust client, removing the bitflags dependency and enabling overflow checks. + +- [#3852](https://github.com/tigerbeetle/tigerbeetle/pull/3852) + + Experimental CLI `--memory` flag that automatically sizes the grid and object caches to use + all assigned memory. + +### Internals + +- [#3825](https://github.com/tigerbeetle/tigerbeetle/pull/3825), + [#3831](https://github.com/tigerbeetle/tigerbeetle/pull/3831) + + Improve various code comments. + +- [#3793](https://github.com/tigerbeetle/tigerbeetle/pull/3793) + + Switch to `stdx.SocketAddress`. + +- [#3826](https://github.com/tigerbeetle/tigerbeetle/pull/3826) + + Fail on curl instead of unzip for network flakes during Vortex builds. + +- [#3813](https://github.com/tigerbeetle/tigerbeetle/pull/3813) + + Expose `TimeSim` and fixtures in VSR. + +- [#3820](https://github.com/tigerbeetle/tigerbeetle/pull/3820) + + Introduce the canonical workload principle for benchmarks. + +- [#3682](https://github.com/tigerbeetle/tigerbeetle/pull/3682) + + Make publishing TigerBeetle client artifacts idempotent. + +- [#3817](https://github.com/tigerbeetle/tigerbeetle/pull/3817) + + Workaround MacOS builds on Zig 0.14.1 (see [issue](ttps://codeberg.org/ziglang/zig/issues/31658)). + +### TigerTracks 🎧 + +- [Home](https://www.youtube.com/watch?v=5Fc9A6mLHJU) + +## TigerBeetle 0.17.8 + +Released: 2026-06-19 + +### Safety And Performance + +- [#3802](https://github.com/tigerbeetle/tigerbeetle/pull/3802) + + Fix a bug in which the stash would overflow due to excess tombstones. + +- [#3798](https://github.com/tigerbeetle/tigerbeetle/pull/3798) + + Fuzz scans over mutable secondary indexes, and fix an assertion. + +- [#3797](https://github.com/tigerbeetle/tigerbeetle/pull/3797) + + Fix commit stall injection for clusters with 8 or more active clients. + +### Features + +- [#3787](https://github.com/tigerbeetle/tigerbeetle/pull/3787) + + Prepare Ruby client for publishing. + +- [#3792](https://github.com/tigerbeetle/tigerbeetle/pull/3792) + + Add gauge metric to record replica start time. + +### Internals + +- [#3795](https://github.com/tigerbeetle/tigerbeetle/pull/3795) + + Add case conversion helpers to `stdx`. + +- [#3781](https://github.com/tigerbeetle/tigerbeetle/pull/3781) + + Add `stdx.net` with IP address implementation. + +- [#3801](https://github.com/tigerbeetle/tigerbeetle/pull/3801) + + Add `FuzzIterations` abstraction to control fuzzer smoke test duration. + +### TigerTracks 🎧 + +- [The Fallen](https://www.youtube.com/watch?v=YsjHZXRGM1M) + +## TigerBeetle 0.17.7 + +Released: 2026-06-12 + +Note: it is advisable to skip 0.17.6 and 0.17.7 and upgrade directly to 0.17.8 due to a potential +crash. If you encounter the crash, it can be mitigated by temporarily increasing the object cache +size. See [#3802](https://github.com/tigerbeetle/tigerbeetle/pull/3802). + +### Safety And Performance + +- [#3786](https://github.com/tigerbeetle/tigerbeetle/pull/3786) + + Significantly improve the TigerBeetle REPL parser. Ensure that users see correct and useful error + messages, and fix some inconsistencies. + + Introduce snapshot testing and proper fuzzing, which allowed fixing some latent parsing bugs. + +### Internals + +- [#3779](https://github.com/tigerbeetle/tigerbeetle/pull/3779) + + Replace `hatchling` as the TigerBeetle python client packager with our own zig wheel builder, + which allows reproducible builds without dependencies. + +- [#3783](https://github.com/tigerbeetle/tigerbeetle/pull/3783) + + Allow passing timeouts for all shell operations called from zig tooling. + +- [#3785](https://github.com/tigerbeetle/tigerbeetle/pull/3785) + + Ensure CI release and release validation jobs don't run concurrently to reduce flakes induced by + CI job race conditions. + +### TigerTracks 🎧 + +- [むこう岸が視る夢](https://open.spotify.com/track/4NGcqePLnHw7Si5BrDzGSM) + +## TigerBeetle 0.17.6 + +Released: 2026-06-05 + +Note: it is advisable to skip 0.17.6 and 0.17.7 and upgrade directly to 0.17.8 due to a potential +crash. If you encounter the crash, it can be mitigated by temporarily increasing the object cache +size. See [#3802](https://github.com/tigerbeetle/tigerbeetle/pull/3802). + +### Safety And Performance + +- [#3778](https://github.com/tigerbeetle/tigerbeetle/pull/3778), + [#3762](https://github.com/tigerbeetle/tigerbeetle/pull/3762) + + Fix resource leak & potential overflow in the .NET, Java, and Go clients. + + Thank you @jf-li00 for spotting these! + +- [#3673](https://github.com/tigerbeetle/tigerbeetle/pull/3673), + [#3770](https://github.com/tigerbeetle/tigerbeetle/pull/3770) + + Substantially reduce tail latency by performing compaction merge incrementally + as opposed to a single big merge at the end of the bar. + +- [#3748](https://github.com/tigerbeetle/tigerbeetle/pull/3748) + + Make space for replica connections on the `accept` path, evicting clients or + random peers that may be hogging TCP connections. + +- [#3769](https://github.com/tigerbeetle/tigerbeetle/pull/3769) + + Various improvements to the ping-pong protocol between the client and cluster. + + Specifically, clients now proactively send a ping while registering a session + with the cluster (as opposed to waiting 30 seconds), and the cluster may now + respond with an eviction message when a ping is received (as opposed to + waiting for the next request from an evicted client). + +- [#3735](https://github.com/tigerbeetle/tigerbeetle/pull/3735) + + Improve fuzzing around LSM tree deletions on the lookup path, which now checks + for tombstones. + +- [#3774](https://github.com/tigerbeetle/tigerbeetle/pull/3774) + + Guarantee that the StateMachine works on cache-line-aligned buffers; callers + can take advantage of this alignment to efficiently reinterpret the bytes. + +### Internals + +- [#3780](https://github.com/tigerbeetle/tigerbeetle/pull/3780) + + Ban the usage of `std.fmt.parseInt`, which is useful for interactive use-cases + like CLI/REPL, but a bit too permissive for protocols. + +### TigerTracks 🎧 + +- [Everything In Its Right Place](https://open.spotify.com/track/2kRFrWaLWiKq48YYVdGcm8) + +## TigerBeetle 0.17.5 + +Released: 2026-05-29 + +Note: it is advisable to skip 0.17.5 and upgrade directly to 0.17.8 due to a potential crash on +upgrade (see [#3808](https://github.com/tigerbeetle/tigerbeetle/issues/3808)). + +### Safety And Performance + +- [#3744](https://github.com/tigerbeetle/tigerbeetle/pull/3744) + + Fix a possible Node.js integer overflow panic. + +- [#3717](https://github.com/tigerbeetle/tigerbeetle/pull/3717) + + Fix a potential crash in the I/O event listener. + +- [#3709](https://github.com/tigerbeetle/tigerbeetle/pull/3709) + + Correct assertions in the code. + +- [#3592](https://github.com/tigerbeetle/tigerbeetle/pull/3592) + + Run DISCARD when formatting a block device on Linux, improving SSD performance. + +- [#3712](https://github.com/tigerbeetle/tigerbeetle/pull/3712) + + Replace Java's assert keyword with `AssertionError.assertTrue` to prevent silent failures. + +- [#3686](https://github.com/tigerbeetle/tigerbeetle/pull/3686) + + Refactor the grid to own all blocks through reference counting. + +- [#3702](https://github.com/tigerbeetle/tigerbeetle/pull/3702), + [#3730](https://github.com/tigerbeetle/tigerbeetle/pull/3730) + + Inject stalls on the primary more accurately by observing a backup's + `op - commit_min`, improving throughput. + +- [#3704](https://github.com/tigerbeetle/tigerbeetle/pull/3704) + + Fan out VSR prepare timeout retries. + +- [#3729](https://github.com/tigerbeetle/tigerbeetle/pull/3729) + + Explicitly fail on invalid enum values in AMQP parsing per Swival security audit recommendation. + +- [#3726](https://github.com/tigerbeetle/tigerbeetle/pull/3726) + + Add an assertion to `CheckpointTrailer.open` as per Swival security audit recommendation. + +### Features + +- [#3733](https://github.com/tigerbeetle/tigerbeetle/pull/3733) + + Add a Ruby client. Thanks @citizen428! + +- [#3750](https://github.com/tigerbeetle/tigerbeetle/pull/3750) + + Switch from `ArenaAllocator` to `DebugAllocator` to fix `ArenaAllocator` + rounding up allocations by large amounts. + +### Internals + +- [#3753](https://github.com/tigerbeetle/tigerbeetle/pull/3753) + + Improve the names of some VSR commands. + +- [#3763](https://github.com/tigerbeetle/tigerbeetle/pull/3763) + + Make release validation run from `main`, making it easier to fix when release validation fails. + +- [#3746](https://github.com/tigerbeetle/tigerbeetle/pull/3746), + [#3752](https://github.com/tigerbeetle/tigerbeetle/pull/3752) + + Move `shell.zig` into `stdx`, making it more robust for different zig project setups. + +- [#3745](https://github.com/tigerbeetle/tigerbeetle/pull/3745) + + Show the freshness of performance results in DevHub. + +- [#3716](https://github.com/tigerbeetle/tigerbeetle/pull/3716), + [#3710](https://github.com/tigerbeetle/tigerbeetle/pull/3710) + + Cleanup `Time` usage and drop `std.time.Timer` uses ahead of its removal in Zig 0.16. + +- [#3721](https://github.com/tigerbeetle/tigerbeetle/pull/3721) + + Raise the clock-skew warning threshold from 1ms to 50ms to prevent CI flake. + +- [#3703](https://github.com/tigerbeetle/tigerbeetle/pull/3703) + + Introduce configurable primary keys and multiple unique keys to LSM Forests. + This enables querying two-phase transfers by `pending_id`. + +- [#3711](https://github.com/tigerbeetle/tigerbeetle/pull/3711) + + Fix various tb_client issues, enable Rust client CI on Windows. + +- [#3734](https://github.com/tigerbeetle/tigerbeetle/pull/3734) + + Remove the `cancel_all` feature used to shutdown tb_client on Linux, instead terminate message bus + connections explicitly. + +- [#3724](https://github.com/tigerbeetle/tigerbeetle/pull/3724) + + Dial up concurrency in the Node.js client test to reliably race `destroy` against `create`. + +- [#3736](https://github.com/tigerbeetle/tigerbeetle/pull/3736), + [#3738](https://github.com/tigerbeetle/tigerbeetle/pull/3738) + + Fix flaky Vörtex and Windows cache CI failures. + +### TigerTracks 🎧 + +- [Weird Fishes](https://open.spotify.com/track/5ygk8Hkp4WCCk7GXWEUP9V) + +## TigerBeetle 0.17.4 + +Released: 2026-05-08 + +As of TigerBeetle 0.17.4, the minimum required Linux kernel version is 5.11. + +### Safety And Performance + +- [#3619](https://github.com/tigerbeetle/tigerbeetle/pull/3619) + + Refactor and simplify the Linux IO event loop, by directly embedding a timeout when calling + `io_uring_enter`, making `next_tick` a top level function (instead of executing all callbacks + back to back) and reentering the kernel after each callback. + + Combined, these changes give a ~8% improvement on the standard benchmark, improve tail latencies + in certain edge cases, as well as prevent tick starvation under heavy load or slow disks. + +- [#3701](https://github.com/tigerbeetle/tigerbeetle/pull/3701) + + Fix a bug in reformat caused by invoking `IO.run` from within `IO.run_for_ns`. + +- [#3680](https://github.com/tigerbeetle/tigerbeetle/pull/3680) + + Track the object caches as metrics, to help with sizing (e.g., `--cache-accounts`). + +- [#3693](https://github.com/tigerbeetle/tigerbeetle/pull/3693) + + Fix file permissions to no longer ask for world readable/writable files when creating them. + Normally, this does get prevented by the default `umask`. + +### Internals + +- [#3698](https://github.com/tigerbeetle/tigerbeetle/pull/3698) + + Add warnings when the event loop is slow. + +- [#3694](https://github.com/tigerbeetle/tigerbeetle/pull/3694), + [#3684](https://github.com/tigerbeetle/tigerbeetle/pull/3684) + + Cache the Zig download for test workflows, add the flake count to Devhub and + miscellaneous CI fixes. + +### TigerTracks 🎧 + +- [Das alte Haus von Rocky Docky](https://www.youtube.com/watch?v=c4b1IMYFBso) + +## TigerBeetle 0.17.3 + +Released: 2026-05-01 + +### Safety And Performance + +- [#3669](https://github.com/tigerbeetle/tigerbeetle/pull/3669), + [#3668](https://github.com/tigerbeetle/tigerbeetle/pull/3668) + + Switch replication strategy from adaptive replication routing to star. + + Star replication involves the primary broadcasting prepares to all + backups and handles network jitter better, leading to lower p100 + latencies in cross-region deployments. + +- [#3665](https://github.com/tigerbeetle/tigerbeetle/pull/3665), + [#3671](https://github.com/tigerbeetle/tigerbeetle/pull/3671), + [#3676](https://github.com/tigerbeetle/tigerbeetle/pull/3676) + + Guard against supply-chain attacks via GitHub actions by pinning + them to the latest commit SHAs. + +- [#3659](https://github.com/tigerbeetle/tigerbeetle/pull/3659) + + Recompute the checksum body only for operations that modify the body. + +- [#3679](https://github.com/tigerbeetle/tigerbeetle/pull/3679) + + Simplify the control flow of Zig-Zag merge using + [galloping search](https://en.wikipedia.org/wiki/Exponential_search), + to drain the keys and avoid rebuilding the tree multiple times. + +### Internals + +- [#3678](https://github.com/tigerbeetle/tigerbeetle/pull/3678), + [#3675](https://github.com/tigerbeetle/tigerbeetle/pull/3675) + + Workaround various flakes caused by GitHub actions. + + Specifically, actions cache flakes on Windows, and `gh run list` + sometimes returns no response. + +- [#3672](https://github.com/tigerbeetle/tigerbeetle/pull/3672) + + Fix a flaky client test due to an overly conservative timeout + for expiring transfers. + +- [#3674](https://github.com/tigerbeetle/tigerbeetle/pull/3674) + + Make release validation work cross-platform. + +- [#3683](https://github.com/tigerbeetle/tigerbeetle/pull/3683) + + Fix the Go client documentation for posting/voiding pending transfers. + +### TigerTracks 🎧 + +- [Airbag](https://open.spotify.com/track/7c378mlmubSu7NGkLFa4sN) + +## TigerBeetle 0.17.2 + +Released: 2026-04-24 + +### Safety And Performance + +- [#3660](https://github.com/tigerbeetle/tigerbeetle/pull/3660) + + When a replica sends a message to itself, copy in message body before computing + `checksum_body` and `checksum`. + +### Features + +- [#3657](https://github.com/tigerbeetle/tigerbeetle/pull/3657) + + Track `value_count_visible` as a metric, per tree. + This is useful for counting the number of accounts/transfers in the database. + +### Internals + +- [#3661](https://github.com/tigerbeetle/tigerbeetle/pull/3661) + + Test that checksum works on unaligned data. + +### TigerTracks 🎧 + +- [Line Between](https://www.youtube.com/watch?v=bF_w7mGjwB4) + +## TigerBeetle 0.17.1 + +Released: 2026-04-17 + +### Safety And Performance + +- [#3643](https://github.com/tigerbeetle/tigerbeetle/pull/3643) + + Make VSR repair less eager, cutting `request_prepare` traffic by ~70-80% and + `request_headers` by ~80-90% for minor overhead. + +- [#3600](https://github.com/tigerbeetle/tigerbeetle/pull/3600) + + Improve compaction table-selection from `O(a * log b)` to `O(a + b)`, especially + noticeable at 20TB+. + +### Internals + +- [#3637](https://github.com/tigerbeetle/tigerbeetle/pull/3637) + + Refactor `stdx.flags` to own its allocator and return trailing `--` args as a slice. + +- [#3642](https://github.com/tigerbeetle/tigerbeetle/pull/3642) + + Rename VSR view-change messages: `DoViewChange` -> `JoinView`, `StartView` -> `View`, + `StartViewChange` -> `ExitView`. + +- [#3652](https://github.com/tigerbeetle/tigerbeetle/pull/3652) + + Re-enable VOPR in CI. + +- [#3649](https://github.com/tigerbeetle/tigerbeetle/pull/3649) + + Increase tick budget for the Vortex upgrade/recover test, which was occasionally + timing out. + +- [#3647](https://github.com/tigerbeetle/tigerbeetle/pull/3647) + + CI: fetch git tags in the `release-validate` job. + +- [#3648](https://github.com/tigerbeetle/tigerbeetle/pull/3648) + + Docs: drop an overly wide comparison table from the API changes page. + +- [#3650](https://github.com/tigerbeetle/tigerbeetle/pull/3650), + [#3651](https://github.com/tigerbeetle/tigerbeetle/pull/3651) + + Docs: remove references to the retired Startup Program. + +- [#3653](https://github.com/tigerbeetle/tigerbeetle/pull/3653) + + Clean up TigerTracks in CHANGELOG.md by only using song titles. + +### TigerTracks 🎧 + +- [Golden Times](https://www.youtube.com/watch?v=EvgaGxL30HM) + +## TigerBeetle 0.17.0 + +Released: 2026-04-10 + +This release improves `create_accounts` and `create_transfers` operations. They now return +timestamps of newly created objects, allowing the application to learn the resulting global order +of events without additional roundtrips. As usual, this release remains wire-compatible with +previous clients. To gain access to the new APIs, please upgrade clients to version 0.17.0. Refer +to the [API changes](https://docs.tigerbeetle.com/coding/api-changes) page for details. + +### Safety And Performance + +- [#3599](https://github.com/tigerbeetle/tigerbeetle/pull/3599) + + Improve repair performance by tracking per-replica repair budgets, to prevent a slow replica + from tying up the global budget. + +- [#3618](https://github.com/tigerbeetle/tigerbeetle/pull/3618), + [#3623](https://github.com/tigerbeetle/tigerbeetle/pull/3623), + [#3630](https://github.com/tigerbeetle/tigerbeetle/pull/3630), + [#3638](https://github.com/tigerbeetle/tigerbeetle/pull/3638) + + Add randomness to repair timeouts and faulty block iteration, to guard against + resonance liveness bugs. + +- [#3612](https://github.com/tigerbeetle/tigerbeetle/pull/3612) + + Test cluster upgrades using real TigerBeetle binaries in Vortex. + +### Features + +- [#3258](https://github.com/tigerbeetle/tigerbeetle/pull/3258) + + Change the return type and semantics of the `create_{accounts,transfers}`. + +- [#3574](https://github.com/tigerbeetle/tigerbeetle/pull/3574) + + Add the API changes page to the docs: . + +- [#3624](https://github.com/tigerbeetle/tigerbeetle/pull/3624) + + Remove default initialization from Python client's `AccountFilter` and `QueryFilter`. + +- [#3607](https://github.com/tigerbeetle/tigerbeetle/pull/3607) + + Querying with a `limit` which would not fit in the maximum message size now + fails with `TooMuchData` in the client, rather than silently truncating results. + +- [#3593](https://github.com/tigerbeetle/tigerbeetle/pull/3593) + + Add metric tracking recent client min/max releases seen by the cluster. + +- [#3617](https://github.com/tigerbeetle/tigerbeetle/pull/3617) + + Fix several bugs in the `client_request_round_trip` metric. + +- [#3620](https://github.com/tigerbeetle/tigerbeetle/pull/3620) + + Make the Golang client API more idiomatic using `*big.Int`. + +- [#3628](https://github.com/tigerbeetle/tigerbeetle/pull/3628) + + While generating a TigerBeetle time-based ID, clients now wrap the random + bits and increment the timestamp bits when the former overflows. + + Earlier, clients panicked when random bits overflowed on increment. + +### Internals + +- [#3609](https://github.com/tigerbeetle/tigerbeetle/pull/3609) + + Cache Zig compiler on CI. + +- [#3604](https://github.com/tigerbeetle/tigerbeetle/pull/3604), + [#3622](https://github.com/tigerbeetle/tigerbeetle/pull/3622) + + IO/Linux: Add another attempt to the flock() retry loop. + +- [#3634](https://github.com/tigerbeetle/tigerbeetle/pull/3634), + [#3629](https://github.com/tigerbeetle/tigerbeetle/pull/3629), + [#3635](https://github.com/tigerbeetle/tigerbeetle/pull/3635) + + Various Vortex and CFO fixes. + +- [#3632](https://github.com/tigerbeetle/tigerbeetle/pull/3632) + + Docs: Clarify the number of replicas required for upgrades. + +- [#3595](https://github.com/tigerbeetle/tigerbeetle/pull/3595) + + Docs: Fix a flipped account flag in `balance-invariant-transfers.md`. + +### TigerTracks 🎧 + +- [Картины Босха](https://www.youtube.com/watch?v=bJIpxObvd7A) + +## TigerBeetle 0.16.78 + +Released: 2026-03-20 + +### Safety And Performance + +- [#3581](https://github.com/tigerbeetle/tigerbeetle/pull/3581) + + Fix an over-tight assert -- we may still be writing non-repair blocks at the start of + checkpointing. + +- [#3586](https://github.com/tigerbeetle/tigerbeetle/pull/3586) + + Fix a potential liveness issue where we try to repair a block while simultaneously writing to it. + +### Internals + +- [#3585](https://github.com/tigerbeetle/tigerbeetle/pull/3585) + + `tigerbeetle inspect constants` now prints out the number of Transfers per table. + +- [#3579](https://github.com/tigerbeetle/tigerbeetle/pull/3579), + [#3589](https://github.com/tigerbeetle/tigerbeetle/pull/3589), + [#3591](https://github.com/tigerbeetle/tigerbeetle/pull/3591), + [#3584](https://github.com/tigerbeetle/tigerbeetle/pull/3584), + [#3594](https://github.com/tigerbeetle/tigerbeetle/pull/3594) + + Fix the CI release validation job. + +- [#3583](https://github.com/tigerbeetle/tigerbeetle/pull/3583) + + Refactor our iops.zig to a more appropriate home in stdx. + +### TigerTracks 🎧 + +- [Rage Room](https://open.spotify.com/track/1EDaSjhCkBMJ8RI2Xc6k4H) + +## TigerBeetle 0.16.77 + +Released: 2026-03-13 + +Note: it is advisable to skip 0.16.77 and upgrade directly to 0.16.78, due to potential liveness +issues (see [#3581](https://github.com/tigerbeetle/tigerbeetle/pull/3581) and +[#3586](https://github.com/tigerbeetle/tigerbeetle/pull/3586)). + +### Safety And Performance + +- [#3485](https://github.com/tigerbeetle/tigerbeetle/pull/3485) + + Rework how eviction is signaled in `tb_client` to avoid race conditions between eviction and + shutdown, unify notification logic, and add assertions for running on the correct thread. + +- [#3553](https://github.com/tigerbeetle/tigerbeetle/pull/3553) + + Optimize compaction performance by removing IO stalls at the end of each beat, allowing IO to + overlap with other work and increasing throughput by ~4%. + +- [#3567](https://github.com/tigerbeetle/tigerbeetle/pull/3567) + + Allow the grid to serve reads directly from write queues, improving performance for blocks being + currently repaired or created. + +- [#3575](https://github.com/tigerbeetle/tigerbeetle/pull/3575) + + Work around a miscompilation in Zig's LLVM backend by downcasting alignment in `stdx.equal_bytes`. + +### Internals + +- [#3572](https://github.com/tigerbeetle/tigerbeetle/pull/3572) + + Prepare the Rust client for release by removing dependencies (`anyhow`, `futures-channel`), + and setting up automated release builds (not enabled yet). + +- [#3537](https://github.com/tigerbeetle/tigerbeetle/pull/3537) + + Fuzz updates on `CompositeKeys`, identifying a bug in a proposed prefix-only sort optimization. + +- [#3566](https://github.com/tigerbeetle/tigerbeetle/pull/3566) + + Add a new metric to track time spent on CPU work, specifically callbacks. + +- [#3569](https://github.com/tigerbeetle/tigerbeetle/pull/3569) + + Tighten units for IOPS, concurrency, and durations in config. + +- [#3576](https://github.com/tigerbeetle/tigerbeetle/pull/3576) + + Fix worst case sizing and add prefetch in the scan fuzzer, to mirror state machine logic. + +### TigerTracks 🎧 + +- [Stairway To Heaven](https://www.youtube.com/watch?v=Ly6ZhQVnVow) + +## TigerBeetle 0.16.76 + +Released: 2026-03-06 + +### Features + +- [#3556](https://github.com/tigerbeetle/tigerbeetle/pull/3556) + + Expose CLI arguments to diagnose connectivity issues with AMQP and + TigerBeetle during CDC. + + Users can now specify `--amqp-timeout-seconds` and `--tigerbeetle-timeout-seconds` + (both default to 30), which define the maximum time to wait for a reply + from AMQP and TigerBeetle respectively. + +- [#3565](https://github.com/tigerbeetle/tigerbeetle/pull/3565) + + Record request timing metrics for reserved VSR operations. + + This gives users visibility into the latency of client registrations (the + first request a client sends to the cluster), using the `register` operation. + +### Internals + +- [#3543](https://github.com/tigerbeetle/tigerbeetle/pull/3543) + + Add tidy check to flag functions with length [71, 72], as per TigerStyle. + + This check intentionally incentivizes shortening the length to <=70 for + functions that are _just_ over, as a starting point towards the 70-line limit. + +- [#3561](https://github.com/tigerbeetle/tigerbeetle/pull/3561), + [#3560](https://github.com/tigerbeetle/tigerbeetle/pull/3560), + [#3559](https://github.com/tigerbeetle/tigerbeetle/pull/3559), + [#3558](https://github.com/tigerbeetle/tigerbeetle/pull/3558) + + Various documentation improvements. + +- [#3552](https://github.com/tigerbeetle/tigerbeetle/pull/3552) + + Use a fixed, high retry delay instead of exponential backoff to work + around GitHub availability. + +- [#3568](https://github.com/tigerbeetle/tigerbeetle/pull/3568) + + Various refactors and debug logging improvements in compaction. + +- [#3555](https://github.com/tigerbeetle/tigerbeetle/pull/3555) + + Add logging to clarify the semantics of multiversion upgrade polling. + +### TigerTracks 🎧 + +- [What I've Done](https://open.spotify.com/track/18lR4BzEs7e3qzc0KVkTpU) + +## TigerBeetle 0.16.75 + +Released: 2026-02-27 + +### Safety And Performance + +- [#3529](https://github.com/tigerbeetle/tigerbeetle/pull/3529) + + Fix the fast path check for `key_range_contains`. Previously, the fast path would never be taken + due to checking `snapshot_latest` incorrectly. + +- [#3545](https://github.com/tigerbeetle/tigerbeetle/pull/3545) + + Enable transparent huge pages on Linux - relieving TLB pressure and giving a nice little + throughput bump! + +### Internals + +- [#3549](https://github.com/tigerbeetle/tigerbeetle/pull/3549), + [#3547](https://github.com/tigerbeetle/tigerbeetle/pull/3547), + [#3538](https://github.com/tigerbeetle/tigerbeetle/pull/3538) + + Rage against the (GitHub) Machine. + +- [#3541](https://github.com/tigerbeetle/tigerbeetle/pull/3541) + + Fix an overestimation of the number of index beats that will be generated by compaction. + +### TigerTracks 🎧 + +- [Sleep Now in the Fire](https://www.youtube.com/watch?v=kl4wkIPiTcY) + +## TigerBeetle 0.16.74 + +Released: 2026-02-20 + +### Safety And Performance + +- [#3528](https://github.com/tigerbeetle/tigerbeetle/pull/3528) + + Add a replica test for an asymmetric partition where the primary can never receive + messages, yet still does not abdicate. This test documents the current behavior, + which must be fixed. + +### Internals + +- [#3400](https://github.com/tigerbeetle/tigerbeetle/pull/3400) + + Refactor LSM scan direction handling to simplify key comparisons in ascending + and descending order. + +- [#3518](https://github.com/tigerbeetle/tigerbeetle/pull/3518) + + Reduce the internal I/O thread stack size of the TigerBeetle client to 512 KiB. + +- [#3525](https://github.com/tigerbeetle/tigerbeetle/pull/3525) + + Bump the minimum `rustc` version to 1.71 for building the TigerBeetle Rust client. + +### TigerTracks 🎧 + +- [Don't Stop Me Now](https://www.youtube.com/watch?v=HgzGwKwLmgM) + +## TigerBeetle 0.16.73 + +Released: 2026-02-13 + +### Safety And Performance + +- [#3505](https://github.com/tigerbeetle/tigerbeetle/pull/3505) + + Making AOF recovery deterministic, and prevent interference from non-aof-recovery requests. + +- [#3508](https://github.com/tigerbeetle/tigerbeetle/pull/3508), + [#3516](https://github.com/tigerbeetle/tigerbeetle/pull/3516) + + Improve Rust client testing. + +### Internals + +- [#3511](https://github.com/tigerbeetle/tigerbeetle/pull/3511) + + Strip tracking parameters from Spotify links. + +- [#3517](https://github.com/tigerbeetle/tigerbeetle/pull/3517) + + Include vortex zig driver executable in release artifacts, + in preparation for multiversion testing in vortex. + +- [#3522](https://github.com/tigerbeetle/tigerbeetle/pull/3522) + + Add metrics tracking LSM write amplification. + +- [#3523](https://github.com/tigerbeetle/tigerbeetle/pull/3523) + + Reduce the frequency of metrics tracking in the message bus. + +### TigerTracks 🎧 + +- [Never Gonna Give You Up](https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=RDdQw4w9WgXcQ) + +## TigerBeetle 0.16.72 + +Released: 2026-02-06 + +### Safety And Performance + +- [#3471](https://github.com/tigerbeetle/tigerbeetle/pull/3471) + + Dramatically improve View Change time with a new `FaultDetector` that estimates + the probability of a primary crash. + +### Features + +- [#3484](https://github.com/tigerbeetle/tigerbeetle/pull/3484) + + Track `commit_timestamp` and `message_bus` connections. + +- [#3497](https://github.com/tigerbeetle/tigerbeetle/pull/3497) + [#3421](https://github.com/tigerbeetle/tigerbeetle/pull/3421) + + Add support for rotating AOF files without signals. + +### Internals + +- [#3491](https://github.com/tigerbeetle/tigerbeetle/pull/3491) + [#3496](https://github.com/tigerbeetle/tigerbeetle/pull/3496) + [#3494](https://github.com/tigerbeetle/tigerbeetle/pull/3494) + [#3490](https://github.com/tigerbeetle/tigerbeetle/pull/3490) + [#3495](https://github.com/tigerbeetle/tigerbeetle/pull/3495) + [#3489](https://github.com/tigerbeetle/tigerbeetle/pull/3489) + [#3488](https://github.com/tigerbeetle/tigerbeetle/pull/3488) + + Various documentation improvements and clarifications. + +- [#3503](https://github.com/tigerbeetle/tigerbeetle/pull/3503) + + Clarify that PR descriptions are ephemeral and do not replace well-written commit messages. + +- [#3504](https://github.com/tigerbeetle/tigerbeetle/pull/3504) + + Capture additional Vortex logs during end-to-end testing. + +- [#3499](https://github.com/tigerbeetle/tigerbeetle/pull/3499) + [#3501](https://github.com/tigerbeetle/tigerbeetle/pull/3501) + [#3498](https://github.com/tigerbeetle/tigerbeetle/pull/3498) + + Code cleanup and an increase in the maximum number of accounts supported by the benchmark. + +### TigerTracks 🎧 + +- [Sonderling](https://www.youtube.com/watch?v=UxLEKVMpN48) + +## TigerBeetle 0.16.70 + +Released: 2026-01-31 + +### Safety And Performance + +- [#3482](https://github.com/tigerbeetle/tigerbeetle/pull/3482) + + Add basic tracing/metrics to event loop times. Also fix a bug in "max" timing metrics. + +- [#3475](https://github.com/tigerbeetle/tigerbeetle/pull/3475) + + Rewrite and improve performance of tree-of-losers k-way-merge implementation. + +- [#3479](https://github.com/tigerbeetle/tigerbeetle/pull/3479) + + Use `callconv(.@"inline")` for radix sort `key_from_value` function. + +- [#3478](https://github.com/tigerbeetle/tigerbeetle/pull/3478) + + Add `--id-order=tbid` to benchmark. This makes the benchmark more accurately resemble a + real-world workload. Additionally, with this setting multiple runs of the benchmark will not + encounter id collisions. + +- [#3476](https://github.com/tigerbeetle/tigerbeetle/pull/3476) + + Prefetch index blocks during compaction, so that we don't need to block waiting for the read. + +### Features + +- [#3453](https://github.com/tigerbeetle/tigerbeetle/pull/3453) + + Ensure public contract is stated in the `tb_client` header file. + +### Internals + +- [#3480](https://github.com/tigerbeetle/tigerbeetle/pull/3480) + + Make CFO crash less easily if fuzzer behaves unexpectedly. + Also make local testing of CFO more similar to real CFO execution. + +### TigerTracks 🎧 + +- [DreamFlux](https://www.youtube.com/watch?v=4IOf4D6SAO0) + +## TigerBeetle 0.16.69 + +Released: 2026-01-23 + +### Features + +- [#3464](https://github.com/tigerbeetle/tigerbeetle/pull/3464) + + Document client cancellation policy. + +- [#3460](https://github.com/tigerbeetle/tigerbeetle/pull/3460) + + Move [ARCHITECTURE.md](https://github.com/tigerbeetle/tigerbeetle/blob/main/docs/ARCHITECTURE.md) + up in the docs hierarchy. It's a part of internal docs, but is well worth reading for anyone + curious about TigerBeetle internals! + +### Safety And Performance + +- [#3426](https://github.com/tigerbeetle/tigerbeetle/pull/3426) + + Enable history by default for `tigerbeetle benchmark`. Argument-less `benchmark` captures our + current understanding of a representative workload, so the results are not guaranteed to be + comparable across `tigerbeetle` versions. + +### Internals + +- [#3468](https://github.com/tigerbeetle/tigerbeetle/pull/3468) + + Tighten up CLI parsing to forbid accidentally allowed signs. E.g, `--cluster=+0` is now an error. + +- [#3467](https://github.com/tigerbeetle/tigerbeetle/pull/3467) + + Make experimental AOF feature a run-time, rather than compile-time flag. + +- [#3465](https://github.com/tigerbeetle/tigerbeetle/pull/3465) + + Simplify debugging devhub by cloning (but not pushing) devhub-db during local development. + +- [#3457](https://github.com/tigerbeetle/tigerbeetle/pull/3457) + + Use `--cache-grid` to reduce memory in tests of Rust client. + +- [#3440](https://github.com/tigerbeetle/tigerbeetle/pull/3440) + + Simplify state machine by using actual op number instead of symbolic `snapshot_latest` for LSM + lookups. + +### TigerTracks 🎧 + +- [Model](https://open.spotify.com/track/2hFeOGfy9HmGs6kS6FuJHd) + +## TigerBeetle 0.16.68 + +Released: 2026-01-09 + +### Safety And Performance + +- [#3436](https://github.com/tigerbeetle/tigerbeetle/pull/3436) + + Fix a potential resource leak in `SegmentedArray` during initialization failure. + +### Features + +- [#3454](https://github.com/tigerbeetle/tigerbeetle/pull/3454) + + Rust client: Display platform information in the error message when attempting to run on + unsupported platforms. + +- [#3449](https://github.com/tigerbeetle/tigerbeetle/pull/3449) + + Node client: Switch to `process.report.getReport()` for more reliable Musl libc detection. + +### Internals + +- [#3458](https://github.com/tigerbeetle/tigerbeetle/pull/3458) + + Docs: Clarify the prose in the balance-invariant transfer recipe. + +- [#3455](https://github.com/tigerbeetle/tigerbeetle/pull/3455) + + Refactor `DateTimeUTC` to `InstantUnix` for tracking real, non-monotonic time. + +- [#3452](https://github.com/tigerbeetle/tigerbeetle/pull/3452) + + Refactor Vörtex network handling. + +- [#3432](https://github.com/tigerbeetle/tigerbeetle/pull/3432) + + Remove confusing "slow request" warning from VSR logs. + +- [#3439](https://github.com/tigerbeetle/tigerbeetle/pull/3439) + [#3448](https://github.com/tigerbeetle/tigerbeetle/pull/3448) + + Improve logging consistency for headers and checksums. Thanks @fdesu! + +- [#3435](https://github.com/tigerbeetle/tigerbeetle/pull/3435) + [#3438](https://github.com/tigerbeetle/tigerbeetle/pull/3438) + [#3434](https://github.com/tigerbeetle/tigerbeetle/pull/3434) + [#3430](https://github.com/tigerbeetle/tigerbeetle/pull/3430) + + Various microbenchmark improvements for checksums, k-way-merge, and binary search. + +- [#3444](https://github.com/tigerbeetle/tigerbeetle/pull/3444) + [#3441](https://github.com/tigerbeetle/tigerbeetle/pull/3441) + [#3422](https://github.com/tigerbeetle/tigerbeetle/pull/3422) + + `stdx`: Remove `stream_precedence`, improve shuffle implementation robustness, and document + monotonicity of `Instant`. + +- [#3451](https://github.com/tigerbeetle/tigerbeetle/pull/3451) + [#3450](https://github.com/tigerbeetle/tigerbeetle/pull/3450) + [#3447](https://github.com/tigerbeetle/tigerbeetle/pull/3447) + [#3428](https://github.com/tigerbeetle/tigerbeetle/pull/3428) + + CI and Build: Reduce Java flakiness, remove APK workarounds, simplify release validation, and + deduplicate `tb_client` builds. + +- [#3431](https://github.com/tigerbeetle/tigerbeetle/pull/3431) + [#3425](https://github.com/tigerbeetle/tigerbeetle/pull/3425) + + Devhub: Throw `Error` instead of `String` in asserts, and support more units. + +- [#3446](https://github.com/tigerbeetle/tigerbeetle/pull/3446) + [#3437](https://github.com/tigerbeetle/tigerbeetle/pull/3437) + [#3445](https://github.com/tigerbeetle/tigerbeetle/pull/3445) + [#3427](https://github.com/tigerbeetle/tigerbeetle/pull/3427) + [#3419](https://github.com/tigerbeetle/tigerbeetle/pull/3419) + + Minor fixes, tidy ups, and documentation improvements. Thanks @dsp and @amiraliakbari! + +### TigerTracks 🎧 + +- [Wie viele Hände hat der Octopus?](https://www.youtube.com/watch?v=yTLa2VXkflE) + +## TigerBeetle 0.16.67 + +Released: 2025-12-12 + +### Safety And Performance + +- [#3415](https://github.com/tigerbeetle/tigerbeetle/pull/3415) + + Speed up VOPR storage and network during liveness mode for fast convergence, to avoid false + positives. + +- [#3402](https://github.com/tigerbeetle/tigerbeetle/pull/3402) + + Don't discard high 16 bits of timestamp when generating ID's with the Node.js client. + +- [#3411](https://github.com/tigerbeetle/tigerbeetle/pull/3411) + + Relax VOPR condition for truncating acked ops to prevent false positives. + +- [#3405](https://github.com/tigerbeetle/tigerbeetle/pull/3405) + + Implement a micro-benchmarking harness, to protect the benchmarks from bitrot. + +### Features + +- [#3386](https://github.com/tigerbeetle/tigerbeetle/pull/3386) + + Add more documentation details around query multibatching. + +### Internals + +- [#3414](https://github.com/tigerbeetle/tigerbeetle/pull/3414) + + Make `Pending!?Value` the canonical stream signature. This type is chosen because it is the + orthogonal composition of iteration and asynchrony. + +- [#3417](https://github.com/tigerbeetle/tigerbeetle/pull/3417) + + Prevent "out (disk) of space" failures during CI. + +- [#3396](https://github.com/tigerbeetle/tigerbeetle/pull/3396) + + Make DSL for parsing positional CLI flags more natural. + +- [#3375](https://github.com/tigerbeetle/tigerbeetle/pull/3375) + + Remove dead code from zig-zag merge join. + +- [#3389](https://github.com/tigerbeetle/tigerbeetle/pull/3389) + + Improve how Vörtex handles SIGTERM. + +- [#3391](https://github.com/tigerbeetle/tigerbeetle/pull/3391) + [#3394](https://github.com/tigerbeetle/tigerbeetle/pull/3394) + + Improve the way we handle ratios in stdx, and refactor `parse_flag_value`. + +- [#3395](https://github.com/tigerbeetle/tigerbeetle/pull/3395) + + Update TigerStyle with more context for the line and function length limits. + +- [#3390](https://github.com/tigerbeetle/tigerbeetle/pull/3390) + + Improve some spelling and grammar in `start.md`. Thanks @elness! + +### TigerTracks 🎧 + +- [The Ominous Blue](https://open.spotify.com/track/32ivi8KXX5qNqntYKfLMQT) + +## TigerBeetle 0.16.66 + +Released: 2025-11-21 + +### Safety And Performance + +- [#3379](https://github.com/tigerbeetle/tigerbeetle/pull/3379) + + Fix non-monotonic ID generation in the Python client. Thank you for spotting this, @rbino! + +### Internals + +- [#3371](https://github.com/tigerbeetle/tigerbeetle/pull/3371) + + Introduce `op_checkpoint_sync` to simplify assertions during state sync. + +- [#3353](https://github.com/tigerbeetle/tigerbeetle/pull/3353) + + Run Vörtex in CFO (Continuous Fuzzing Orchestrator). + +- [#3380](https://github.com/tigerbeetle/tigerbeetle/pull/3380) +- [#3384](https://github.com/tigerbeetle/tigerbeetle/pull/3384) + + Various fixes for Vörtex in CFO. + +### TigerTracks 🎧 + +- [The Flute Song](https://open.spotify.com/track/49fOKvQVojIKvJKQhQj2nA) + +## TigerBeetle 0.16.65 + +Released: 2025-11-14 + +### Safety And Performance + +- [#3377](https://github.com/tigerbeetle/tigerbeetle/pull/3377) + + Fix overzealous assertion that didn't account for state sync while accepting start view headers. + +### Internals + +- [#3369](https://github.com/tigerbeetle/tigerbeetle/pull/3369), + [#3370](https://github.com/tigerbeetle/tigerbeetle/pull/3370), + [#3374](https://github.com/tigerbeetle/tigerbeetle/pull/3374) + + Various MessageBus refactors to reduce code bloat. + + Earlier, the connection and message passing logic was spread out across the MessageBus and + Connection types. Now, all that logic is contained within the MessageBus type, with Connection + only maintaining connection state between two peers. + +- [#3367](https://github.com/tigerbeetle/tigerbeetle/pull/3367) + + Fix bug in cut_suffix wherein the suffix itself was being returned instead of the prefix. + +- [#3365](https://github.com/tigerbeetle/tigerbeetle/pull/3365) + + Make IO file descriptor a Zig optional, as opposed to using INVALID_SOCKET. + +- [#3364](https://github.com/tigerbeetle/tigerbeetle/pull/3364) + + Add support for canceling _individual_ inflight IO operations on Linux. + + Earlier, Linux IO only allowed canceling _all_ asynchronous in-flight operations. + +### TigerTracks 🎧 + +- [Elegia](https://open.spotify.com/track/5wZtbH2PjVZ5W1Akn5z2uA) + +## TigerBeetle 0.16.64 + +Released: 2025-11-07 + +### Internals + +- [#3344](https://github.com/tigerbeetle/tigerbeetle/pull/3344), + [#3362](https://github.com/tigerbeetle/tigerbeetle/pull/3362) + + Parametrize code over `Operation` rather than the entire `StateMachine`. Previously, client code + used to have a dependency on the implementation code of the concrete state machine, whereas it + only needs to know the types of the operations involved. + +- [#3358](https://github.com/tigerbeetle/tigerbeetle/pull/3358), + [#3356](https://github.com/tigerbeetle/tigerbeetle/pull/3356), + [#3357](https://github.com/tigerbeetle/tigerbeetle/pull/3357) + + [Remove](https://github.com/tigerbeetle/tigerbeetle/blob/main/docs/TIGER_STYLE.md#safety) `usize` + from constants, scans and CLI arguments. + +- [#3351](https://github.com/tigerbeetle/tigerbeetle/pull/3351) + + Remove `StateMachine`'s dependency on a specific `StateMachineConfig` and rather use the global + constants. + +- [#3343](https://github.com/tigerbeetle/tigerbeetle/pull/3343) + + Simplify the Rust client's build script and bring it inline with other clients by making it no + longer driver TigerBeetle's build. + +### TigerTracks 🎧 + +- [Hallo My Maatjie](https://www.youtube.com/watch?v=t88Abqb1Qp8) + +## TigerBeetle 0.16.63 + +Released: 2025-10-31 + +### Safety And Performance + +- [#3294](https://github.com/tigerbeetle/tigerbeetle/pull/3294), + [#3336](https://github.com/tigerbeetle/tigerbeetle/pull/3336) + + Allow backups to accept prepares from the next checkpoint when they replace already committed + prepares. + +- [#3323](https://github.com/tigerbeetle/tigerbeetle/pull/3323) + + Assert the size of `Headers` and `StartView` messages. + +- [#3318](https://github.com/tigerbeetle/tigerbeetle/pull/3318) + + Correctly reset the `pulse_next_timestamp`. + +- [#3316](https://github.com/tigerbeetle/tigerbeetle/pull/3316) + + Disable `config.verify` in release builds, while promoting several assertions gated by `verify` + to regular assertions. + +- [#3335](https://github.com/tigerbeetle/tigerbeetle/pull/3335) + + Fix an unhandled error on unreachable networks that could cause liveness issues. + Also ban all usage of `posix.send()` in favor of `posix.sendto()`. + +- [#3334](https://github.com/tigerbeetle/tigerbeetle/pull/3334) + + Free up about 300MiB of memory by sharing the same buffer for in-place radix sort. + +### Features + +- [#3302](https://github.com/tigerbeetle/tigerbeetle/pull/3302) + + Clarify that the `TBID`, which is a `u128` number, only specifies the bit layout. + +- [#3291](https://github.com/tigerbeetle/tigerbeetle/pull/3291) + + Introduces `tigerbeetle inspect integrity` to verify offline that a data file is uncorrupted. + +- [#3254](https://github.com/tigerbeetle/tigerbeetle/pull/3254) + + Prepare the Rust client for publication. + +### Internals + +- [#3312](https://github.com/tigerbeetle/tigerbeetle/pull/3312), + [#3324](https://github.com/tigerbeetle/tigerbeetle/pull/3324) + + Improve duration parsing by removing ambiguous units and adding fuzz tests. + +- [#3313](https://github.com/tigerbeetle/tigerbeetle/pull/3313) + + Update data file documentation to correctly state the grid block size as 512 KiB. + +- [#3311](https://github.com/tigerbeetle/tigerbeetle/pull/3311) + + Clarify the release process in case a version of TigerBeetle is skipped. + +- [#3317](https://github.com/tigerbeetle/tigerbeetle/pull/3317) + + Fix invalid payload references after modifying the active tag in a tagged union. + +- [#3339](https://github.com/tigerbeetle/tigerbeetle/pull/3339) + + Remove unnecessary deduplication logic when scanning from memory tables, + since [#2592](https://github.com/tigerbeetle/tigerbeetle/pull/2592) already + introduced deduplication during sorting. + +- [#3346](https://github.com/tigerbeetle/tigerbeetle/pull/3346) + + Reserve the maximum release version (65535.x.x) for testing clusters (`cluster_id` zero). + +- [#3342](https://github.com/tigerbeetle/tigerbeetle/pull/3342), + [#3338](https://github.com/tigerbeetle/tigerbeetle/pull/3338), + [#3330](https://github.com/tigerbeetle/tigerbeetle/pull/3330), + [#3347](https://github.com/tigerbeetle/tigerbeetle/pull/3347), + [#3341](https://github.com/tigerbeetle/tigerbeetle/pull/3341) + + Various Vortex and CFO improvements. + +- [#3332](https://github.com/tigerbeetle/tigerbeetle/pull/3332), + [#3331](https://github.com/tigerbeetle/tigerbeetle/pull/3331) + + Fix the build command for clients in HACKING.md and other typos. + Thanks @gharbi-mohamed-dev! + +### TigerTracks 🎧 + +- [NINETY-TWO](https://www.youtube.com/watch?v=qbXSXDv6gWU) + +## TigerBeetle 0.16.62 + +Released: 2025-10-17 + +### Safety And Performance + +- [#3304](https://github.com/tigerbeetle/tigerbeetle/pull/3304) + + Enable VOPR to detect when the message limit is exceeded. + +- [#3307](https://github.com/tigerbeetle/tigerbeetle/pull/3307) + + Enable unit tests for deprecated operations. + +- [#3309](https://github.com/tigerbeetle/tigerbeetle/pull/3309) + + Improve the release process by publishing to npm via trusted publishers. + +### Features + +- [#3299](https://github.com/tigerbeetle/tigerbeetle/pull/3299) + + Rephrase the "debits first" explanation in documentation. + +### Internals + +- [#3300](https://github.com/tigerbeetle/tigerbeetle/pull/3300) + + Refactor the message bus to save memory and tighten explicit `SendQueue` limits. + +- [#3296](https://github.com/tigerbeetle/tigerbeetle/pull/3296) + + Implement `BoundedArray` from scratch. + +- [#3292](https://github.com/tigerbeetle/tigerbeetle/pull/3292) + + Remove needless use of bounded array from REPL. + +- [#3308](https://github.com/tigerbeetle/tigerbeetle/pull/3308) + + Refactor and cleanup Vortex. + +### TigerTracks 🎧 + +- [No Goodbye](https://www.youtube.com/watch?v=oWDzTvjoDn4) + +## TigerBeetle (unreleased) + +Released: 2025-10-10 + +### Features + +- [#3299](https://github.com/tigerbeetle/tigerbeetle/pull/3299) + + Rephrase the "debits first" explanation in documentation. + +### Internals + +- [#3296](https://github.com/tigerbeetle/tigerbeetle/pull/3296) + + Implement `BoundedArray` from scratch. + +- [#3292](https://github.com/tigerbeetle/tigerbeetle/pull/3292) + + Remove needless use of bounded array from REPL. + +### TigerTracks 🎧 + +- [voyager](https://www.youtube.com/watch?v=BLVI-RS9srI) + +## TigerBeetle 0.16.61 + +Released: 2025-10-03 + +### Safety And Performance + +- [#3263](https://github.com/tigerbeetle/tigerbeetle/pull/3263) + + Speed up cluster repair by adapting the pace and distribution of repair requests according to + observed network conditions. + +- [#3282](https://github.com/tigerbeetle/tigerbeetle/pull/3282) + + Speed up cluster repair by not delaying execution of committed prepares until the log is fully + repaired. + +- [#3249](https://github.com/tigerbeetle/tigerbeetle/pull/3249), + [#3293](https://github.com/tigerbeetle/tigerbeetle/pull/3293), + [#3289](https://github.com/tigerbeetle/tigerbeetle/pull/3289) + + Add a dedicated fuzzer for `MessageBus`. + +### Features + +- [#3212](https://github.com/tigerbeetle/tigerbeetle/pull/3212) + + Add `--log-trace` for extra verbose logging. + +- [#3286](https://github.com/tigerbeetle/tigerbeetle/pull/3286) + + After successfully adding a test that tests in + [#3136](https://github.com/tigerbeetle/tigerbeetle/pull/3136), we doubled down on this strategy + and are adding a metric for tracking metrics. + +### Internals + +- [#3115](https://github.com/tigerbeetle/tigerbeetle/pull/3115) + + Use ISO4217 three-letter codes when writing about currencies (so, `USD` over `$`). + +- [#3284](https://github.com/tigerbeetle/tigerbeetle/pull/3284) + + Clean up Adaptive Replication Routing implementation. + +- [#3287](https://github.com/tigerbeetle/tigerbeetle/pull/3287) + + Speed up `zig build test` by removing false build-time dependencies. + +### TigerTracks 🎧 + +- [Сколько тебя](https://open.spotify.com/track/480AuAqeCkgwY46HocIbXk) + +## TigerBeetle 0.16.60 + +Released: 2025-09-26 + +### Safety And Performance + +- [#3270](https://github.com/tigerbeetle/tigerbeetle/pull/3270) + + Remove a copy from the StateMachine. + +- [#3273](https://github.com/tigerbeetle/tigerbeetle/pull/3273) + + Update the k-way-merge to use the new `from_seed_testing()`. + +- [#3277](https://github.com/tigerbeetle/tigerbeetle/pull/3277) + + Improve metrics to use a reduce the worst case packet count (and benefit from a small memory + saving while we're at it). + +### Features + +- [#3278](https://github.com/tigerbeetle/tigerbeetle/pull/3278) + + Clarified documentation on closing accounts and two-phase transfers. + Thanks @raui100! + +### Internals + +- [#3275](https://github.com/tigerbeetle/tigerbeetle/pull/3275) + + Refactor references to old time types to use the Instant and Duration types. + +- [#3274](https://github.com/tigerbeetle/tigerbeetle/pull/3274) + + Added documentation for our CI entrypoint: `zig build ci`. + +### TigerTracks 🎧 + +- [Undefeated](https://open.spotify.com/track/5fwKEMTyS0FqLk7KVdGQwl) + +## TigerBeetle 0.16.59 + +Released: 2025-09-19 + +### Safety And Performance + +- [#3257](https://github.com/tigerbeetle/tigerbeetle/pull/3257) + + Introduce Least Significant Digit (LSD) radix sort in stdx. + +- [#3268](https://github.com/tigerbeetle/tigerbeetle/pull/3268) + + Use radix sort in the memory tables to get more performance improvements. + +- [#3250](https://github.com/tigerbeetle/tigerbeetle/pull/3250) + + Reduce tail latencies by tracking sorted runs and use k-way merge to sort them. + + Collectively, these changes result in the following performance improvements on modern servers + (Hetzner AX102): + + | Metric | Before | After | + |--------|--------|-------| + | Load accepted (tx/s) | 414,375 | 606,258 | + | Batch latency p100 | 115ms | 75ms | + +### Internals + +- [#3262](https://github.com/tigerbeetle/tigerbeetle/pull/3262) + + Use Zig's new `std.testing.random_seed` to introduce genuine randomness in tests. + +- [#3260](https://github.com/tigerbeetle/tigerbeetle/pull/3260) + + Fix a crash due to corruption and misdirection found by the WIP message bus fuzzer. + +### TigerTracks 🎧 + +- [Autobahn](https://open.spotify.com/track/31uidLEHAcF8Cw1cX1VCS8) + +## TigerBeetle 0.16.58 + +Released: 2025-09-12 + +### Safety And Performance + +- [#3248](https://github.com/tigerbeetle/tigerbeetle/pull/3248) + + Fix a bug where grid.cancel was erroneously being invoked during commit_stage=checkpoint_durable. + +- [#3245](https://github.com/tigerbeetle/tigerbeetle/pull/3245) + + Remove peer type from MessageBuffer, maintaining it only at MessageBus level. + + This solves a bug introduced by [#3206](https://github.com/tigerbeetle/tigerbeetle/pull/3206), + due to divergent peer state between MessageBus and MessageBuffer. + +- [#3226](https://github.com/tigerbeetle/tigerbeetle/pull/3226), + [#3230](https://github.com/tigerbeetle/tigerbeetle/pull/3230), + [#3223](https://github.com/tigerbeetle/tigerbeetle/pull/3223), + [#3222](https://github.com/tigerbeetle/tigerbeetle/pull/3222) + + Low-level LSM performance improvements. The k-way merge iterator now uses a tournament tree + instead of a heap, we skip sorting of the mutable table if it's not needed, and we skip binary + search if possible by min/max key ranges. + +- [#3237](https://github.com/tigerbeetle/tigerbeetle/pull/3237) + + Use a different PRNG seed for Replica each time, rather than a fixed seed of the replica ID. This + PRNG controls things like exponential backoff jitter and the order of the blocks on which the grid + scrubber runs. + +### Features + +- [#3253](https://github.com/tigerbeetle/tigerbeetle/pull/3253) + + Introduce `--requests-per-second-limit` to throttle CDC requests to TigerBeetle. + + Usage for this option is orthogonal to `--idle-interval-ms` and `--event-count-max`, allowing + fine-tuning for low latency without overflowing the AMQP target queue. + +- [#3206](https://github.com/tigerbeetle/tigerbeetle/pull/3206) + + In order to avoid bimodality if a replica is down (e.g., a client sends a request, doesn't hear + anything, eventually times out and tries a different replica) clients now proactively send their + requests to the primary and a randomly selected replica. + + Backups can also send replies directly to clients, meaning that a client could be completely + partitioned from the primary, but still remain available. + +### Internals + +- [#3251](https://github.com/tigerbeetle/tigerbeetle/pull/3251) + + Mark grid cache blocks as MADV_DONTDUMP, making core dump size tractable even with large caches. + +- [#3227](https://github.com/tigerbeetle/tigerbeetle/pull/3227), + [#3231](https://github.com/tigerbeetle/tigerbeetle/pull/3231), + [#3242](https://github.com/tigerbeetle/tigerbeetle/pull/3242) + + Preparation for the Zig 0.15.1 upgrade. + +- [#3236](https://github.com/tigerbeetle/tigerbeetle/pull/3236), + [#3229](https://github.com/tigerbeetle/tigerbeetle/pull/3229), + [#3198](https://github.com/tigerbeetle/tigerbeetle/pull/3198) + + A host of multiversion improvements! Multiversioning is now a proper interface, rather than being + scattered about. Additionally, Windows support is now significantly more robust, and will be + integration tested like the other platforms in the next release. + +### TigerTracks 🎧 + +- [See You Again](https://www.youtube.com/watch?v=RgKAFK5djSk) + +## TigerBeetle 0.16.57 + +Released: 2025-08-29 + +### Safety And Performance + +- [#3200](https://github.com/tigerbeetle/tigerbeetle/pull/3200) + + Improved performance in `set_associative_cache.zig` by using Fastrange and SIMD. + +- [#3201](https://github.com/tigerbeetle/tigerbeetle/pull/3201) + + Improved performance and better codegen in `aegis.zig` by avoiding aliasing. + +- [#3205](https://github.com/tigerbeetle/tigerbeetle/pull/3205) + + Retain table repair progress across checkpoint boundaries, + to reduce the time to complete state sync for active clusters. + +- [#3216](https://github.com/tigerbeetle/tigerbeetle/pull/3216) + + Use the `NOSIGNAL` flag with both asynchronous `send` and synchronous `send_now`. + This avoids receiving a possible `SIGPIPE` signal raised by the kernel. + +- [#3189](https://github.com/tigerbeetle/tigerbeetle/pull/3189) + + Improve the Rust client API to make the returned future thread-safe. + Thanks @michabp! + +- [#3208](https://github.com/tigerbeetle/tigerbeetle/pull/3208) + + Fix the Go client to make the subfolder with external files required by CGO + compatible with `go vendor`. + Thanks @itzloop. + +- [#3203](https://github.com/tigerbeetle/tigerbeetle/pull/3203) + + Fix the Python client to make `close()` async on `ClientAsync`. + +### Internals + +- [#3215](https://github.com/tigerbeetle/tigerbeetle/pull/3215) + + Removed support for _closed loop replication_ in repair and sync protocols. + +- [#3210](https://github.com/tigerbeetle/tigerbeetle/pull/3210), + [#3211](https://github.com/tigerbeetle/tigerbeetle/pull/3211) + + Ban equality and inequality comparisons with `error` values, as they may silently + perform an untyped comparison. + Enforce handling errors with `switch` blocks instead. + +- [#3207](https://github.com/tigerbeetle/tigerbeetle/pull/3207) + + Consolidate all Windows APIs we use (not present in Zig’s `std`) under `stdx.windows`. + +- [#3199](https://github.com/tigerbeetle/tigerbeetle/pull/3199), + [#3194](https://github.com/tigerbeetle/tigerbeetle/pull/3194) + + Improve upgrade tests. + +- [#3202](https://github.com/tigerbeetle/tigerbeetle/pull/3202), + [#3140](https://github.com/tigerbeetle/tigerbeetle/pull/3140) + + Improve the documentation to explain adaptive routing and add notes about + using CDC in production. + +- [#3196](https://github.com/tigerbeetle/tigerbeetle/pull/3196) + + Moves the `unshare` code into `stdx.unshare` and uses it for both Vortex and CFO. + Also fixes how `vortex run` was handling the child process error code. + +### TigerTracks 🎧 + +- [American Pie](https://www.youtube.com/watch?v=9SzrN3oGCCw) + +## TigerBeetle 0.16.56 + +Released: 2025-08-22 + +### Safety And Performance + +- [#3185](https://github.com/tigerbeetle/tigerbeetle/pull/3185) + + Improve the speed of trailer repairs by initiating repair requests more proactively. + +- [#3017](https://github.com/tigerbeetle/tigerbeetle/pull/3017) + + Add [Vortex](https://tigerbeetle.com/blog/2025-02-13-a-descent-into-the-vortex/) to CI to test clients (Java, Zig, Rust). + +- [#3193](https://github.com/tigerbeetle/tigerbeetle/pull/3193) + + Ensure only the primary responds to VSR repeat requests. + +### Features + +- [#3014](https://github.com/tigerbeetle/tigerbeetle/pull/3014) + + Add a typed Python client (thanks @stenczelt). + +### Internals + +- [#3195](https://github.com/tigerbeetle/tigerbeetle/pull/3195) + + Simplify budgeting for VSR repairs. + +- [#3181](https://github.com/tigerbeetle/tigerbeetle/pull/3181) + + Refactor `ReleaseList` to contain the release logic. + +- [#3190](https://github.com/tigerbeetle/tigerbeetle/pull/3190) + + Improve naming for journal and grid message budgets. + +### TigerTracks 🎧 + +- [Boiler Room](https://www.youtube.com/watch?v=bk6Xst6euQk) + +## TigerBeetle 0.16.55 + +Released: 2025-08-15 + +### Safety And Performance + +- [3187](https://github.com/tigerbeetle/tigerbeetle/pull/3187) + + Make repair timeout reliably fire in a loaded cluster processing small batches. + +- [#2863](https://github.com/tigerbeetle/tigerbeetle/pull/2863) + + Make `tigerbeetle format` concurrent and only write essential data. + This speeds up the time to format considerably. + +- [#3145](https://github.com/tigerbeetle/tigerbeetle/pull/3145) + + Cache prepares from the future, to help avoid needing to repair the WAL near checkpoints when a + backup is a little behind primary. + +### Features + +- [#3174](https://github.com/tigerbeetle/tigerbeetle/pull/3174) + + Don't unlink data file on formatting failure. + +- [#3173](https://github.com/tigerbeetle/tigerbeetle/pull/3173) + + Use correct default statsd port (8125). + +- [#3154](https://github.com/tigerbeetle/tigerbeetle/pull/3154) + + Remove translation logic from old checkpoint state to new. Note that this means that + `tigerbeetle inspect` will no longer decode superblocks from 0.16.25 or older, until + they are upgraded to at least 0.16.26. + +### Internals + +- [#3186](https://github.com/tigerbeetle/tigerbeetle/pull/3186) + + Improvements to the balance bounds, rate limiting, and two phase transfers recipes. Thanks @snth! + +- [#3150](https://github.com/tigerbeetle/tigerbeetle/pull/3150) + + Use true quine to generate unit tests. + +- [#3160](https://github.com/tigerbeetle/tigerbeetle/pull/3160) + + Drop `SigIllHandler`. This was supposed to print a nice error message on unsupported + architectures, but we hit `SigIll` in Zig's `_start`, before we get to our `main`. + +- [#3148](https://github.com/tigerbeetle/tigerbeetle/pull/3148) + + Add constants for KiB thru PiB. + +### TigerTracks 🎧 + +- [Heavyweight](https://www.youtube.com/watch?v=9Axg_e8astI) + +## TigerBeetle 0.16.54 + +Released: 2025-08-08 + +### Safety And Performance + +- [#3123](https://github.com/tigerbeetle/tigerbeetle/pull/3123) + + Speed up repair by removing a round-trip to fetch headers. + +- [#3134](https://github.com/tigerbeetle/tigerbeetle/pull/3134) + + Check checksums when downloading Zig during the build. + +### Features + +- [#2993](https://github.com/tigerbeetle/tigerbeetle/pull/2993) + + Add documentation for Rust client library. + +- [#2989](https://github.com/tigerbeetle/tigerbeetle/pull/2989) + + Test that release artifacts are fully reproducible. + +### Internals + +- [#3136](https://github.com/tigerbeetle/tigerbeetle/pull/3136) + + Add a test to test that tests include all the tests. + +- [#3143](https://github.com/tigerbeetle/tigerbeetle/pull/3143) + + Remove local variable aliasing as per TigerStyle. + +- [#3124](https://github.com/tigerbeetle/tigerbeetle/pull/3124) + + `@splat` all the things. + +- [#3135](https://github.com/tigerbeetle/tigerbeetle/pull/3135) + + Use double-entry accounting for allocations. + +- [#3129](https://github.com/tigerbeetle/tigerbeetle/pull/3129) + + Remove `git-review`. + +- [#3131](https://github.com/tigerbeetle/tigerbeetle/pull/3131), + [#3130](https://github.com/tigerbeetle/tigerbeetle/pull/3130) + + Show total number of VOPR runs for release. + +### TigerTracks 🎧 + +- [War Pigs](https://www.youtube.com/watch?v=IB6jbWoGtlA&list=RDIB6jbWoGtlA) + +## TigerBeetle 0.16.53 + +Released: 2025-08-01 + +### Safety And Performance + +- [#3090](https://github.com/tigerbeetle/tigerbeetle/pull/3090), + [#3116](https://github.com/tigerbeetle/tigerbeetle/pull/3116) + + Allowing EWAH to decode bigger free set into smaller. This fixes the `--limit-storage` flag. + +- [#3089](https://github.com/tigerbeetle/tigerbeetle/pull/3089) + + Fix Node.js v24 client. + +### Features + +- [#3119](https://github.com/tigerbeetle/tigerbeetle/pull/3119) + + Add compaction/checkpoint/journal slot count to `tigerbeetle inspect`. + +### Internals + +- [#3121](https://github.com/tigerbeetle/tigerbeetle/pull/3121) + + During tests, verify that grid read errors correspond to either storage faults or ongoing state + sync. + +- [#3122](https://github.com/tigerbeetle/tigerbeetle/pull/3122) + + Teach snaptest how to decode/encode hex & zon. + +- [#3110](https://github.com/tigerbeetle/tigerbeetle/pull/3110) + + Fix typo in `manifest_log_fuzz`. + +- [#3113](https://github.com/tigerbeetle/tigerbeetle/pull/3113) + + Test `CreateTransfersResult.exists` in VOPR. + +- [#3117](https://github.com/tigerbeetle/tigerbeetle/pull/3117), + [#3120](https://github.com/tigerbeetle/tigerbeetle/pull/3120) + + `stdx` refactoring. + +### TigerTracks 🎧 + +- [Summer Eyes](https://www.youtube.com/watch?v=4Kc1Cks29-w) + +## TigerBeetle 0.16.52 + +Released: 2025-07-25 + +### Safety And Performance + +- [#3093](https://github.com/tigerbeetle/tigerbeetle/pull/3093) + + Improve repair performance by tracking requested prepares so each is repaired exactly once per + timeout. + +### Internals + +- [#2956](https://github.com/tigerbeetle/tigerbeetle/pull/2956) + + In VOPR, model events using nanosecond-resolution timestamps to uncover more interesting + interleaving. + +- [#3088](https://github.com/tigerbeetle/tigerbeetle/pull/3088) + [#3100](https://github.com/tigerbeetle/tigerbeetle/pull/3100) + [#3102](https://github.com/tigerbeetle/tigerbeetle/pull/3102) + + Initialize `IO` and `Tracer` early. Avoid comptime type specialization. + +- [#3101](https://github.com/tigerbeetle/tigerbeetle/pull/3101) + + Update TigerStyle with additional conventions for naming things and ordering struct fields. + +- [#3105](https://github.com/tigerbeetle/tigerbeetle/pull/3105) + + Add `--requests-max` CLI flag to VOPR. + +- [#3107](https://github.com/tigerbeetle/tigerbeetle/pull/3107) + + In DevHub, use `font-size-adjust` to better match sizes of sans and monospace text. + +- [#3108](https://github.com/tigerbeetle/tigerbeetle/pull/3108) + + Introduce a fixtures module for fuzzing and testing to avoid code duplication. + +### TigerTracks 🎧 + +- [Changes](https://open.spotify.com/track/2wNEcJHnFxoKZIrxjxF5jL) + +## TigerBeetle 0.16.51 + +Released: 2025-07-18 + + +### Safety And Performance + +- [#3096](https://github.com/tigerbeetle/tigerbeetle/pull/3096) + + Fix incorrect assert in the commit stall logic. + + This assert could cause the primary to crash while it is injecting a commit stall, if an old + primary has committed ahead of it. + +- [#3008](https://github.com/tigerbeetle/tigerbeetle/pull/3008) + + Improve compaction scheduling algorithm to be more performant and memory efficient. + + Earlier, during each beat, we used to compact each active tree and level, leading to multiple + context switches. Now, we compact each tree and level to completion before moving on to the next. + +### Features + +- [#3086](https://github.com/tigerbeetle/tigerbeetle/pull/3086) + + Add metrics that track the time taken to complete read and write IO. + +### Internals + +- [#3087](https://github.com/tigerbeetle/tigerbeetle/pull/3087) + + Remove comptime type specialization on Time and replace it with a runtime interface. + +- [#3085](https://github.com/tigerbeetle/tigerbeetle/pull/3085) + + Change all instances of data block -> value block, more aptly named for blocks containing values. + +- [#3084](https://github.com/tigerbeetle/tigerbeetle/pull/3084) + + Update [architecture documentation](docs/internals/ARCHITECTURE.md#systems-thinking) to explain + how to correctly integrate TigerBeetle into a larger data processing system. + +- [#3083](https://github.com/tigerbeetle/tigerbeetle/pull/3083) + + Fix example for voiding pending transfers in the dotnet, go, node, and python clients. + +- [#3082](https://github.com/tigerbeetle/tigerbeetle/pull/3082), + [#3091](https://github.com/tigerbeetle/tigerbeetle/pull/3091) + + Fix broken link to the Viewstamped Replication paper and some typos in the documentation. + +- [#3078](https://github.com/tigerbeetle/tigerbeetle/pull/3078) + + Fix VOPR false positive where we erroneously find two different versions of an uncommitted header. + +- [#2990](https://github.com/tigerbeetle/tigerbeetle/pull/2990) + + Refine BoundedArrayType API, renaming functions to be shorter and consistent with Queue and Stack. + +### TigerTracks 🎧 + +- [LOVE.](https://open.spotify.com/track/6PGoSes0D9eUDeeAafB2As) + +## TigerBeetle 0.16.50 + +Released: 2025-07-13 + +### Internals + +- [#3076](https://github.com/tigerbeetle/tigerbeetle/pull/3076) + + Cleanup Zig TODO items that have been resolved with the recent upgrade to Zig 0.14.1. + +- [#3071](https://github.com/tigerbeetle/tigerbeetle/pull/3071) + + Always copy fields from `vsr_options` to `build_options`, since Zig 0.14.1 removed anonymous + structs. Thanks @rbino! + +- [#3075](https://github.com/tigerbeetle/tigerbeetle/pull/3075), + [#3074](https://github.com/tigerbeetle/tigerbeetle/pull/3074) + + Use realtime to enforce budget and refresh timeouts in the CFO. + +- [#3072](https://github.com/tigerbeetle/tigerbeetle/pull/3072) + + Remove `unwind_tables` from release builds and strip client libraries to reduce binary size. + +### TigerTracks 🎧 + +- [Washday Blues](https://www.youtube.com/watch?v=a77xKtyVKMw) + +## TigerBeetle 0.16.49 + +Released: 2025-07-04 + +### Safety And Performance + +- [#3064](https://github.com/tigerbeetle/tigerbeetle/pull/3064) + + Fix a division by zero when logging CDC metrics, and increase resolution to nanoseconds. + +- [#3050](https://github.com/tigerbeetle/tigerbeetle/pull/3050) + + Apply backpressure at primary to mitigate an issue with lagging backups. + +### Internals + +- [#2705](https://github.com/tigerbeetle/tigerbeetle/pull/2705) + + Upgrade to Zig 0.14.1. + +- [#3068](https://github.com/tigerbeetle/tigerbeetle/pull/3068) + + Fix a typo that caused probabilities to be parsed as hexadecimal. + +### TigerTracks 🎧 + +- [Dance of Maria](https://open.spotify.com/track/0f7iz1qAWSz61BdHTXbzvC) + +## TigerBeetle 0.16.48 + +Released: 2025-07-01 + +### Internals + +- [#3062](https://github.com/tigerbeetle/tigerbeetle/pull/3062) + + Updates the publishing process for the Java client to conform to the Maven Central Repository + due to the [OSSRH service end-of-life](https://central.sonatype.org/news/20250326_ossrh_sunset/). + +- [#3048](https://github.com/tigerbeetle/tigerbeetle/pull/3048), + [#3047](https://github.com/tigerbeetle/tigerbeetle/pull/3047) + + Fixes and improvements for tracing and metrics. + +### TigerTracks 🎧 + +- [All Shook Up](https://www.youtube.com/watch?v=23zLefwiii4&list=RD23zLefwiii4) + +## TigerBeetle 0.16.47 + +Released: 2025-06-27 + +Note: This release is missing some client libraries in their respective package managers. + +### Safety And Performance + +- [#3032](https://github.com/tigerbeetle/tigerbeetle/pull/3032) + + Fix ABI assertions in Rust client. + +- [#3039](https://github.com/tigerbeetle/tigerbeetle/pull/3039) + + Swarm test different replication configurations in VOPR. + +- [#3053](https://github.com/tigerbeetle/tigerbeetle/pull/3053) + + Supports CDC processing for transfers created by versions earlier than `0.16.29`. + Fixes a liveness bug that would crash the replica if a CDC query encountered objects + created with a schema before [#2507](https://github.com/tigerbeetle/tigerbeetle/pull/2507). + +### Features + +- [#3038](https://github.com/tigerbeetle/tigerbeetle/pull/3038) + + Add `client_request_round_trip` metric to track end-to-end client request latency. + +- [#3043](https://github.com/tigerbeetle/tigerbeetle/pull/3043) + + Support `--clients` alongside `--transfer-batch-delay-us` in `tigerbeetle benchmark`. + +- [#3056](https://github.com/tigerbeetle/tigerbeetle/pull/3056) + + The command `tigerbeetle inspect constants` prints VSR queue sizes. + +### Internals + +- [#3045](https://github.com/tigerbeetle/tigerbeetle/pull/3045) + + Define timeouts in terms of `tick_ms`. + +- [#3042](https://github.com/tigerbeetle/tigerbeetle/pull/3042) + + Disable "hint" argument for mmap call, which was observed to cause stack overflow. + +### TigerTracks 🎧 + +- [Wishmaster](https://www.youtube.com/watch?v=XCGQiGEYl4Y) + +## TigerBeetle 0.16.46 + +Released: 2025-06-19 + +### Safety And Performance + +- [#3030](https://github.com/tigerbeetle/tigerbeetle/pull/3030) + + Always build tb_client for Rust client in release mode. + +### Internals + +- [#3031](https://github.com/tigerbeetle/tigerbeetle/pull/3031) + + Prioritize more important fuzzers in CFO. + +### TigerTracks 🎧 + +- [Geef Mij Maar Amsterdam](https://open.spotify.com/track/2eiYJEuVh8axfumgEGvyPz) + +## TigerBeetle 0.16.45 + +Released: 2025-06-13 + +This release changes the CDC message header to use AMQP signed integers. +The new encoding will be handled transparently by RabbitMQ/AMQP clients. However, code changes +might be necessary if the consumer explicitly relies on the unsigned data type. + +### Safety And Performance + +- [#3023](https://github.com/tigerbeetle/tigerbeetle/pull/3023) + + Fix a liveness bug related to when replicas are syncing. + +- [#3022](https://github.com/tigerbeetle/tigerbeetle/pull/3022) + + Fix a crash related to timing when measuring commit timing. + +### Features + +- [#2907](https://github.com/tigerbeetle/tigerbeetle/pull/2907) + + Add documentation on how to monitor TigerBeetle, track requests end-to-end for better monitoring. + +- [#3019](https://github.com/tigerbeetle/tigerbeetle/pull/3019), + [#3029](https://github.com/tigerbeetle/tigerbeetle/pull/3029) + + Improves compatibility of our new CDC connector by supporting AMQP signed integer types, and + fixes an assertion that previously overlooked the possibility of receiving an asynchronous + `basic_ack` while publishing a batch of messages. + Thanks @alvinyan-bond for your feedback! + +### Internals + +- [#3018](https://github.com/tigerbeetle/tigerbeetle/pull/3018) + + Add a recovery smoke test. + +### TigerTracks 🎧 + +- [The Grid](https://open.spotify.com/track/64VYy2f9QBx26P1YjNQrEc) + +## TigerBeetle 0.16.44 + +Released: 2025-06-06 + +### Features + +- [#3006](https://github.com/tigerbeetle/tigerbeetle/pull/3006) + + Improve logging for missing replies by including the op number. + +### Internals + +- [#3011](https://github.com/tigerbeetle/tigerbeetle/pull/3011) + + DevHub now displays how many fuzz runs are executed per minute (VPM). + +- [#3010](https://github.com/tigerbeetle/tigerbeetle/pull/3010) + + Remove `cluster` from the MessageBus as part of the MessageBuffer rework. + +- [#2992](https://github.com/tigerbeetle/tigerbeetle/pull/2992) + + Handle all message padding uniformly. + +- [#3001](https://github.com/tigerbeetle/tigerbeetle/pull/3001) + + Limit the fuzzer processes to 20GiB of RAM. + +- [#3009](https://github.com/tigerbeetle/tigerbeetle/pull/3009) + + Prevent stack probing from actually using all of the stack due to unexpected inlining. + +### TigerTracks 🎧 + +- [Lose My Mind](https://www.youtube.com/watch?v=WWEs82u37Mw) + +## TigerBeetle 0.16.43 + +Released: 2025-05-30 + +This release includes the `tigerbeetle recover` subcommand, which can be used to _safely_ recover a +replica that is permanently lost. + +Additionally, it includes Change Data Capture (CDC) support to stream TigerBeetle state to Advanced +Message Queuing Protocol (AMQP) targets, such as RabbitMQ and other compatible brokers. + +Check out the [documentation](https://docs.tigerbeetle.com/operating/) to learn about how to use +CDC and `tigerbeetle recover`! + + +### Safety And Performance + +- [#2996](https://github.com/tigerbeetle/tigerbeetle/pull/2996) + + Add the `tigerbeetle recover` subcommand, to safely recover a replica that is permanently lost + (e.g. if the SSD fails). + + Earlier, the only way to recover a permanently lost replica was using the `tigerbeetle format` + command. However, this was unsafe, as a newly-formatted replica may nack prepares which its + previous incarnation acked -- a correctness bug. + +- [#2880](https://github.com/tigerbeetle/tigerbeetle/pull/2880) + + Implement an adaptive replication routing protocol to handle changes in network topology. + + To select the best route, primary uses outcome-focused explore-exploit approach. Every once in a + while, the primary tries an alternative route, and replaces the current route if the alternative + provides better replication latency. + +- [#2970](https://github.com/tigerbeetle/tigerbeetle/pull/2970), + [#3002](https://github.com/tigerbeetle/tigerbeetle/pull/3002) + + Replica pulls messages from the MessageBus, as opposed to the MessageBus pushing messages. + + Earlier, replicas had to process _every_ message that the bus pushed. This could lead to messages + being dropped due to lack of available disk read/write IOPs. Now, a replica can "suspend" certain + messages and return to them later when it has enough IOPs. + +### Features + +- [#2917](https://github.com/tigerbeetle/tigerbeetle/pull/2917) + + CDC support to stream TigerBeetle state to AMQP targets, such as RabbitMQ and other compatible + brokers. + + We implement the AMQP 0.9.1 specification instead of AMQP 1.0 as it is simpler and more widely + supported (e.g., RabbitMQ only recently added native AMQP 1.0 support). + +### Internals + +- [#2982](https://github.com/tigerbeetle/tigerbeetle/pull/2982) + + Unify the production and testing AOF code paths to make sure the production AOF is rigorously + fuzzed by the VOPR. + +- [#2991](https://github.com/tigerbeetle/tigerbeetle/pull/2991) + + Reduce code duplication while erasing IO callbacks' type. + +- [#2998](https://github.com/tigerbeetle/tigerbeetle/pull/2998) + + Track debug build times on DevHub. + +- [#2987](https://github.com/tigerbeetle/tigerbeetle/pull/2987), + [#2988](https://github.com/tigerbeetle/tigerbeetle/pull/2988), + [#2997](https://github.com/tigerbeetle/tigerbeetle/pull/2997), + [#2999](https://github.com/tigerbeetle/tigerbeetle/pull/2999) + + Miscellaneous improvements and fixes to CI and release. + +### TigerTracks 🎧 + +- [16 CARRIAGES](https://open.spotify.com/track/6XXxKsu3RJeN3ZvbMYrgQW) + +## TigerBeetle 0.16.42 + +Released: 2025-05-23 + +### Safety And Performance + +- [#2980](https://github.com/tigerbeetle/tigerbeetle/pull/2980) + + Fix assert in `fulfill_block`, if a replica receives a block that it didn't ask for from a newer + replica. + +### Internals + +- [#2973](https://github.com/tigerbeetle/tigerbeetle/pull/2973) + + Extract the parsing parts of MessageBus into a sans-IO style ReceiveBuffer, that'll be used for + the [upcoming](https://github.com/tigerbeetle/tigerbeetle/pull/2970) pull based MessageBus. + +- [#2979](https://github.com/tigerbeetle/tigerbeetle/pull/2979) + + Speed up the LSM scan fuzzer. + +### TigerTracks 🎧 + +- [Knowing Me, Knowing You](https://www.youtube.com/watch?v=iUrzicaiRLU) + +## TigerBeetle 0.16.41 + +Released: 2025-05-16 + +### Safety And Performance + +- [#2972](https://github.com/tigerbeetle/tigerbeetle/pull/2972) + + Implement request throttling for grid repair, making state sync less chatty over the network. + +- [#2957](https://github.com/tigerbeetle/tigerbeetle/pull/2957) + + Improved latency and throughput in prepare repair operations. + +### Internals + +- [#2967](https://github.com/tigerbeetle/tigerbeetle/pull/2967) + + Make the Quick Start page more direct by showing only the installation instructions for Linux + by default, with Windows and macOS hidden behind click-to-expand sections. + +- [#2958](https://github.com/tigerbeetle/tigerbeetle/pull/2958) + + New `tigerbeetle inspect op` command that displays checkpoints and triggers surrounding a given + `op` number. + +### TigerTracks 🎧 + +- [Paradise City](https://www.youtube.com/watch?v=Rbm6GXllBiw) + +## TigerBeetle 0.16.40 + +Released: 2025-05-09 + +### Safety And Performance + +- [#2959](https://github.com/tigerbeetle/tigerbeetle/pull/2959) + + Fix AOF `unflushed` assertion. + +- [#2950](https://github.com/tigerbeetle/tigerbeetle/pull/2950) + + Decouple prepare repair from header breaks. This helps a replica repair faster, and prevents a + case where we would request a prepare only to discard it when it arrives. + +- [#2945](https://github.com/tigerbeetle/tigerbeetle/pull/2945), + [#2961](https://github.com/tigerbeetle/tigerbeetle/pull/2961) + + Bump default journal write iops. Previously we were inadvertently throttling repair by not + allocating enough. + +- [#2944](https://github.com/tigerbeetle/tigerbeetle/pull/2944) + + Primary now broadcasts `start_view` message on checkpoint durability. + This will help syncing replicas update to the latest sync target as early as possible. + +- [#2952](https://github.com/tigerbeetle/tigerbeetle/pull/2952) + + Add assert to check return value of `next_batch_of_block_requests`. + +### Features + +- [#2943](https://github.com/tigerbeetle/tigerbeetle/pull/2943), + [#2949](https://github.com/tigerbeetle/tigerbeetle/pull/2949), + [#2951](https://github.com/tigerbeetle/tigerbeetle/pull/2951) + + Improve logging, especially of lagging replicas. + +### Internals + +- [#2937](https://github.com/tigerbeetle/tigerbeetle/pull/2937) + + Add subcommand to `git-review` to send review comments as a email. + +- [#2926](https://github.com/tigerbeetle/tigerbeetle/pull/2926) + + Add subcommand to `git-review` to split suggested code changes out of a review commit. + +- [#2940](https://github.com/tigerbeetle/tigerbeetle/pull/2940) + + Speed up fuzz smoke tests. + +- [#2941](https://github.com/tigerbeetle/tigerbeetle/pull/2941) + + Improve memory usage of tests 10x and test runtime 10x. + +- [#2936](https://github.com/tigerbeetle/tigerbeetle/pull/2936) + + Add arbitrary debug output to CFO seeds. + +- [#2931](https://github.com/tigerbeetle/tigerbeetle/pull/2931) + + Fix for multiple CFO branches on same commit. + +- [#2964](https://github.com/tigerbeetle/tigerbeetle/pull/2964) + + Reduce closed loop replication quorum from `n` to `n-1`. + +### TigerTracks 🎧 + +- [Field of Stars](https://www.youtube.com/watch?v=l1Pqy1tuVLI) + +## TigerBeetle 0.16.39 + +Released: 2025-05-02 + +Heads up, we are changing our release process! From this point on, a TigerBeetle release is tagged +on Friday, spends a weekend on the +[CFO fleet](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/scripts/cfo.zig), and is +published on Monday. In other words, you'll still be getting a new release every Monday, but the +date of the release will be set to Friday. This setup allows extra time for fuzzers to find problems +in the specific commit we are trying to release. + +### Safety And Performance + +- [#2928](https://github.com/tigerbeetle/tigerbeetle/pull/2928) + + Continuously fuzz the release branch, in addition to the main branch and the pull requests. + +- [#2923](https://github.com/tigerbeetle/tigerbeetle/pull/2923) + + Eagerly deinitialize clients upon eviction, to proactively sever TCP connections to replicas. + +### Features + +- [#2679](https://github.com/tigerbeetle/tigerbeetle/pull/2679) + + Add an initial Rust client. Note that it is not published to crates.io yet. + +### Internals + +- [#2906](https://github.com/tigerbeetle/tigerbeetle/pull/2906) + + Reduce verbosity when running full CI suite locally. + +### TigerTracks 🎧 + +- [Тёплые Коты](https://open.spotify.com/track/4UWBuck7q0VeKYiHrwoqnU) + +## TigerBeetle 0.16.38 + +Released: 2025-04-28 + +### Safety And Performance + +- [#2664](https://github.com/tigerbeetle/tigerbeetle/pull/2664) + + Switch to using gpa as backing allocator for clients. + +- [#2902](https://github.com/tigerbeetle/tigerbeetle/pull/2902) + + Set socket options for peer connections. + +- [#2665](https://github.com/tigerbeetle/tigerbeetle/pull/2665) + + Add `tb_client_init_parameters` and implement for Python client. + +### Features + +- [#2921](https://github.com/tigerbeetle/tigerbeetle/pull/2921) + + Fix `tigerbeetle inspect grid` for data files with no used blocks. + +### Internals + +- [#2914](https://github.com/tigerbeetle/tigerbeetle/pull/2914), + [#2913](https://github.com/tigerbeetle/tigerbeetle/pull/2913) + + CI improvements. + +- [#2918](https://github.com/tigerbeetle/tigerbeetle/pull/2918) + + De-genericify `Queue`. + +- [#2912](https://github.com/tigerbeetle/tigerbeetle/pull/2912) + + Modernize `stdx.cut` API + +- [#2732](https://github.com/tigerbeetle/tigerbeetle/pull/2732), + [#2915](https://github.com/tigerbeetle/tigerbeetle/pull/2915), + [#2920](https://github.com/tigerbeetle/tigerbeetle/pull/2920) + + Trial a git-native + offline-first code review interface. + +- [#2924](https://github.com/tigerbeetle/tigerbeetle/pull/2924), + [#2911](https://github.com/tigerbeetle/tigerbeetle/pull/2911), + [#2910](https://github.com/tigerbeetle/tigerbeetle/pull/2910), + [#2909](https://github.com/tigerbeetle/tigerbeetle/pull/2909) + + Devhub improvements. In particular, ensure that a failing canary fuzzer is obvious. + +- [#2905](https://github.com/tigerbeetle/tigerbeetle/pull/2905) + + Compile scripts for `zig build ci`. + +### TigerTracks 🎧 + +- [Eventide](https://www.youtube.com/watch?v=uMedRcXMDz0) + +## TigerBeetle 0.16.37 + +Released: 2025-04-21 + +### Safety And Performance + +- [#2896](https://github.com/tigerbeetle/tigerbeetle/pull/2896) + + Fix a bug where VOPR latencies were computed incorrectly when the minimum + and mean were equal. + +### Internals + +- [#2893](https://github.com/tigerbeetle/tigerbeetle/pull/2893) + + Improve binary size and compilation time by making implementation of intrusive stack non-generic. + +- [#2898](https://github.com/tigerbeetle/tigerbeetle/pull/2898) + + Make the Docs website pass the W3C HTML validation test. + +- [#2883](https://github.com/tigerbeetle/tigerbeetle/pull/2883) + + Fix a memory leak in VOPR and apply idiomatic naming conventions for the allocator. + +- [#2901](https://github.com/tigerbeetle/tigerbeetle/pull/2901) + + Fix a panic where the VOPR attempts to print a deinitialized packet when debug + logs are enabled. + +### TigerTracks 🎧 + +- [Money](https://www.youtube.com/watch?v=mSNTa9kmUsk) + +## TigerBeetle 0.16.36 + +Released: 2025-04-14 + +### Safety And Performance + +- [#2891](https://github.com/tigerbeetle/tigerbeetle/pull/2891) + + Fix journal disjoint-buffer assertion. + +- [#2887](https://github.com/tigerbeetle/tigerbeetle/pull/2887) + + Make object cache optional. This improves throughput by ~10%, as we can omit the object cache from + the account events groove, which never uses it. + +### Internals + +- [#2888](https://github.com/tigerbeetle/tigerbeetle/pull/2888) + + Rename FIFO to Queue + +- [#2882](https://github.com/tigerbeetle/tigerbeetle/pull/2882) + + Add `zig build ci` to help run CI checks locally. + +### TigerTracks 🎧 + +- [The Great Gig in the Sky](https://www.youtube.com/watch?v=2PMnJ_Luk_o) + +## TigerBeetle 0.16.35 + +Released: 2025-04-07 + +Please note that after [#2787](https://github.com/tigerbeetle/tigerbeetle/pull/2787), which adds +batching support for all operations, the `batch_max` limit has changed from 8190 to 8189 +accounts/transfers per batch. + +### Safety And Performance + +- [#2787](https://github.com/tigerbeetle/tigerbeetle/pull/2787) + + Add support for batching multiple independent requests of the same operation within a single VSR + message, amortizing network and consensus costs. + + Earlier, we batched only create_accounts and create_transfers. Now, we can batch any operation! + +### Internals + +- [#2878](https://github.com/tigerbeetle/tigerbeetle/pull/2878) + + Use TigerBeetle's time abstraction as opposed to raw OS time in the tracer. + +- [#2881](https://github.com/tigerbeetle/tigerbeetle/pull/2881) + + Allow custom network packet delay functions in the VOPR. This allows us to simulate latencies for + different network topologies, for example the ring/star topology. + +- [#2866](https://github.com/tigerbeetle/tigerbeetle/pull/2866), + [#2874](https://github.com/tigerbeetle/tigerbeetle/pull/2874) + + Fix a VOPR false positive wherein we were accessing a crashed replica's uninitialized memory. + +- [#2869](https://github.com/tigerbeetle/tigerbeetle/pull/2869) + + Improve error thrown when an invalid --account/transfer-batch-size is passed to the benchmark CLI. + +- [#2876](https://github.com/tigerbeetle/tigerbeetle/pull/2876), + [#2877](https://github.com/tigerbeetle/tigerbeetle/pull/2877) + + Minor refactors to VSR and VOPR. + +- [#2872](https://github.com/tigerbeetle/tigerbeetle/pull/2872), + [#2873](https://github.com/tigerbeetle/tigerbeetle/pull/2873) + + Fixes and improvements in the documentation. + +### TigerTracks 🎧 + +- [All Things Must Pass](https://open.spotify.com/track/1AGridgU0QAWZykQReGWk5) + +## TigerBeetle 0.16.34 + +Released: 2025-03-31 + +### Safety And Performance + +- [#2861](https://github.com/tigerbeetle/tigerbeetle/pull/2861) + + Add basic fuzzing for the state machine. + +- [#2846](https://github.com/tigerbeetle/tigerbeetle/pull/2846) + + Re-do tickless VOPR to simulate fast IOPs. + +- [#2852](https://github.com/tigerbeetle/tigerbeetle/pull/2852) + + Allow simulating one-replica-down scenario in the VOPR. + +- [#2858](https://github.com/tigerbeetle/tigerbeetle/pull/2858) + + Print dropped packets in the VOPR. + +- [#2850](https://github.com/tigerbeetle/tigerbeetle/pull/2850) + + Various changes for VOPR performance mode. + +- [#2821](https://github.com/tigerbeetle/tigerbeetle/pull/2821) + + A quicker request protocol for VSR. + +- [#2848](https://github.com/tigerbeetle/tigerbeetle/pull/2848) + + Account for pulses when computing the size of the request queue in VSR. + +- [#2853](https://github.com/tigerbeetle/tigerbeetle/pull/2853) + + Fix a possible panic in the Node.js client by handling the "too much data" error. + +- [#2860](https://github.com/tigerbeetle/tigerbeetle/pull/2860) + + Fix a possible replica crash if negative timestamps would be provided to AccountFilter or + QueryFilter. + +### Features + +- [#2830](https://github.com/tigerbeetle/tigerbeetle/pull/2830) + + Allow `tigerbeetle inspect` to run on open data files. This helps with getting an idea what's + going on a running cluster without needing to shut it down first. + +### Internals + +- [#2833](https://github.com/tigerbeetle/tigerbeetle/pull/2833) + + Vendor our own BitSet in stdx, TigerBeetle's extended standard library. + This change reduces the Linux binary size by 38KiB. + +- [#2862](https://github.com/tigerbeetle/tigerbeetle/pull/2862) + + Track the REPL execution time on [DevHub](https://devhub.tigerbeetle.com). + +- [#2859](https://github.com/tigerbeetle/tigerbeetle/pull/2859) + + Fix the wording in the [correcting transfers + example](https://docs.tigerbeetle.com/coding/recipes/correcting-transfers/#example). + Thanks @shraddha38! + +- [#2845](https://github.com/tigerbeetle/tigerbeetle/pull/2845) + + Remove the global allocator from the fuzzers and pass the allocator explicitly to align more with + TigerStyle. + +- [#2854](https://github.com/tigerbeetle/tigerbeetle/pull/2854) + + Block merges in the CI based on DevHub pipeline results. + +- [#2840](https://github.com/tigerbeetle/tigerbeetle/pull/2840) + + Add replica/lsm/grid/journal metrics to VSR. + +### TigerTracks 🎧 + +- [Pushing Onwards](https://www.youtube.com/watch?v=a7AhS0SxE1s) + +## TigerBeetle 0.16.33 + +Released: 2025-03-24 + +Note that [#2824](https://github.com/tigerbeetle/tigerbeetle/pull/2824) bumps the oldest supported +client version to 0.16.4, removing backward compatibility with various deprecated features. Please +make sure that all of your clients are running on at least 0.16.4 before upgrading to this release! + +### Safety And Performance + +- [#2835](https://github.com/tigerbeetle/tigerbeetle/pull/2835) + + Add performance mode to the VOPR, which tracks the number and aggregate size of each kind of + message during a run. + +### Features + +- [#2824](https://github.com/tigerbeetle/tigerbeetle/pull/2824) + + Bump the oldest supported client version to 0.16.4, removing backward compatibility with various + deprecated features. + +### Internals + +- [#2826](https://github.com/tigerbeetle/tigerbeetle/pull/2826) + + Simplify the idiom around adding elements to lists with comptime known lengths. + +- [#2827](https://github.com/tigerbeetle/tigerbeetle/pull/2827) + + Better styling for links and block quotes in the documentation. + +- [#2831](https://github.com/tigerbeetle/tigerbeetle/pull/2831) + + Change debug multiversion builds to encapsulate two versions as opposed to five. + +- [#2832](https://github.com/tigerbeetle/tigerbeetle/pull/2832) + + Improve CFO efficacy by retaining Zig build cache across fuzzing iterations. + +- [#2836](https://github.com/tigerbeetle/tigerbeetle/pull/2836) + + Update TigerStyle to add a new rule about using long form arguments in scripts (--force over -f). + +- [#2834](https://github.com/tigerbeetle/tigerbeetle/pull/2834), + [#2837](https://github.com/tigerbeetle/tigerbeetle/pull/2837), + [#2839](https://github.com/tigerbeetle/tigerbeetle/pull/2839), + [#2841](https://github.com/tigerbeetle/tigerbeetle/pull/2841), + + Fix various VOPR false positives. + +### TigerTracks 🎧 + +- [On The Way Home](https://open.spotify.com/track/4Fz1WWr5o0OrlIcZxcyZtK) + +## TigerBeetle 0.16.32 + +Released: 2025-03-17 + +### Safety And Performance + +- [#2798](https://github.com/tigerbeetle/tigerbeetle/pull/2798), + [#2815](https://github.com/tigerbeetle/tigerbeetle/pull/2815) + + Vendor the PRNG, and tweak the API to be less wordy. + + PRNG algorithms tend to change, often for reasons not applicable to TigerBeetle. We need neither + the fastest, nor the most secure PRNG, and it's better if we rotate our PRNG algorithm at our own + pace. + +- [#2813](https://github.com/tigerbeetle/tigerbeetle/pull/2813) + + Fix a case where, if a busy cluster's pipeline is full, the prepare_timeout was never reset. + + This improves performance of a local benchmark for a 6-replica cluster with one replica down and 4 + clients almost ~4x. + +### Internals + +- [#2804](https://github.com/tigerbeetle/tigerbeetle/pull/2804), + [#2806](https://github.com/tigerbeetle/tigerbeetle/pull/2806), + [#2803](https://github.com/tigerbeetle/tigerbeetle/pull/2803) + + Add syntax highlighting to docs code snippets (thanks @nilskch!), fix Python example code (thanks + @IvoCrnkovic!) and update our HACKING.md with current practices. + +- [#2819](https://github.com/tigerbeetle/tigerbeetle/pull/2819), + [#2814](https://github.com/tigerbeetle/tigerbeetle/pull/2814), + [#2812](https://github.com/tigerbeetle/tigerbeetle/pull/2812) + + Prepare for the [Zig 0.14 upgrade](https://github.com/tigerbeetle/tigerbeetle/pull/2705) by + applying as many changes that still work on 0.13 as possible. This includes vendoring AEGIS, to + keep hash stability, as 0.14 changes the implementation. + +### TigerTracks 🎧 + +- [Get Ready](https://open.spotify.com/track/4tvOVmc2jorV20Z2hFDtDg) + +## TigerBeetle 0.16.31 + +Released: 2025-03-09 + +### Safety And Performance + +- [#2790](https://github.com/tigerbeetle/tigerbeetle/pull/2790) + + Use LIFO instead of FIFO for free blocks during compaction for better temporal locality. + +- [#2799](https://github.com/tigerbeetle/tigerbeetle/pull/2799) + + Disallow converting negative big integers to `UInt128` in the Java and Go clients, as they + would be incorrectly interpreted as unsigned big integers when converted back. + +- [#2801](https://github.com/tigerbeetle/tigerbeetle/pull/2801) + + Remove spurious `write_reply_next()` after read completes in `client_replies`, + as reads and writes can happen concurrently. + +- [#2783](https://github.com/tigerbeetle/tigerbeetle/pull/2783) + + Assert that `cache_map.stash` is not using any element beyond its defined capacity. + +### Features + +- [#2725](https://github.com/tigerbeetle/tigerbeetle/pull/2725) + + Various REPL fixes and improvements. + +- [#2773](https://github.com/tigerbeetle/tigerbeetle/pull/2773) + + Add single-page mode to the documentation website. + +### Internals + +- [#2791](https://github.com/tigerbeetle/tigerbeetle/pull/2791), + [#2792](https://github.com/tigerbeetle/tigerbeetle/pull/2792), + [#2793](https://github.com/tigerbeetle/tigerbeetle/pull/2793), + [#2794](https://github.com/tigerbeetle/tigerbeetle/pull/2794), + [#2795](https://github.com/tigerbeetle/tigerbeetle/pull/2795), + [#2796](https://github.com/tigerbeetle/tigerbeetle/pull/2796), + [#2807](https://github.com/tigerbeetle/tigerbeetle/pull/2807) + + Miscellaneous documentation typo fixes, clarifications, and references. + +- [#2788](https://github.com/tigerbeetle/tigerbeetle/pull/2788), + [#2802](https://github.com/tigerbeetle/tigerbeetle/pull/2802) + + Ban qualified `std.debug.assert` and `@memcpy`. + +- [#2776](https://github.com/tigerbeetle/tigerbeetle/pull/2776), + [#2800](https://github.com/tigerbeetle/tigerbeetle/pull/2800) + + Fix code comment typos. + +- [#2595](https://github.com/tigerbeetle/tigerbeetle/pull/2595) + + Add tracking of format time and startup time to the [devhub](https://devhub.tigerbeetle.com/). + +### TigerTracks 🎧 + +- [The Hardest Button To Button](https://www.youtube.com/watch?v=K4dx42YzQCE) + +## TigerBeetle 0.16.30 + +Released: 2025-03-03 + +Note: Before performing this upgrade, please make sure to check that no replicas are lagging and +state syncing. + +You can ensure this by temporarily pausing load to the TigerBeetle cluster and waiting for all +replicas to catch up. If some replicas in your cluster were indeed lagging, you should see +`on_repair_sync_timeout: request sync; lagging behind cluster` in the logs, followed by +`sync: ops=`, which indicates the end of state sync. If you don't see the former in the logs, then +you are already safe to upgrade! + +This is to work around an issue in the upgrade between 0.16.25 → 0.16.26, wherein a state syncing +replica goes into a crash loop when it upgrades to 0.16.26. If one of your replicas has already hit +this crash loop, please reach out to us on the Community Slack so we can help you safely revive it. + + +### Safety And Performance + +- [#2774](https://github.com/tigerbeetle/tigerbeetle/pull/2774) + + Fix TOCTOU bug in our hybrid set-associative cache and hash map structure, wherein a promotion + to the cache coupled with an eviction from the cache could lead to invalid pointer references. + +- [#2771](https://github.com/tigerbeetle/tigerbeetle/pull/2771) + + Add logic to crash replica upon receiving unknown commands from clients and other replicas. + +- [#2766](https://github.com/tigerbeetle/tigerbeetle/pull/2766) + + Fix upgrade bug wherein a replica does not detect a change in the binary if it is replaced during + its initialization in `Replica.open()`. + +- [#2761](https://github.com/tigerbeetle/tigerbeetle/pull/2761) + + Alternate replication direction for even and odd ops to better detect breaks in the ring topology. + +### Internals + +- [#2770](https://github.com/tigerbeetle/tigerbeetle/pull/2770), + [#2781](https://github.com/tigerbeetle/tigerbeetle/pull/2781), + [#2779](https://github.com/tigerbeetle/tigerbeetle/pull/2779), + [#2778](https://github.com/tigerbeetle/tigerbeetle/pull/2778), + [#2769](https://github.com/tigerbeetle/tigerbeetle/pull/2769), + + Add new docs content to `TigerBeetle Architecture`, fix miscellaneous typos and references. + +- [#2760](https://github.com/tigerbeetle/tigerbeetle/pull/2760) + + Simplify idiom around a replica sending messages to itself. + +- [#2764](https://github.com/tigerbeetle/tigerbeetle/pull/2764) + + Refactor MessageBus to remove platform-specific IO logic. + +### TigerTracks 🎧 + +- [For Crying Out Loud](https://open.spotify.com/track/4nsd2DbMYqRwkvIQ51r4cp) + +## TigerBeetle 0.16.29 + +Released: 2025-02-24 + +Note: Before performing this upgrade, please make sure to check that no replicas are lagging and +state syncing. + +You can ensure this by temporarily pausing load to the TigerBeetle cluster and waiting for all +replicas to catch up. If some replicas in your cluster were indeed lagging, you should see +`on_repair_sync_timeout: request sync; lagging behind cluster` in the logs, followed by +`sync: ops=`, which indicates the end of state sync. If you don't see the former in the logs, then +you are already safe to upgrade! + +This is to work around an issue in the upgrade between 0.16.25 → 0.16.26, wherein a state syncing +replica goes into a crash loop when it upgrades to 0.16.26. If one of your replicas has already hit +this crash loop, please reach out to us on the Community Slack so we can help you safely revive it. + + +### Safety And Performance + +- [#2763](https://github.com/tigerbeetle/tigerbeetle/pull/2763) + + Explicitly ignore deprecated protocol messages. + +- [#2758](https://github.com/tigerbeetle/tigerbeetle/pull/2758) + + Fix a crash when, during upgrade, replica's binary is changed the second time. + +### Features + +- [#2698](https://github.com/tigerbeetle/tigerbeetle/pull/2698) + + Implement metrics, using statsd format. + +- [#2507](https://github.com/tigerbeetle/tigerbeetle/pull/2507) + + Add new indexes to the account balances to enable CDC. + +- [#2521](https://github.com/tigerbeetle/tigerbeetle/pull/2521), + [#2751](https://github.com/tigerbeetle/tigerbeetle/pull/2751), + [#2756](https://github.com/tigerbeetle/tigerbeetle/pull/2756), + [#2757](https://github.com/tigerbeetle/tigerbeetle/pull/2757), + [#2754](https://github.com/tigerbeetle/tigerbeetle/pull/2754) + + Restructure documentation. + +- [#2727](https://github.com/tigerbeetle/tigerbeetle/pull/2727) + + Implement more standard shortcuts for REPL. + +### Internals + +- [#2742](https://github.com/tigerbeetle/tigerbeetle/pull/2742) + + Refactor C client API to remove internal mutex. + +- [#2747](https://github.com/tigerbeetle/tigerbeetle/pull/2747) + + Don't use deprecated Node.js APIs in the samples. + +- [#2748](https://github.com/tigerbeetle/tigerbeetle/pull/2748), + [#2744](https://github.com/tigerbeetle/tigerbeetle/pull/2744) + + Improve documentation search. + +- [#2734](https://github.com/tigerbeetle/tigerbeetle/pull/2734) + + Switch to vale for documentation spell checking. + + +### TigerTracks 🎧 + +- [Зов Крови](https://open.spotify.com/track/6YS6ZOCL6KX9kDRQRzD9s0) + +## TigerBeetle 0.16.28 + +Released: 2025-02-17 + +Note: Before performing this upgrade, please make sure to check that no replicas are lagging and +state syncing. + +You can ensure this by temporarily pausing load to the TigerBeetle cluster and waiting for all +replicas to catch up. If some replicas in your cluster were indeed lagging, you should see +`on_repair_sync_timeout: request sync; lagging behind cluster` in the logs, followed by +`sync: ops=`, which indicates the end of state sync. If you don't see the former in the logs, then +you are already safe to upgrade! + +This is to work around an issue in the upgrade between 0.16.25 → 0.16.26, wherein a state syncing +replica goes into a crash loop when it upgrades to 0.16.26. If one of your replicas has already hit +this crash loop, please reach out to us on the Community Slack so we can help you safely revive it. + +### Safety And Performance + +- [#2677](https://github.com/tigerbeetle/tigerbeetle/pull/2677) + + Test misdirected writes in the VOPR. + +- [#2711](https://github.com/tigerbeetle/tigerbeetle/pull/2711) + + Fix a recovery correctness bug caused by a misdirected write in the WAL + (discovered by the VOPR in #2677). + +- [#2728](https://github.com/tigerbeetle/tigerbeetle/pull/2728) + + Refactor the tb_client packet interface, hiding private members in an opaque field. + Add assertions to enforce expectations for each packet field. + +- [#2717](https://github.com/tigerbeetle/tigerbeetle/pull/2717) + + Fix a Node.js client crash when it was closed with outstanding requests. + +- [#2720](https://github.com/tigerbeetle/tigerbeetle/pull/2720) + + Flush loopback queue before queueing another prepare_ok. + +- [#2730](https://github.com/tigerbeetle/tigerbeetle/pull/2730) + + Fuzzer weights are now configurable. + +- [#2702](https://github.com/tigerbeetle/tigerbeetle/pull/2702) + + The REPL now uses `StaticAllocator` on init and deinit. + +### Features + +- [#2716](https://github.com/tigerbeetle/tigerbeetle/pull/2716) + + `tigerbeetle inspect constants` now prints a napkin math estimate for the memory usage. + +### Internals + +- [#2719](https://github.com/tigerbeetle/tigerbeetle/pull/2719), + [#2718](https://github.com/tigerbeetle/tigerbeetle/pull/2718), + [#2736](https://github.com/tigerbeetle/tigerbeetle/pull/2736) + + Update docs with new talks, an updated illustration, and a new Slack invite link. + +- [#2724](https://github.com/tigerbeetle/tigerbeetle/pull/2724) + + Don't expose VSR module to dependents in build.zig. + +### TigerTracks 🎧 + +- [Formula 06](https://open.spotify.com/track/7rzRbj2WnmxcE5iQfGbhKN) + +## TigerBeetle 0.16.27 + +Released: 2025-02-10 + +Note: Before performing this upgrade, please make sure to check that no replicas are lagging and +state syncing. + +You can ensure this by temporarily pausing load to the TigerBeetle cluster and waiting for all +replicas to catch up. If some replicas in your cluster were indeed lagging, you should see +`on_repair_sync_timeout: request sync; lagging behind cluster` in the logs, followed by +`sync: ops=`, which indicates the end of state sync. If you don't see the former in the logs, then +you are already safe to upgrade! + +This is to work around an issue in the upgrade between 0.16.25 → 0.16.26, wherein a state syncing +replica goes into a crash loop when it upgrades to 0.16.26. If one of your replicas has already hit +this crash loop, please reach out to us on the Community Slack so we can help you safely revive it. + +### Safety And Performance + +- [#2700](https://github.com/tigerbeetle/tigerbeetle/pull/2700) + + Remove redundant calls to `IO.init()` and `IO.deinit()` during the `format` and `start` commands. + + These redundant calls could lead to an assertion error in the Zig standard library when a failure + occurs after the second `IO.init()`. + +### Features + +- [#2701](https://github.com/tigerbeetle/tigerbeetle/pull/2701) + + Enhance the docs search bar with arrow-key navigation over search results, and folder collapse + using the enter key. + +### Internals +- [#2713](https://github.com/tigerbeetle/tigerbeetle/pull/2713) + + Add [talks](https://github.com/tigerbeetle/tigerbeetle/blob/main/docs/TALKS.md) from + SystemsDistributed '23, P99 CONF '23, Money2020 '24, and SYCL '24. + +- [#2710](https://github.com/tigerbeetle/tigerbeetle/pull/2710) + + Improve CPU utilization of the CFO by spawning fuzzers more frequently. + + Motivated by the measurement that the cumulative CPU was (on average) 40-45% idle. + +- [#2709](https://github.com/tigerbeetle/tigerbeetle/pull/2709) + + Assert zeroed padding for WAL prepares in the VOPR. + +- [#2703](https://github.com/tigerbeetle/tigerbeetle/pull/2703) + + Remove the deprecated version of the start_view message. + + As part of [#2600](https://github.com/tigerbeetle/tigerbeetle/pull/2600), we rolled out a new + on-disk format for the CheckpointState. To avoid bumping the VSR version, we made it so that + replicas temporarily send two versions of the start_view message, with both the old and new + CheckpointState formats. + +- [#2697](https://github.com/tigerbeetle/tigerbeetle/pull/2697) + + Add fair scheduler to the CFO to avoid starvation of short running fuzzers. + + Earlier, long running LSM fuzzers ended up spending more than their fair share of time on the CPU, + with only 1-10% of CFO time being spent on short running VOPR fuzzers. + + +### TigerTracks 🎧 + +- [Neon](https://open.spotify.com/track/7Kohy4v3KLWfUXlv9N3feB) + +## TigerBeetle 0.16.26 + +Released: 2025-02-03 + +Note: Before performing this upgrade, please make sure to check that no replicas are lagging and +state syncing. + +You can ensure this by temporarily pausing load to the TigerBeetle cluster and waiting for all +replicas to catch up. If some replicas in your cluster were indeed lagging, you should see +`on_repair_sync_timeout: request sync; lagging behind cluster` in the logs, followed by +`sync: ops=`, which indicates the end of state sync. If you don't see the former in the logs, then +you are already safe to upgrade! + +This is to work around an issue in the upgrade between 0.16.25 → 0.16.26, wherein a state syncing +replica goes into a crash loop when it upgrades to 0.16.26. If one of your replicas has already hit +this crash loop, please reach out to us on the Community Slack so we can help you safely revive it. + +### Safety And Performance + +- [#2681](https://github.com/tigerbeetle/tigerbeetle/pull/2681) + + Consider blocks and prepares with nonzero padding to be corrupt. + Previously blocks asserted zero padding, which can fail due to bitrot. + + Also fix a similar bug in the superblock copy index handling. The copy index is not covered + by a checksum, so we must treat it carefully to avoid propagating bad data if it is corrupt. + + VOPR now injects single-bit errors into storage rather than whole-sector errors. + +- [#2600](https://github.com/tigerbeetle/tigerbeetle/pull/2600) + + The current checkpoint process immediately frees all blocks released in the previous checkpoint. + This can lead to cluster unavailability by prematurely freeing and overwriting released blocks. + + To fix this, delay freeing blocks until the checkpoint is durable on a commit-quorum, ensuring + data integrity and preventing single-replica failures (in a 3 node cluster) from impacting + availability. + +- [#2692](https://github.com/tigerbeetle/tigerbeetle/pull/2692) + + When state syncing, replicas would send prepare_oks only up to a point, to ensure they don't + falsely contribute to the durability of a non-durable checkpoint they've synced to. + + However, the logic to send these prepare_oks after state sync has finished was missing, which + could lead to a situation where a primary was unavailable to advance. Add in the ability to send + these prepare_oks after syncing. + +- [#2689](https://github.com/tigerbeetle/tigerbeetle/pull/2689) + + Recently, tb_client was reworked to use OS native signals instead of a socket for delivering + cross thread events. + + Fix some incorrect asserts, and add a fuzz test. + +### Features + +- [#2694](https://github.com/tigerbeetle/tigerbeetle/pull/2694), + [#2695](https://github.com/tigerbeetle/tigerbeetle/pull/2695), + [#2686](https://github.com/tigerbeetle/tigerbeetle/pull/2686), + [#2688](https://github.com/tigerbeetle/tigerbeetle/pull/2688), + [#2685](https://github.com/tigerbeetle/tigerbeetle/pull/2685), + [#2676](https://github.com/tigerbeetle/tigerbeetle/pull/2676), + [#2684](https://github.com/tigerbeetle/tigerbeetle/pull/2684) + + A few fixes and an "Edit this page" button for our new docs! + +### Internals + +- [#2674](https://github.com/tigerbeetle/tigerbeetle/pull/2674) + + Allocate the reply buffer in the Go client once the reply has been received. This can save up to + 1MB of memory. + +- [#2680](https://github.com/tigerbeetle/tigerbeetle/pull/2680) + + Refactor parts of our CFO, the process responsible for running fuzzers and the VOPR and sending + the results to devhub, to better handle OOM in subprocesses and reduce false fuzz failures. + +### TigerTracks 🎧 + +- [Like a Prayer](https://open.spotify.com/track/1z3ugFmUKoCzGsI6jdY4Ci) + +## TigerBeetle 0.16.25 + +Released: 2025-01-27 + +### Safety And Performance + +- [#2653](https://github.com/tigerbeetle/tigerbeetle/pull/2653) + + Avoid considering just-repaired journal headers as faulty during WAL recovery. + +- [#2655](https://github.com/tigerbeetle/tigerbeetle/pull/2655) + + Reduce the minimum exponential backoff delay from 100ms (which is too pessimistic) to 10ms, + a value more appropriate for fast networks. + +- [#2662](https://github.com/tigerbeetle/tigerbeetle/pull/2662) + + Introduce a new `CheckpointState` format on disk, which ensures that blocks released during + a checkpoint are freed only when the next checkpoint is durable, solving a known + [liveness issue](https://github.com/tigerbeetle/tigerbeetle/pull/2600). + The previous format is still supported and will be removed in a future release to ensure the + proper upgrade path. + +- [#2605](https://github.com/tigerbeetle/tigerbeetle/pull/2605) + + Demote a clock skew _warning_ to a _debug message_ when the ping time is legitimately behind + the window. On the other hand, assert that the monotonic clock is within the window. + +- [#2659](https://github.com/tigerbeetle/tigerbeetle/pull/2659) + + Workaround to prevent the initialization value for the AOF message from being embedded as a + binary resource, saving `constants.message_size_max` bytes in the executable size! + +- [#2654](https://github.com/tigerbeetle/tigerbeetle/pull/2654) + + Fix a VOPR false positive where it erroneously infers that a replica has lost a prepare that + it has acknowledged. + +### Features + +- [#2479](https://github.com/tigerbeetle/tigerbeetle/pull/2479), + [#2663](https://github.com/tigerbeetle/tigerbeetle/pull/2663) + + New statically generated docs website, featuring many UX improvements while removing tons of + dependencies! Check it out at https://docs.tigerbeetle.com/ + +### Internals + +- [#2661](https://github.com/tigerbeetle/tigerbeetle/pull/2661) + + Make the CFO utilize all cores all the time for running tests, pushing updates every 5 minutes. + +### TigerTracks 🎧 + +- [Correnteza](https://www.youtube.com/watch?v=6m4DSpZgxZw) + +## TigerBeetle 0.16.23 + +Released: 2025-01-20 + +## TigerBeetle (unreleased) + +Released: 2025-01-20 + +(Unreleased due to CI flake.) + +### Safety And Performance + +- [#2652](https://github.com/tigerbeetle/tigerbeetle/pull/2652) + + Fix Python client initialization with long address strings. + +- [#2614](https://github.com/tigerbeetle/tigerbeetle/pull/2614) + + Prevent Client IO thread starvation. + +- [#2583](https://github.com/tigerbeetle/tigerbeetle/pull/2583) + + Set prepare/request timeout RTTs dynamically. Previously our backoff was almost always much too + high, leading to much lower throughput when ring replication failed. + +### Features + +- [#2580](https://github.com/tigerbeetle/tigerbeetle/pull/2580) + + Add `--clients` flag to `tigerbeetle benchmark` for generating load from multiple concurrent + clients. + +### Internals + +- [#2651](https://github.com/tigerbeetle/tigerbeetle/pull/2651) + + For test/verify only functions, assert `constants.verify` at compile-time, not runtime. + +- [#2650](https://github.com/tigerbeetle/tigerbeetle/pull/2650) + + Fix flaky Java client test `testConcurrentInterruptedTasks`. + +- [#2646](https://github.com/tigerbeetle/tigerbeetle/pull/2646) + + Fix VOPR false positive due to `smallest_missing_prepare_between` bug. + +- [#2611](https://github.com/tigerbeetle/tigerbeetle/pull/2611) + + In clients, add `Event`s for efficient cross-thread notification. + +- [#2644](https://github.com/tigerbeetle/tigerbeetle/pull/2644), + [#2648](https://github.com/tigerbeetle/tigerbeetle/pull/2648) + + Devhub fixes due to Ubuntu/kcov. + +### TigerTracks 🎧 + +- [Have You Ever Seen The Rain?](https://www.youtube.com/watch?v=bO28lB1uwp4) + +## TigerBeetle 0.16.21 + +Released: 2025-01-13 + +Happy 2025! + +### Safety And Performance + +- [#2637](https://github.com/tigerbeetle/tigerbeetle/pull/2637) + + Fix multiple VOPR false positives + +- [#2632](https://github.com/tigerbeetle/tigerbeetle/pull/2632) + + Disable costly cache map verification: trust, verify, but mind big-O! + +- [#2629](https://github.com/tigerbeetle/tigerbeetle/pull/2629) + + Improve state sync performance by getting rid of `awaiting_checkpoint` state. + +- [#2624](https://github.com/tigerbeetle/tigerbeetle/pull/2624) + + Improve VOPR coverage when running out of IOPs. + +- [#2593](https://github.com/tigerbeetle/tigerbeetle/pull/2593), + [#2623](https://github.com/tigerbeetle/tigerbeetle/pull/2623), + [#2625](https://github.com/tigerbeetle/tigerbeetle/pull/2625), + [#2626](https://github.com/tigerbeetle/tigerbeetle/pull/2626), + [#2627](https://github.com/tigerbeetle/tigerbeetle/pull/2627), + [#2628](https://github.com/tigerbeetle/tigerbeetle/pull/2628) + + Improve WAL repair performance. + +- [#2613](https://github.com/tigerbeetle/tigerbeetle/pull/2613), + [#2616](https://github.com/tigerbeetle/tigerbeetle/pull/2616), + [#2620](https://github.com/tigerbeetle/tigerbeetle/pull/2620), + [#2622](https://github.com/tigerbeetle/tigerbeetle/pull/2622) + + Improve replication performance. + +- [#2618](https://github.com/tigerbeetle/tigerbeetle/pull/2618) + + Add simple metrics to the state machine. + +### Features + +- [#2615](https://github.com/tigerbeetle/tigerbeetle/pull/2615) + + Greatly improve performance of append-only file (AOF). Note that this changes format of AOF on + disk. + +- [#2641](https://github.com/tigerbeetle/tigerbeetle/pull/2641) + + Add `tigerbeetle inspect constants` command to visualize important compile-time parameters. + +- [#2630](https://github.com/tigerbeetle/tigerbeetle/pull/2630) + + Use asynchronous disk IO on Windows (as a reminder, at the moment TigerBeetle server is considered + to production-ready only on Linux). + +- [#2635](https://github.com/tigerbeetle/tigerbeetle/pull/2635) + + Add experimental alternative replication topologies (star and closed loop). + +### Internals + +- [#2634](https://github.com/tigerbeetle/tigerbeetle/pull/2634) + + Move RingBufferType into stdx, TigerBeetle's extended standard library. + +- [#2639](https://github.com/tigerbeetle/tigerbeetle/pull/2639) + + Run `go vet` on CI. + +- [#2631](https://github.com/tigerbeetle/tigerbeetle/pull/2631) + + Fix tracing compatibility with perfetto. + +- [#2564](https://github.com/tigerbeetle/tigerbeetle/pull/2564), + [#2559](https://github.com/tigerbeetle/tigerbeetle/pull/2559). + + Make it easier to investigate Vortex runs. + +- [#2610](https://github.com/tigerbeetle/tigerbeetle/pull/2610) + + Correctly calculate the number of results in the Go client. Previously, the answer was correct + despite the logic being wrong! + +### TigerTracks 🎧 + +- [Blush Response](https://open.spotify.com/track/0cSnUM2fNEx4pAkNfWpdkU) + +## TigerBeetle 0.16.20 + +Released: 2024-12-27 + +### Safety And Performance + +- [#2608](https://github.com/tigerbeetle/tigerbeetle/pull/2608) + + Improve replication reliability for tiny messages. + +### Features + +- [#2607](https://github.com/tigerbeetle/tigerbeetle/pull/2607) + + Add info-level logging for basic progress events. + +- [#2603](https://github.com/tigerbeetle/tigerbeetle/pull/2603) + + Add logging and runtime configuration parameters for state sync. + +### Internals + +- [#2604](https://github.com/tigerbeetle/tigerbeetle/pull/2604) + + Fix `fuzz_lsm_scan` checkpoint schedule. + +### TigerTracks 🎧 + +- [Street Spirit](https://open.spotify.com/track/2QwObYJWyJTiozvs0RI7CF) + +## TigerBeetle 0.16.19 + +Released: 2024-12-22 + +### Safety And Performance + +- [#2592](https://github.com/tigerbeetle/tigerbeetle/pull/2592) + + Coalesce LSM tables in memory before writing them to disk. This significantly improves workloads + with small batch sizes, that would otherwise churn in the top level of the LSM while incurring + heavy write amplification. + +### Features + +- [#2590](https://github.com/tigerbeetle/tigerbeetle/pull/2590) + + TigerBeetle recently gained the ability to do runtime debug logging with `--log-debug`. Extend + that to other subcommands - not just `start`. + + Additionally, the Python client now has logs integrated with Python's native `logging` module, + which means no more printing to stderr! + +### Internals + +- [#2591](https://github.com/tigerbeetle/tigerbeetle/pull/2591) + + Fix broken links to TigerBeetle blog posts, thanks @PThorpe92! + +### TigerTracks 🎧 + +- [Unwritten](https://www.youtube.com/watch?v=b7k0a5hYnSI) + +## TigerBeetle 0.16.18 + +Released: 2024-12-19 + +### Safety And Performance + +- [#2584](https://github.com/tigerbeetle/tigerbeetle/pull/2584) + + Our repair can create a feedback loop. Repair requests prepares and headers, but, upon receiving + back, we re-trigger repair, which could lead to duplicate repair work. + + To avoid that, make sure that we are not sending more than two repair messages per replica per our + repair timeout. + +- [#2586](https://github.com/tigerbeetle/tigerbeetle/pull/2586) + + Trace AOF write duration + +- [#2582](https://github.com/tigerbeetle/tigerbeetle/pull/2582) + + Timeouts with exponential backoff should reset to their original delay when the timeout is + stopped. + +- [#2577](https://github.com/tigerbeetle/tigerbeetle/pull/2577) + + Assert against ABA problem during commit. + +- [#2578](https://github.com/tigerbeetle/tigerbeetle/pull/2578) + + Add retry for `flock`. `flock`s are cleaned up by the kernel when the file descriptor is closed, + but since that file descriptor is used by io_uring, it actually outlives the process itself. + +- [#2579](https://github.com/tigerbeetle/tigerbeetle/pull/2579) + + Fix `mlock` flag value. + +- [#2571](https://github.com/tigerbeetle/tigerbeetle/pull/2571) + + Don't crash if a round of view changes happened while repairing the pipeline. + +### Features + +- [#2423](https://github.com/tigerbeetle/tigerbeetle/pull/2423) + + Tab completion in REPL + +- [#2566](https://github.com/tigerbeetle/tigerbeetle/pull/2566) + + Fix `--account-count-hot` flag. + +- [#2576](https://github.com/tigerbeetle/tigerbeetle/pull/2576) + + Fix multi-debit recipe. + +### TigerTracks 🎧 + +- [By The Way](https://www.youtube.com/watch?v=qxQnSH3x3sg) + +## TigerBeetle (unreleased) + +Released: 2024-12-16 + +### Safety And Performance + +- [#2537](https://github.com/tigerbeetle/tigerbeetle/pull/2537) + + Exit cleanly if the database grows to the maximum size. + +- [#2511](https://github.com/tigerbeetle/tigerbeetle/pull/2511) + + Simulate virtual machine migration in the VOPR. + +- [#2561](https://github.com/tigerbeetle/tigerbeetle/pull/2561) + + Test that Java client behaves correctly when its thread is interrupted. + +### Features + +- [#2540](https://github.com/tigerbeetle/tigerbeetle/pull/2540) + + `tigerbeetle format` now automatically generates a random cluster id if it isn't passed on the + command line. To avoid operational errors, it is important that each cluster gets a globally + unique id. + +- [#2558](https://github.com/tigerbeetle/tigerbeetle/pull/2558), + [#2552](https://github.com/tigerbeetle/tigerbeetle/pull/2552) + + Improve error reporting when a client gets evicted due to a mismatched version. + +### Internals + +- [#2542](https://github.com/tigerbeetle/tigerbeetle/pull/2542) + + Switch CLI client to static allocation. + +- [#2562](https://github.com/tigerbeetle/tigerbeetle/pull/2562), + [#2560](https://github.com/tigerbeetle/tigerbeetle/pull/2560) + + Run benchmark under sudo to enable memory locking for accurate RSS stats. + +- [#2556](https://github.com/tigerbeetle/tigerbeetle/pull/2556), + [#2555](https://github.com/tigerbeetle/tigerbeetle/pull/2555) + + Remove many false negatives from unused imports tidy check. + +### TigerTracks 🎧 + +- [You Bring Out The Boogie In Me](https://youtu.be/0AsLHBAbByk) + +## TigerBeetle 0.16.17 + +Released: 2024-12-09 + +This release includes a correctness fix for the preview API `get_account_balances`, +`get_account_transfers`, `query_accounts`, and `query_transfers`. If your application uses these +features it might be affected. + +### Safety And Performance + +- [#2544](https://github.com/tigerbeetle/tigerbeetle/pull/2544) + + Fix correctness bug which affected the preview `get_account_balances`, `get_account_transfers`, + `query_accounts`, and `query_transfers` queries. Specifically, if several filters are used (that + is, several fields in `QueryFilter` or `AccountFilter` are set), then some objects might be + missing from the result set. + + The underlying data is safely stored in the database, so re-running these queries using the new + version of TigerBeetle will give correct results. + +- [#2550](https://github.com/tigerbeetle/tigerbeetle/pull/2550) + + Fix panic in the node client which occurred on eviction. + +- [#2534](https://github.com/tigerbeetle/tigerbeetle/pull/2534) + + Fix incorrect ABI used on aarch64 for the client library. To prevent such issues from cropping up + in the future, add aarch64 testing to CI. + +- [#2520](https://github.com/tigerbeetle/tigerbeetle/pull/2520) + + Add extra assertions to verify object cache consistency. + +- [#2485](https://github.com/tigerbeetle/tigerbeetle/pull/2485) + + Implement network fault injection in Vörtex, our non-deterministic whole system simulator. + +### Features + +- [#2539](https://github.com/tigerbeetle/tigerbeetle/pull/2539) + + Log level can now be specified at runtime. + +- [#2538](https://github.com/tigerbeetle/tigerbeetle/pull/2538) + + Log messages now include UTC timestamp (formatted as per [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339)). + +### Internals + +- [#2543](https://github.com/tigerbeetle/tigerbeetle/pull/2543) + + Relax compiler requirements for building TigerBeetle. While we only support building using one + specific version of Zig, the one downloaded via `./zig/download.sh`, you can try using other + versions. + +- [#2533](https://github.com/tigerbeetle/tigerbeetle/pull/2533) + + Document how to handle errors during release. TigerBeetle's release process is complicated, as we + need to release, in lockstep, both the `tigerbeetle` binary and all the related client libraries. + The release process is intentionally designed to be "highly-available", such that a failure of any + aspect of a release can be safely detected, isolated, and repaired. But, so far, this wasn't + clearly spelled out in the documentation! + +### TigerTracks 🎧 + +- [The Beetle's Buzz](https://soundcloud.com/kprotty/the-beetles-buzz) + +## TigerBeetle 0.16.16 + +Released: 2024-12-02 + +The highlight of today's release is the new official Python client, implemented in +[#2527](https://github.com/tigerbeetle/tigerbeetle/pull/2527), and +[#2487](https://github.com/tigerbeetle/tigerbeetle/pull/2487). Please kick the tires! + +### Safety And Performance + +- [#2517](https://github.com/tigerbeetle/tigerbeetle/pull/2517) + + Require that all replicas in a cluster have the latest TigerBeetle binary as a precondition for an + upgrade. + +- [#2506](https://github.com/tigerbeetle/tigerbeetle/pull/2506) + + Fix several bugs when handling misdirected writes. That is, situations when the disk reports + a write as successful, despite the write ending up in the wrong place on the disk! + +- [#2478](https://github.com/tigerbeetle/tigerbeetle/pull/2478) + + Make sure that TigerBeetle memory is not swappable, otherwise a storage fault can occur in a + currently swapped-out page, circumventing TigerBeetle guarantees. + +- [#2522](https://github.com/tigerbeetle/tigerbeetle/pull/2522) + + REPL correctly emits errors when several objects are used as an argument of an operation that + only works for a single object, like `get_account_transfers`. + +- [#2502](https://github.com/tigerbeetle/tigerbeetle/pull/2502) + + Add randomized integration tests for the Java client. + +### Features + +- [#2527](https://github.com/tigerbeetle/tigerbeetle/pull/2527), + [#2487](https://github.com/tigerbeetle/tigerbeetle/pull/2487) + + The Python client! + +### Internals + +- [#2526](https://github.com/tigerbeetle/tigerbeetle/pull/2526) + + Ignore OOM failures during fuzzing. Fuzzers normally don't use that much memory, but, depending on + random parameters selected by swarm testing, there are big outliers. If all concurrent fuzzers hit + a seed that requires a lot of memory, a fuzzing machine runs out of physical RAM. Handle such + errors and don't treat them as fuzzing failures. + +- [#2510](https://github.com/tigerbeetle/tigerbeetle/pull/2510) + + Track the number of untriaged issues on [DevHub](https://devhub.tigerbeetle.com/). + +### TigerTracks 🎧 + +- [Always Look On The Bright Side of Life](https://www.youtube.com/watch?v=X_-q9xeOgG4) + +## TigerBeetle 0.16.14 + +Released: 2024-11-25 + +### Safety And Performance + +- [#2501](https://github.com/tigerbeetle/tigerbeetle/pull/2501) + + Call `DetachCurrentThread` when the Java client is closed. The underlying Zig TigerBeetle client + runs in a separate thread internally, and a handler to this thread was being leaked. + + This is not noticeable in normal operation, but could impact long running processes that + create and close clients frequently. + +- [#2492](https://github.com/tigerbeetle/tigerbeetle/pull/2492) + + Document that it's not possible to currently look up or query more than a full batch of accounts + atomically without using the history flag and querying balances. + +- [#2434](https://github.com/tigerbeetle/tigerbeetle/pull/2434) + + Add the ability to check timestamp order - and verify they are monotonically increasing - for + accounts and transfers inside Vortex. + +### Internals + +- [#2455](https://github.com/tigerbeetle/tigerbeetle/pull/2455) + + Recently the VOPR has gotten too good, and it's very tempting to switch to an empirical mode of + coding: write some code and let the VOPR figure out whether it is correct or not. + + This is suboptimal - silence of the VOPR doesn't guarantee total absence of bugs and safety comes + in layers and cross checks. Just formal or informal reasoning is not enough, we need both. + + Document this in TigerStyle. + +- [#2472](https://github.com/tigerbeetle/tigerbeetle/pull/2472) + + Previously, the Zig part of languages clients logged directly to stderr using Zig's `std.log`, but + since directly outputting to stderr is considered rude for a library, logging was disabled. + + This PR adds in scaffolding for sending these logs to the client language to be handled there, + tying in with native log libraries (e.g., Log4j). No languages use it yet, however. + + Additionally, log `warn` and `err` directly to stderr, if there's no handler. + +### TigerTracks 🎧 + +- [Hello](https://www.youtube.com/watch?v=kK42LZqO0wA) + +## TigerBeetle 0.16.13 + +Released: 2024-11-18 + +### Safety And Performance + +- [#2461](https://github.com/tigerbeetle/tigerbeetle/pull/2461) + + Fix a broken assert when a recently-state-synced replica that has not completed journal repair + receives an old `commit` message. + +- [#2474](https://github.com/tigerbeetle/tigerbeetle/pull/2474) + + Retry `EAGAIN` on (disk) reads. This is essential for running TigerBeetle on XFS, since XFS + returns `EAGAIN` unexpectedly. + +- [#2476](https://github.com/tigerbeetle/tigerbeetle/pull/2476) + + Fix a message bus crash when a client reconnects to a replica without the replica receiving a + disconnect for the first connection. + +- [#2475](https://github.com/tigerbeetle/tigerbeetle/pull/2475) + + Save 256KiB of RAM by not having a prefetch cache for historical balances. + (Historical balances are never prefetched, so this cache was unused.) + +- [#2482](https://github.com/tigerbeetle/tigerbeetle/pull/2482) + + Update hardware requirements in the documentation to include the recommended network bandwidth, + advice for very large data files, and farther emphasis on the importance of ECC RAM. + +- [#2484](https://github.com/tigerbeetle/tigerbeetle/pull/2484) + + Don't panic the client when the client's session is + [evicted](https://docs.tigerbeetle.com/reference/sessions/#eviction). + Instead, report an error any time a new batch is submitted to the evicted client. + (How the error is reported depends on the client language – e.g. Java throws an exception, whereas + Node.js rejects the `Promise`). + + Note that if running clients are evicted, that typically indicates that there are too many clients + running – check out the + [suggested system architecture](https://docs.tigerbeetle.com/coding/system-architecture/). + +### Features + +- [#2464](https://github.com/tigerbeetle/tigerbeetle/pull/2464) + + Add REPL interactivity. Also change the REPL from dynamic to static allocation. + Thanks @wpaulino! + +### Internals + +- [#2481](https://github.com/tigerbeetle/tigerbeetle/pull/2481) + + Expose the VSR timestamp to the client. (This is an experimental feature which will be removed + soon – don't use this!) + +### TigerTracks 🎧 + +- [Olympic Airways](https://www.youtube.com/watch?v=4BcNLA2KB2c) + +## TigerBeetle 0.16.12 + +Released: 2024-11-11 + +### Safety And Performance + +- [#2435](https://github.com/tigerbeetle/tigerbeetle/pull/2435) + + Fix an attempt to access uninitialized fields of `tb_packet_t` when `tb_client_deinit` aborts + pending requests. Also add Java unit tests to reproduce the problem and validate the fix. + +- [#2437](https://github.com/tigerbeetle/tigerbeetle/pull/2437) + + Fix a liveness issue where the cluster gets stuck despite sufficient durability, caused by buggy + logic for cycling through faulty blocks during repair. Now, we divide the request buffer between + the `read_global_queue` and `faulty_blocks`, ensuring that we always request blocks from both. + +### Features + +- [#2462](https://github.com/tigerbeetle/tigerbeetle/pull/2462) + + Update DevHub styling. + +- [#2424](https://github.com/tigerbeetle/tigerbeetle/pull/2424) + + Vortex can now not only crash replicas (by killing and restarting the process) but also stop and + resume them. + +### Internals + +- [#2458](https://github.com/tigerbeetle/tigerbeetle/pull/2458) + + Fix the Dotnet walkthrough example that misused `length` instead of the final index when slicing + an array. Thanks @tenatus for reporting it! + +- [#2453](https://github.com/tigerbeetle/tigerbeetle/pull/2453) + + Fix a CI failure caused by concurrent processes trying to create the `fs_supports_direct_io` + probe file in the same path. + +- [#2441](https://github.com/tigerbeetle/tigerbeetle/pull/2441) + + Replace curl shell invocation with Zig's http client. 😎 + +- [#2454](https://github.com/tigerbeetle/tigerbeetle/pull/2454) + + Properly handle "host unreachable" (`EHOSTUNREACH`) on Linux, instead of returning unexpected + error. + +- [#2443](https://github.com/tigerbeetle/tigerbeetle/pull/2443), + [#2451](https://github.com/tigerbeetle/tigerbeetle/pull/2451), + [#2459](https://github.com/tigerbeetle/tigerbeetle/pull/2459), + [#2460](https://github.com/tigerbeetle/tigerbeetle/pull/2460), + [#2465](https://github.com/tigerbeetle/tigerbeetle/pull/2465) + + Various code refactorings to improve naming conventions, readability, and organization. + +### TigerTracks 🎧 + +- [Scatman (Ski-ba-bop-ba-dop-bop)](https://www.youtube.com/watch?v=ZhSY7vYbXkk&t=106s) + +## TigerBeetle (unreleased) + +Released: 2024-11-04 + +### Safety And Performance + +- [#2356](https://github.com/tigerbeetle/tigerbeetle/pull/2356) + + Add "Vortex" – a full-system integration test. + Notably, unlike the VOPR this test suite covers the language clients. + +- [#2430](https://github.com/tigerbeetle/tigerbeetle/pull/2430) + + Cancel in-flight async (Linux) IO before freeing memory. This was not an issue on the replica + side, as replicas only stop when their process stops. However, clients may be closed + without the process also ending. If IO is still in flight when this occurs, we must ensure that + all IO is cancelled before the client's buffers are freed, to guard against a use-after-free. + + This PR also fixes an unrelated assertion failure that triggered when closing a client that + had already closed its socket. + +- [#2432](https://github.com/tigerbeetle/tigerbeetle/pull/2432) + + On startup and after checkpoint, assert that number of blocks acquired by the free set is + consistent with the number of blocks we see acquired via the manifest and checkpoint trailers. + +- [#2436](https://github.com/tigerbeetle/tigerbeetle/pull/2436) + + Reject connections from unknown replicas. + +- [#2438](https://github.com/tigerbeetle/tigerbeetle/pull/2438) + + Fix multiversion builds on MacOS. + +- [#2442](https://github.com/tigerbeetle/tigerbeetle/pull/2442) + + On an unrecognized error code from the OS, print that error before we panic. + (This was already the policy in `Debug` builds, but now it includes `ReleaseSafe` as well.) + +- [#2444](https://github.com/tigerbeetle/tigerbeetle/pull/2444) + + Fix a panic involving an in-flight write to an old reply after state sync. + +### Internals + +- [#2440](https://github.com/tigerbeetle/tigerbeetle/pull/2440) + + Expose reply timestamp from `vsr.Client`. + (Note that this is not yet surfaced by language clients). + +### TigerTracks 🎧 + +- [Everything In Its Right Place](https://www.youtube.com/watch?v=onRk0sjSgFU) + +## TigerBeetle 0.16.11 + +Released: 2024-10-28 + +### Safety And Performance + +- [#2428](https://github.com/tigerbeetle/tigerbeetle/pull/2428) + + Make `Grid.reserve()` abort rather than returning null. + When `Grid.reserve()` aborts, that indicates that the data file size limit would be exceeded by + the reservation. We were already panicking in this case by unwrapping the result, but now it has + a useful error message. + +- [#2416](https://github.com/tigerbeetle/tigerbeetle/pull/2416) + + Improve availability and performance by sending `start_view` message earlier in the new-primary + recovery – as soon as the journal headers are repaired. + +- [#2360](https://github.com/tigerbeetle/tigerbeetle/pull/2360) + + Refactor compaction to clarify the scheduling logic, schedule more aggressively, and make it + easier to run multiple compactions concurrently. This also improved the benchmark performance. + +### Features + +- [#2425](https://github.com/tigerbeetle/tigerbeetle/pull/2425) + + Support multiversion (non-automatic) upgrades when the replica is started with `--development` + or `--experimental`. + +### Internals + +- [#2427](https://github.com/tigerbeetle/tigerbeetle/pull/2427) + + Allow a release's Git tag and `config.process.release` to differ. This simplifies the release + process for hotfixes, when the Git tag is bumped but the `config.process.release` is unchanged. + +### TigerTracks 🎧 + +- [Stuck in a Timeloop](https://www.youtube.com/watch?v=FWBjzQnDb8o) + +## TigerBeetle 0.16.10 + +Released: 2024-10-21 + +### Safety And Performance + +- [#2414](https://github.com/tigerbeetle/tigerbeetle/pull/2414) + + Improve performance & availability during view change by ensuring a replica only repairs the + portion of the WAL that is *required* to become primary, instead of repairing it in its entirety. + +- [#2412](https://github.com/tigerbeetle/tigerbeetle/pull/2412) + + Add a unit test for Zig's stdlib sort. + + Stable sort is critical for compaction correctness. Zig stdlib does have a sort fuzz test, but it + doesn't cover the presorted subarray case, and doesn't check arrays much larger than the sort + algorithm's on-stack cache. + +- [#2413](https://github.com/tigerbeetle/tigerbeetle/pull/2413) + + Fix a bug in the MessageBus wherein connections weren't being terminated during client teardown. + +### Internals + + +- [#2405](https://github.com/tigerbeetle/tigerbeetle/pull/2405) + + Fix a bug in the benchmark wherein the usage of `--account-count-hot` was broken when used in + conjunction with the `uniform` distribution. + +- [#2409](https://github.com/tigerbeetle/tigerbeetle/pull/2409) + + Revamp the `core_missing_prepares` liveness-mode check to correctly check for the prepares that a + replica should repair (after [#2414](https://github.com/tigerbeetle/tigerbeetle/pull/2414)). + + +### TigerTracks 🎧 + +- [Last Train Home](https://open.spotify.com/track/0tgBtQ0ISnMQOKorrN9HLX) + +## TigerBeetle 0.16.9 + +Released: 2024-10-15 + +### Safety And Performance + +- [#2394](https://github.com/tigerbeetle/tigerbeetle/pull/2394), + [#2401](https://github.com/tigerbeetle/tigerbeetle/pull/2401) + + TigerBeetle clients internally batch operations for improved performance. Fix a bug where an + unclosed link chain could be batched before another linked chain, causing them to be treated as + one long linked chain. Additionally, prevent non-batchable requests from sharing packets entirely. + +- [#2398](https://github.com/tigerbeetle/tigerbeetle/pull/2398) + + `AMOUNT_MAX` is used as a sentinel value for things like balancing transfers to specify moving + as much as possible. Correct and fix its value in the Java client. Thanks @tKe! + +### Features + +- [#2274](https://github.com/tigerbeetle/tigerbeetle/pull/2274) + + Improve the benchmark by adding Zipfian distributed random numbers, to better simulate realistic + conditions and as a precursor to approximating YCSB. + +- [#2393](https://github.com/tigerbeetle/tigerbeetle/pull/2393) + + Previously, TigerBeetle's clients disallowed empty batches locally, before the request was even + sent to the cluster. However, this is actually a valid protocol message - even if it's not used by + the current state machine - so allow empty batches to be sent from clients. + +- [#2384](https://github.com/tigerbeetle/tigerbeetle/pull/2384) + + Revamp client documentation so that each snippet is self-contained, and standardize it across all + languages. + +### Internals + +- [#2404](https://github.com/tigerbeetle/tigerbeetle/pull/2404) + + Give the [DevHub](https://devhub.tigerbeetle.com/) a fresh coat of paint, and fix + passing seeds being blue in dark mode. + +- [#2408](https://github.com/tigerbeetle/tigerbeetle/pull/2408), + [#2400](https://github.com/tigerbeetle/tigerbeetle/pull/2400), + [#2391](https://github.com/tigerbeetle/tigerbeetle/pull/2391), + [#2399](https://github.com/tigerbeetle/tigerbeetle/pull/2399), + [#2402](https://github.com/tigerbeetle/tigerbeetle/pull/2402), + [#2385](https://github.com/tigerbeetle/tigerbeetle/pull/2385) + + Improve VOPR logging and fix a few failing seeds. + +### TigerTracks 🎧 + +- [99 Luftballons](https://www.youtube.com/watch?v=Fpu5a0Bl8eY) + +## TigerBeetle 0.16.8 + +Released: 2024-10-07 + +### Safety And Performance + +- [#2359](https://github.com/tigerbeetle/tigerbeetle/pull/2359) + + Significantly reduced P100 latency by incrementally spreading the mutable table's sort during + compaction. This leverages the optimization of sort algorithms for processing sequences of + already sorted sub-arrays. + +- [#2367](https://github.com/tigerbeetle/tigerbeetle/pull/2367) + + Improve the workload generator to support concurrent tests with different ledgers. + +- [#2382](https://github.com/tigerbeetle/tigerbeetle/pull/2382), + [#2363](https://github.com/tigerbeetle/tigerbeetle/pull/2363) + + Fix VOPR seeds. + For more awesome details about the backstory and solutions to these issues, + please refer to the PR. + +### Features + +- [#2358](https://github.com/tigerbeetle/tigerbeetle/pull/2358) + + Update the REPL to support representing the maximum integer value as `-0`, + serving as the `AMOUNT_MAX` sentinel. + Additionally, other negative values such as `-1` can be used to represent `maxInt - 1`. + + Also, include support for hexadecimal numbers for more convenient inputting of GUID/UUID + literals (e.g. `0xa1a2a3a4_b1b2_c1c2_d1d2_e1e2e3e4e5e6`). + + Allow the `timestamp` field to be set, enabling the REPL to be used for `imported` events. + +### Internals + +- [#2376](https://github.com/tigerbeetle/tigerbeetle/pull/2376) + + Use `zig fetch` as a replacement for downloading files, removing dependence on external tools. + +- [#2383](https://github.com/tigerbeetle/tigerbeetle/pull/2383) + + Port of Rust's [`dbg!`](https://doc.rust-lang.org/std/macro.dbg.html) macro to Zig, + and the corresponding CI validation to prevent code using it from being merged into `main`! 😎 + +- [#2370](https://github.com/tigerbeetle/tigerbeetle/pull/2370), + [#2373](https://github.com/tigerbeetle/tigerbeetle/pull/2373) + + Verify the release versions included in the multiversion binary pack at build time (not only + during runtime) and improve the `tigerbeetle version --verbose` command's `multiversion` output. + +- [#2369](https://github.com/tigerbeetle/tigerbeetle/pull/2369) + + Fix a multiversioning issue where the binary size exceeded the read buffer, failing to parse the + executable header. + +- [#2380](https://github.com/tigerbeetle/tigerbeetle/pull/2380) + + Consistently use `transient_error` instead of `transient_failure` and cleanup the StateMachine + code. + +- [#2379](https://github.com/tigerbeetle/tigerbeetle/pull/2379) + + Add missing links to the operations `query_accounts` and `query_transfers` in the documentation + and include the declaration for `QueryFilter` and `QueryFilterFlags` in the `tb_client.h` header. + +- [#2333](https://github.com/tigerbeetle/tigerbeetle/pull/2333) + + Clearer error message when the replica crashes due to a data file being too large, instructing + the operator to increase the memory allocated for the manifest log. + +### TigerTracks 🎧 + +- [Creep](https://www.youtube.com/watch?v=XFkzRNyygfk) + +## TigerBeetle 0.16.7 + +Released: 2024-10-04 + +Note: this is an extra release to correct an availability issue in the upgrade path for `0.16.4`. +Specifically, the combination of `tigerbeetle 0.16.4` and a client at `0.16.3` can lead to an +assertion failure and a server crash. No data is lost, but the server becomes unavailable. + +It is recommended to upgrade to `0.16.7`, but this is only _required_ if you are running older +clients. To upgrade, replace the binary on disk, and manually restart the replica. + +Note that although the release is tagged at `0.16.7`, the binary advertises itself as `0.16.4`. + +### Safety And Performance + +- [#2377](https://github.com/tigerbeetle/tigerbeetle/pull/2378) + + Fix an assertion which was incorrect when a pre-transient-error client retried a transient error, + and that transient error condition since disappeared. This mirrors #2345 which handles the case + when it is still failing. + +## TigerBeetle 0.16.6 + +Released: 2024-10-04 + +Note: this is an extra release to correct a potential issue in the upgrade path for `0.16.4`. +Specifically, the combination of `tigerbeetle 0.16.2` and newer, and any client before `0.16.2` can +lead to an assertion failure and a server crash. No data is lost, but the server becomes +unavailable. + +It is recommended to upgrade to `0.16.6`, but this is only _required_ if you are running older +clients. To upgrade, replace the binary on disk, and manually restart the replica. + +Note that although the release is tagged at `0.16.6`, the binary advertises itself as `0.16.4`. + +### Safety And Performance + +- [#2377](https://github.com/tigerbeetle/tigerbeetle/pull/2377) + + Correctly parse AccountFilter from pre `0.16.2` clients. + +## TigerBeetle 0.16.5 + +Released: 2024-10-03 + +Note: this is an extra release to correct an availability issue in the upgrade path for `0.16.4`. +Specifically, the combination of `tigerbeetle 0.16.4` and a client at `0.16.3` can lead to an +assertion failure and a server crash. No data is lost, but the server becomes unavailable. + +It is recommended to upgrade to `0.16.5`, but this is only _required_ if you are running older +clients. To upgrade, replace the binary on disk, and manually restart the replica. + +Note that although the release is tagged at `0.16.5`, the binary advertises itself as `0.16.4`. + +### Safety And Performance + +- [#2345](https://github.com/tigerbeetle/tigerbeetle/pull/2345) + + Fix an assertion which was incorrect when a pre-transient-error client retried a transient error. + +## TigerBeetle 0.16.4 + +Released: 2024-09-30 + +This release introduces "transient errors": error codes for `create_transfers` which depend on the +state of the database (e.g. `exceeds_credits`). Going forward, a transfer that fails with a +transient error will not succeed if retried. + +See the [API tracking issue](https://github.com/tigerbeetle/tigerbeetle/issues/2231#issuecomment-2377879726) +and the [documentation](https://docs.tigerbeetle.com/reference/requests/create_transfers) +for more details. + +### Safety And Performance + +- [#2345](https://github.com/tigerbeetle/tigerbeetle/pull/2345) + + Reduce chance of `recovering_head` status by recovering from torn writes in the WAL. + This improves the availability of the cluster, as `recovering_head` replicas cannot participate in + consensus until after they repair. + +### Features + +- [#2335](https://github.com/tigerbeetle/tigerbeetle/pull/2335) + + Ensure idempotence for `create_transfers`' "transient errors" with new result code + `id_already_failed`. In particular, this guards against surprising behavior when the client is + running in a [stateless API service](https://docs.tigerbeetle.com/coding/system-architecture/). + +### Internals + +- [#2334](https://github.com/tigerbeetle/tigerbeetle/pull/2334), + [#2362](https://github.com/tigerbeetle/tigerbeetle/pull/2362) + + Fix VOPR false positives. + +- [#2115](https://github.com/tigerbeetle/tigerbeetle/pull/2115) + + Fix multiple commands for non-interactive REPL. + +### TigerTracks 🎧 + +- [Lookin' Out My Back Door](https://www.youtube.com/watch?v=Aae_RHRptRg) + +## TigerBeetle 0.16.3 + +Released: 2024-09-23 + +### Safety And Performance + +- [#2313](https://github.com/tigerbeetle/tigerbeetle/pull/2313) + + Improve cluster availability by more aggressive recovery for crashes that happen while a replica + is checkpointing. + +- [#2328](https://github.com/tigerbeetle/tigerbeetle/pull/2328) + + Add a more efficient recipe for balance-conditional transfers. A balance-conditional transfer + is a transfer that succeeds only if the source account has more than a threshold amount of funds + in it. + +### Features + +- [#2327](https://github.com/tigerbeetle/tigerbeetle/pull/2327) + + Add a new recipe for enforcing `debits_must_not_exceed_credits` on some subset of transfers (this + is a special case of a balance-conditional transfer, with the threshold value being equal to + transferred amount). + +### Internals + +- [#2330](https://github.com/tigerbeetle/tigerbeetle/pull/2330) + + Add `triaged` issue label to prevent newly opened issues from slipping through the cracks. + +- [#2332](https://github.com/tigerbeetle/tigerbeetle/pull/2332) + + Add CI check for dead code. + +- [#2323](https://github.com/tigerbeetle/tigerbeetle/pull/2323) + + Cleanup the source tree by removing top-level `tools` directory. + +- [#2316](https://github.com/tigerbeetle/tigerbeetle/pull/2316) + + Make sure that process-spawning API used for build-time "scripting" consistently reports + errors when the subprocess fails or hangs. + +### TigerTracks 🎧 + +- [Prelude in G Major](https://open.spotify.com/track/70FROKEHubzMxSstCgaZZl) + +## TigerBeetle 0.16.2 + +Released: 2024-09-16 + +### Safety And Performance + +- [#2312](https://github.com/tigerbeetle/tigerbeetle/pull/2312) + + Tighten up the VSR assertions so the transition to `.recovering_head` can only be called from the + `.recovering` status. + +- [#2311](https://github.com/tigerbeetle/tigerbeetle/pull/2311) + + Make the primary abdicate if it is unable to process requests due to a broken clock. + +- [#2260](https://github.com/tigerbeetle/tigerbeetle/pull/2260) + + Smoke integration test using the real multiversion binary. + +- [#2270](https://github.com/tigerbeetle/tigerbeetle/pull/2270) + + Workload generator based on the Java client to be used in integration tests (i.e. Antithesis). + +### Features + +- [#2298](https://github.com/tigerbeetle/tigerbeetle/pull/2298), + [#2307](https://github.com/tigerbeetle/tigerbeetle/pull/2307), + [#2304](https://github.com/tigerbeetle/tigerbeetle/pull/2304), + [#2309](https://github.com/tigerbeetle/tigerbeetle/pull/2309), + [#2321](https://github.com/tigerbeetle/tigerbeetle/pull/2321) + + Remove `Tracy` integration and dependencies. + Add JSON traces for events with multiple running instances, such as IO, lookups, and scans. + +- [#2300](https://github.com/tigerbeetle/tigerbeetle/pull/2300) + + Add the ability to filter by `user_data_{128,64,32}` and `code` in `get_account_transfers` + and `get_account_balances`. + +### Internals + +- [#2314](https://github.com/tigerbeetle/tigerbeetle/pull/2314), + [#2315](https://github.com/tigerbeetle/tigerbeetle/pull/2315) + + Mute the log on stderr when building client libraries. + Reduce the log's severity of some entries logged as `.err` to `.warn` for less noise when + running with `log_level = .err`. + +- [#2310](https://github.com/tigerbeetle/tigerbeetle/pull/2310) + + Document and explain how time works in TigerBeetle ⏱️. + +- [#2291](https://github.com/tigerbeetle/tigerbeetle/pull/2291) + + Refactor `Forest.compact` and remove some dead code. + +- [#2294](https://github.com/tigerbeetle/tigerbeetle/pull/2294) + + Rewrite `commit_dispatch`, a chain of asynchronous stages calling each other, as a state machine + implementation that resembles linear control flow that is much easier to read. + +- [#2306](https://github.com/tigerbeetle/tigerbeetle/pull/2306) + + Fix the Node.js example that was using an incorrect enum flag. + Thanks for the heads up @jorispz! + +- [#2319](https://github.com/tigerbeetle/tigerbeetle/pull/2319) + + Update outdated scripts in `HACKING.md`. + +- [#2317](https://github.com/tigerbeetle/tigerbeetle/pull/2317) + + Use git timestamps to build Docker images. + This is a requirement for being deterministic in CI. + +- [#2318](https://github.com/tigerbeetle/tigerbeetle/pull/2318) + + Devhub link to pending code reviews. + +### TigerTracks 🎧 + +- [What's On Your Mind](https://www.youtube.com/watch?v=Z5WRKnCRPHA) + +## TigerBeetle 0.16.1 + +Released: 2024-09-09 + +### Safety And Performance + +- [#2284](https://github.com/tigerbeetle/tigerbeetle/pull/2284) + + Improve view change efficiency; new heuristic for lagging replicas to forfeit view change. + + A lagging replicas first gives a more up-to-date replica a chance to become primary by forfeiting + view change. If the more up-to-date replica cannot step up as primary, the lagging replica + attempts to step up as primary. + +- [#2275](https://github.com/tigerbeetle/tigerbeetle/pull/2275), + [#2254](https://github.com/tigerbeetle/tigerbeetle/pull/2254), + [#2269](https://github.com/tigerbeetle/tigerbeetle/pull/2269), + [#2272](https://github.com/tigerbeetle/tigerbeetle/pull/2272) + + Complete rollout of the new state sync protocol. + + Remove in-code remnants of the old state sync protocol. Replicas now panic if they receive + messages belonging to the old protocol. + + +### Internals + +- [#2283](https://github.com/tigerbeetle/tigerbeetle/pull/2283) + + Improve log warnings for client eviction due to its version being too low/high. + +- [#2288](https://github.com/tigerbeetle/tigerbeetle/pull/2288) + + Fix VOPR false positive wherein checkpoint was being updated twice in the upgrade path. + +- [#2286](https://github.com/tigerbeetle/tigerbeetle/pull/2286) + + Fix typos found using [codespell](https://github.com/codespell-project/codespell). + +- [#2282](https://github.com/tigerbeetle/tigerbeetle/pull/2282) + + Document example for debiting multiple accounts and crediting a single account wherein the total + amount to transfer to the credit account is known, but the balances of the individual debit + accounts are not known. + +- [#2281](https://github.com/tigerbeetle/tigerbeetle/pull/2281) + + Document the behavior of `user_data_128/user_data_64/user_data_32` in the presence of pending + transfers. + +- [#2279](https://github.com/tigerbeetle/tigerbeetle/pull/2279) + + Inline Dockerfile in the release code, removing tools/docker/Dockerfile. + +- [#2280](https://github.com/tigerbeetle/tigerbeetle/pull/2280), + [#2285](https://github.com/tigerbeetle/tigerbeetle/pull/2285) + + Add [`kcov`](https://simonkagstrom.github.io/kcov/) code coverage to + [DevHub](https://devhub.tigerbeetle.com/). + +- [#2266](https://github.com/tigerbeetle/tigerbeetle/pull/2266), + [#2293](https://github.com/tigerbeetle/tigerbeetle/pull/2293) + + Add support for tracing IO & CPU events. This allows for coarse-grained performance analysis, + for example collectively profiling IO and CPU performance (as opposed to IO or CPU in isolation). + +- [#2273](https://github.com/tigerbeetle/tigerbeetle/pull/2273) + + Remove explicit header sector locks, using a common locking path for prepare and header sectors. + +- [#2258](https://github.com/tigerbeetle/tigerbeetle/pull/2258) + + Change CliArgs -> CLIArgs in accordance with TigerStyle. + +- [#2263](https://github.com/tigerbeetle/tigerbeetle/pull/2263) + + Vendor `llvm-objcopy` in the [dependencies](https://github.com/tigerbeetle/dependencies) + repository in accordance with our "no dependencies" policy. This ensures users don't have to + manually install LLVM. + +- [#2297](https://github.com/tigerbeetle/tigerbeetle/pull/2297) + + Assign correct date to the release binary date; it was earlier set to the epoch ("Jan 1 1970"). + +- [#2289](https://github.com/tigerbeetle/tigerbeetle/pull/2289) + + Introduce fatal errors for crashing the replica process in the face of uncorrectable errors (for + example, insufficient memory/storage). + +- [#2287](https://github.com/tigerbeetle/tigerbeetle/pull/2287) + + Add formatting check in the CI for the Go client. + +- [#2278](https://github.com/tigerbeetle/tigerbeetle/pull/2278), + [#2292](https://github.com/tigerbeetle/tigerbeetle/pull/2292) + + Reduce dimensionality of configuration modes. + + Removes the development configuration which was used to run the replica with asserts enabled, + enabling asserts for the production configuration instead. Additionally, removes the -Dconfig CLI + option, making production configuration the default. + + +### TigerTracks 🎧 + +- [Fire on the Mountain](https://open.spotify.com/track/4DpBfWl3q8e0gGB76lAaox) + +## TigerBeetle 0.16.0 + +Released: 2024-09-02 + +This release is 0.16.0 as it includes a new breaking API change around zero amount transfers, as +well as the behavior around posting a full pending transfer amount or balancing as much as possible. +These are all gated by the client's release version. + +If you're running a client older than 0.16.0, you'll see the old behavior where zero amount +transfers are disallowed, but on newer clients these are supported and will create a transfer with +an amount of 0. + +Additionally, the sentinel value to representing posting the full amount of a pending transfer, or +doing a balancing transfer for as much as possible has changed. It's no longer 0, but instead +`AMOUNT_MAX`. + +See the [**tracking issue**](https://github.com/tigerbeetle/tigerbeetle/issues/2231#issuecomment-2305132591) for more details. + +### Safety And Performance + +- [#2221](https://github.com/tigerbeetle/tigerbeetle/pull/2221) + + Change how replicas that haven't finished syncing send a `prepare_ok` message, + preventing them from falsely contributing to the durability of a checkpoint, which could + potentially cause liveness issues in the event of storage faults. + +- [#2255](https://github.com/tigerbeetle/tigerbeetle/pull/2255) + + The new state sync protocol regressed the behavior where the replica would try to repair the WAL + before switching to state sync, and this puts the old behavior back in. + + [WAL repair](https://docs.tigerbeetle.com/about/internals/vsr#protocol-repair-wal) is used when + the lagging replica's log still intersects with the cluster's current log, while + [state sync](https://docs.tigerbeetle.com/about/internals/sync) is used when the logs no + longer intersect. + +- [#2244](https://github.com/tigerbeetle/tigerbeetle/pull/2244) + + Try to repair (but not commit) prepares, even if we don't have all the headers between checkpoint + and head. + + This makes things consistent between the normal and repair paths, and improves concurrency while + repairing. + +- [#2253](https://github.com/tigerbeetle/tigerbeetle/pull/2253) + + Reject prepares on the primary if its view isn't durable, much like solo clusters. + + This solves a failing VOPR seed wherein a primary accepting prepares before making its log_view + durable exposes a break in its hash chain. + +- [#2259](https://github.com/tigerbeetle/tigerbeetle/pull/2259), + [#2246](https://github.com/tigerbeetle/tigerbeetle/pull/2246) + + A few `sysctl`s and security frameworks (e.g., seccomp) might block io_uring. Print out a more + helpful error message, rather than a generic "permission denied" or "system outdated". + + +### Features + +- [#2171](https://github.com/tigerbeetle/tigerbeetle/pull/2171) + + Add the new `imported` flag to allow user-defined timestamps when creating + `Account`s and `Transfer`s from historical events. + +- [#2220](https://github.com/tigerbeetle/tigerbeetle/pull/2220), + [#2237](https://github.com/tigerbeetle/tigerbeetle/pull/2237), + [#2238](https://github.com/tigerbeetle/tigerbeetle/pull/2238), + [#2239](https://github.com/tigerbeetle/tigerbeetle/pull/2239) + + Allow `Transfer`s with `amount=0` and change behavior for _balancing_ and _post-pending_ + transfers, introducing the constant `AMOUNT_MAX` to replace the use of the zero sentinel when + representing the maximum/original value in such cases. Note that this is a + [**breaking change**](https://github.com/tigerbeetle/tigerbeetle/issues/2231#issuecomment-2305132591). + + Also, explicitly define _optional indexes_, which previously were determined simply by not + indexing zeroed values. + +- [#2234](https://github.com/tigerbeetle/tigerbeetle/pull/2234) + + Introduce a new flag, `Account.flags.closed`, which causes an account to reject any further + transfers, except for voiding two-phase transfers that are still pending. + + The account flag can be set during creation or through a closing transfer. In the latter case, + closed account can be re-opened by voiding or expiring the closing transfer. + + +### Internals + +- [#2211](https://github.com/tigerbeetle/tigerbeetle/pull/2211) + + Deprecates the old state sync protocol, no longer supporting both protocols simultaneously. + As planned for this release, it only ignores old messages, allowing replicas to upgrade normally. + In the next release, replicas would panic if they receive an old message. + +- [#2233](https://github.com/tigerbeetle/tigerbeetle/pull/2233) + + Move multiversion build logic into `build.zig` from `release.zig`. This makes it much easier to + build multiversion binaries as part of a regular `zig build`, without having to invoke CI or + release process specific code that's normally part of `release.zig`. + + It also makes it possible to build multiversion binaries on platforms that aren't x86_64 Linux. + +- [#2215](https://github.com/tigerbeetle/tigerbeetle/pull/2215) + + Refactor the _Multiversion_ API, bringing it in line with pre-existing code patterns. + +- [#2251](https://github.com/tigerbeetle/tigerbeetle/pull/2251) + + Previously, TigerBeetle release numbers were based on a finicky conversion of GitHub's internal + action run number to a version number. + + This was error prone, and difficult to reason about before hand (what would the given version + number for a release be?). Instead, make it so this very changelog is the source of truth for + the version number which is explicitly set. + +- [#2252](https://github.com/tigerbeetle/tigerbeetle/pull/2252) + + Change `init` function signatures to allow for in-place initialization. This addresses the silent + stack growth caused by intermediate copy/move allocations during the initialization of large + objects. + + Specifically, the `Forest` struct can grow indefinitely depending on the number of + `Grooves`/`IndexTrees` needed to support the StateMachine's custom logic, causing TigerBeetle to + crash during startup due to stack-overflow. + +- [#2265](https://github.com/tigerbeetle/tigerbeetle/pull/2265) + + Don't cancel in-progress GitHub actions on the main branch. In particular, this ensures that the + devhub records the benchmark measurements for every merge to main, even if those merges occur in + quick succession. + +- [#2218](https://github.com/tigerbeetle/tigerbeetle/pull/2218) + + Make the experimental feature `aof` (append-only file) a runtime flag instead of a build-time + setting. This simplifies operations, allowing the use of the same standard release binary in + environments that require `aof`. + +- [#2228](https://github.com/tigerbeetle/tigerbeetle/pull/2228) + + Renames the LSM constant `lsm_batch_multiple` to `lsm_compaction_ops`, providing clearer meaning + on how it relates to the pace at which LSM tree compaction is triggered. + +- [#2240](https://github.com/tigerbeetle/tigerbeetle/pull/2240) + + Add support for indexing flags, namely the new `imported` flag. + +### TigerTracks 🎧 + +- [I Want To Break Free](https://open.spotify.com/track/7iAqvWLgZzXvH38lA06QZg) +- [Used To Love Her](https://www.youtube.com/watch?v=FDIvIb06abI) + +## TigerBeetle 0.15.6 + +Released: 2024-08-19 + +### Safety And Performance + +- [#1951](https://github.com/tigerbeetle/tigerbeetle/pull/1951), + [#2212](https://github.com/tigerbeetle/tigerbeetle/pull/2212) + + Add new state sync protocol, fixing a couple of liveness issues. + State sync is now performed as part of the view change. + +- [#2207](https://github.com/tigerbeetle/tigerbeetle/pull/2207) + + Major state sync performance improvements. + +### Features + +- [#2224](https://github.com/tigerbeetle/tigerbeetle/pull/2224), + [#2225](https://github.com/tigerbeetle/tigerbeetle/pull/2225), + [#2226](https://github.com/tigerbeetle/tigerbeetle/pull/2226) + + Ensure `u128` (and related type) consistency across client implementations. + +- [#2213](https://github.com/tigerbeetle/tigerbeetle/pull/2213) + + Fix multiversioning builds for aarch64 macOS. + +### Internals + +- [#2210](https://github.com/tigerbeetle/tigerbeetle/pull/2210) + + Automatically include oldest supported releases in release notes. + +- [#2214](https://github.com/tigerbeetle/tigerbeetle/pull/2214) + + Refactor `build.zig` to break up the biggest function in the codebase. + +- [#2178](https://github.com/tigerbeetle/tigerbeetle/pull/2178) + + Minor improvements to zig install scripts. + +### TigerTracks 🎧 + +- [End of the Line](https://www.youtube.com/watch?v=UMVjToYOjbM) + +## TigerBeetle 0.15.5 + +Released: 2024-08-12 + +Highlight of this release is fully rolled-out support for multiversion binaries. This means that, +from now on, the upgrade procedure is going to be as simple as dropping the new version of +`tigerbeetle` binary onto the servers. TigerBeetle will take care of restarting the cluster at the +new version when it is appropriate. See for +reference documentation. + +Note that the upgrade procedure from `0.15.3` and `0.15.4` is a bit more involved. + +- When upgrading from `0.15.3`, you'll need to stop and restart `tigerbeetle` binary manually. +- When upgrading from `0.15.4`, the binary will stop automatically by hitting an `assert`. You + should restart it after that. + +### Safety And Performance + +- [#2174](https://github.com/tigerbeetle/tigerbeetle/pull/2174) + [#2190](https://github.com/tigerbeetle/tigerbeetle/pull/2190), + + Test client eviction in the VOPR. + +- [#2187](https://github.com/tigerbeetle/tigerbeetle/pull/2187) + + Add integration tests for upgrades. + +- [#2188](https://github.com/tigerbeetle/tigerbeetle/pull/2188) + + Add more hardening parameters to the suggested systemd unit definition. + +### Features + +- [#2180](https://github.com/tigerbeetle/tigerbeetle/pull/2180), + [#2185](https://github.com/tigerbeetle/tigerbeetle/pull/2185), + [#2189](https://github.com/tigerbeetle/tigerbeetle/pull/2189), + [#2196](https://github.com/tigerbeetle/tigerbeetle/pull/2196) + + Make the root directory smaller by getting rid of `scripts` and `.gitattributes` entries. + Root directory is the first thing you see when opening the repository, this space shouldn't be + wasted! + +- [#2199](https://github.com/tigerbeetle/tigerbeetle/pull/2199), + [#2165](https://github.com/tigerbeetle/tigerbeetle/pull/2165), + [#2198](https://github.com/tigerbeetle/tigerbeetle/pull/2198), + [#2184](https://github.com/tigerbeetle/tigerbeetle/pull/2184). + + Complete the integration of multiversion binaries with the release infrastructure. From now on, + the upgrade procedure is as simple as replacing the binary on disk with a new version. TigerBeetle + will take care of safely and seamlessly restarting the cluster when appropriate itself. + +- [#2181](https://github.com/tigerbeetle/tigerbeetle/pull/2181) + + Prepare to rollout the new state sync protocol. Stay tuned + for the next release! + +### Internals + +- [#2179](https://github.com/tigerbeetle/tigerbeetle/pull/2179), + [#2200](https://github.com/tigerbeetle/tigerbeetle/pull/2200) + + Simplify iteration over an LSM tree during scans. + +- [#2182](https://github.com/tigerbeetle/tigerbeetle/pull/2182) + + Fix addresses logging in the client regressed by + [#2164](https://github.com/tigerbeetle/tigerbeetle/pull/2164). + +- [#2193](https://github.com/tigerbeetle/tigerbeetle/pull/2193) + + Modernize scripts to generate client bindings to follow modern idioms for `build.zig`. + +- [#2195](https://github.com/tigerbeetle/tigerbeetle/pull/2195) + + Fix typo in the currency exchange example. + + +### TigerTracks 🎧 + +- [High Hopes](https://open.spotify.com/track/236mI0lz8JdQjlmijARSwY) + +## 2024-08-05 (No release: Queued up to improve multiversion upgrade flow) + +### Safety And Performance + +- [#2162](https://github.com/tigerbeetle/tigerbeetle/pull/2162) + + Past release checksums are further validated when printing multi-version information. + +- [#2143](https://github.com/tigerbeetle/tigerbeetle/pull/2143) + + Write Ahead Log (WAL) appending was decoupled from WAL replication, tightening asserts. + +- [#2153](https://github.com/tigerbeetle/tigerbeetle/pull/2153), + [#2170](https://github.com/tigerbeetle/tigerbeetle/pull/2170) + + VSR eviction edge cases receive more hardening. + +- [#2175](https://github.com/tigerbeetle/tigerbeetle/pull/2175) + + Fix account overflows when doing a balance transfer for remaining funds (`amount=0`). + +- [#2168](https://github.com/tigerbeetle/tigerbeetle/pull/2168), + [#2164](https://github.com/tigerbeetle/tigerbeetle/pull/2164), + [#2152](https://github.com/tigerbeetle/tigerbeetle/pull/2152), + [#2122](https://github.com/tigerbeetle/tigerbeetle/pull/2122) + + Command line argument parsing no longer dynamically allocates and handles error handling paths + more explicitly. + +### Internals + +- [#2169](https://github.com/tigerbeetle/tigerbeetle/pull/2169) + + Golang's tests for the CI were re-enabled for ARM64 macOS. + +- [#2159](https://github.com/tigerbeetle/tigerbeetle/pull/2159) + + This is a CHANGELOG entry about fixing a previous CHANGELOG entry. + +### TigerTracks 🎧 + +- [Ramble On](https://www.youtube.com/watch?v=EYeG3QrvkEE) + +## 2024-07-29 + +### Safety And Performance + +- [#2140](https://github.com/tigerbeetle/tigerbeetle/pull/2140), + [#2154](https://github.com/tigerbeetle/tigerbeetle/pull/2154) + + Fix a bug where MessageBus sees block/reply messages (due to state sync or repair) and peer_type + says they are always from replica 0 (since Header.Block.replica == 0 always). So, if they are + being sent by a non-R0 replica, it drops the messages with "message from unexpected peer". + + This leads to a replica being stuck in state sync and unable to progress. + +- [#2137](https://github.com/tigerbeetle/tigerbeetle/pull/2137) + + It was possible for a prepare to exist in a mixture of WALs and checkpoints, which could + compromise physical durability under storage fault conditions, since the data is present across a + commit-quorum of replicas in different forms. + + Rather, ensure a prepare in the WAL is only overwritten if it belongs to a commit-quorum of + checkpoints. + +- [#2127](https://github.com/tigerbeetle/tigerbeetle/pull/2127), + [#2096](https://github.com/tigerbeetle/tigerbeetle/pull/2096) + + A few CI changes: run tests in CI for x86_64 macOS, add in client tests on macOS and run the + benchmark with `--validate` in CI. + +- [#2117](https://github.com/tigerbeetle/tigerbeetle/pull/2117) + + TigerBeetle reserves the most significant bit of the timestamp as the tombstone flag, so indicate + and assert that timestamp_max is a `maxInt(u63)`. + +- [#2123](https://github.com/tigerbeetle/tigerbeetle/pull/2123), + [#2125](https://github.com/tigerbeetle/tigerbeetle/pull/2125) + + Internally, TigerBeetle uses AEGIS-128L for checksumming - hardware AES is a prerequisite for + performance. Due to a build system bug, releases being built with a specified (`-Dtarget=`) target + would only be built with baseline CPU features, and thus use the software AES implementation. + + Enforce at comptime that hardware acceleration is available, fix the build system bug, log + checksum performance on our [devhub](https://devhub.tigerbeetle.com/) and build client + libraries with hardware acceleration too. + +- [#2139](https://github.com/tigerbeetle/tigerbeetle/pull/2139) + + TigerBeetle would wait until all repairable headers are fixed before trying to commits prepares, + but if all the headers after the checkpoint are present then we can start committing even if + some headers from before the checkpoint are missing. + +- [#2141](https://github.com/tigerbeetle/tigerbeetle/pull/2141) + + Clarify that the order of replicas in `--addresses` is important. Currently, the order of replicas + as specified has a direct impact on how messages are routed between them. Having a differing order + leads to significantly degraded performance. + +- [#2120](https://github.com/tigerbeetle/tigerbeetle/pull/2120) + + The state machine depended on `prepare_timestamp` to evaluate `pulse()`, but in an idle cluster, + `prepare_timestamp` would only be set if pulse returned true! Thanks @ikolomiets for reporting. + +- [#2028](https://github.com/tigerbeetle/tigerbeetle/pull/2028) + + Add a fuzzer for scans. + +- [#2109](https://github.com/tigerbeetle/tigerbeetle/pull/2109) + + Fuzz `storage.zig`, by using a mocked IO layer. + +### Features + +- [#2070](https://github.com/tigerbeetle/tigerbeetle/pull/2070) + + Certain workloads (for example, sending in tiny batches) can cause high amounts of space + amplification in TigerBeetle, leading to data file sizes that are much larger than optimal. + + This introduces a stopgap fix, greedily coalescing tables in level 0 of the LSM, which improves + space amplification dramatically. + +- [#2003](https://github.com/tigerbeetle/tigerbeetle/pull/2003) + + Add a data file inspector tool to the TigerBeetle CLI, handy for development and debugging alike. + You can run it with `tigerbeetle inspect --help`. + +- [#2136](https://github.com/tigerbeetle/tigerbeetle/pull/2136), + [#2013](https://github.com/tigerbeetle/tigerbeetle/pull/2013), + [#2126](https://github.com/tigerbeetle/tigerbeetle/pull/2126) + + TigerBeetle clusters can now be [upgraded](https://docs.tigerbeetle.com/operating/upgrading)! + +- [#2095](https://github.com/tigerbeetle/tigerbeetle/pull/2095) + + Add a custom formatter for displaying units in error messages. Thanks @tensorush! + +### Internals + +- [#1380](https://github.com/tigerbeetle/tigerbeetle/pull/1380) + + Allows for language clients to manage their own `Packet` memory, removing the need for tb_client + to do so and thus removing the concepts of acquire/release_packet and concurrency_max. + +- [#2148](https://github.com/tigerbeetle/tigerbeetle/pull/2148) + + Add function length limits to our internal tidy tests. + +- [#2116](https://github.com/tigerbeetle/tigerbeetle/pull/2116), + [#2114](https://github.com/tigerbeetle/tigerbeetle/pull/2114), + [#2111](https://github.com/tigerbeetle/tigerbeetle/pull/2111), + [#2132](https://github.com/tigerbeetle/tigerbeetle/pull/2132), + [#2131](https://github.com/tigerbeetle/tigerbeetle/pull/2131), + [#2124](https://github.com/tigerbeetle/tigerbeetle/pull/2124) + + Lots of small [CFO](https://devhub.tigerbeetle.com/) improvements. + +### TigerTracks 🎧 + +- [Here I Go Again](https://www.youtube.com/watch?v=WyF8RHM1OCg) + +## 2024-07-15 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#2078](https://github.com/tigerbeetle/tigerbeetle/pull/2078) + + Fix an incorrect `assert` that was too tight, crashing the replica after state sync, + when the replica's operation number lags behind checkpoint. + +- [#2103](https://github.com/tigerbeetle/tigerbeetle/pull/2103), + [#2056](https://github.com/tigerbeetle/tigerbeetle/pull/2056), + [#2072](https://github.com/tigerbeetle/tigerbeetle/pull/2072) + + Fixes and improvements to tests and simulator. + +- [#2088](https://github.com/tigerbeetle/tigerbeetle/pull/2088) + + Improve the benchmark to verify the state after execution and enable tests in Windows CI! + +- [#2090](https://github.com/tigerbeetle/tigerbeetle/pull/2090) + + Call `fs_sync` on macOS/Darwin after each write to properly deal with Darwin's `O_DSYNC` which + [doesn't behave like `O_DSYNC` on Linux](https://x.com/TigerBeetleDB/status/1536628729031581697). + +### Features + +- [#2080](https://github.com/tigerbeetle/tigerbeetle/pull/2080) + + New operations `query accounts` and `query transfers` as a stopgap API to add some degree of + user-defined query capabilities. + This is an experimental feature meant to be replaced by a proper querying API. + + +### Internals + +- [#2067](https://github.com/tigerbeetle/tigerbeetle/pull/2067) + + Simplify the comptime configuration by merging `config.test_min` and `config.fuzz_min`. + +- [#2091](https://github.com/tigerbeetle/tigerbeetle/pull/2091) + + Fixed many typos and misspellings, thanks to [Jora Troosh](https://github.com/tensorush). + +- [#2099](https://github.com/tigerbeetle/tigerbeetle/pull/2099), + [#2097](https://github.com/tigerbeetle/tigerbeetle/pull/2097), + [#2098](https://github.com/tigerbeetle/tigerbeetle/pull/2098), + [#2100](https://github.com/tigerbeetle/tigerbeetle/pull/2100), + [#2092](https://github.com/tigerbeetle/tigerbeetle/pull/2092), + [#2094](https://github.com/tigerbeetle/tigerbeetle/pull/2094), + [#2089](https://github.com/tigerbeetle/tigerbeetle/pull/2089), + [#2073](https://github.com/tigerbeetle/tigerbeetle/pull/2073), + [#2087](https://github.com/tigerbeetle/tigerbeetle/pull/2087), + [#2086](https://github.com/tigerbeetle/tigerbeetle/pull/2086), + [#2083](https://github.com/tigerbeetle/tigerbeetle/pull/2083), + [#2085](https://github.com/tigerbeetle/tigerbeetle/pull/2085) + + Multiple and varied changes to conform **all** line lengths to not more than 100 columns, + according to + [TigerStyle](https://github.com/tigerbeetle/tigerbeetle/blob/main/docs/TIGER_STYLE.md#style-by-the-numbers)! + +- [#2081](https://github.com/tigerbeetle/tigerbeetle/pull/2081) + + Run `kcov` during CI as a code coverage sanity check. No automated action is taken regarding the + results. We're not focused on tracking the quantitative coverage metric, but rather on surfacing + blind spots qualitatively. + +### TigerTracks 🎧 + +- [Sultans Of Swing](https://www.youtube.com/watch?v=h0ffIJ7ZO4U) + +## 2024-07-08 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#2035](https://github.com/tigerbeetle/tigerbeetle/pull/2035), + [#2042](https://github.com/tigerbeetle/tigerbeetle/pull/2042), + [#2069](https://github.com/tigerbeetle/tigerbeetle/pull/2069) + + Strengthen LSM assertions. + +- [#2077](https://github.com/tigerbeetle/tigerbeetle/pull/2077) + + Use flexible quorums for clock synchronization. + +### Features + +- [#2037](https://github.com/tigerbeetle/tigerbeetle/pull/2037) + + Improve and clarify balancing transfer `amount` validation. + +### Internals + +- [#2063](https://github.com/tigerbeetle/tigerbeetle/pull/2063) + + Add chaitanyabhandari to the list of release managers. + +- [#2075](https://github.com/tigerbeetle/tigerbeetle/pull/2075) + + Update TigerStyle with advice for splitting long functions. + +- [#2068](https://github.com/tigerbeetle/tigerbeetle/pull/2068), + [#2074](https://github.com/tigerbeetle/tigerbeetle/pull/2074) + + Fix flaky tests. + +- [#1995](https://github.com/tigerbeetle/tigerbeetle/pull/1995) + + Add `--security-opt seccomp=unconfined` to Docker commands in docs, since newer versions of Docker + block access to io_uring. + +- [#2047](https://github.com/tigerbeetle/tigerbeetle/pull/2047), + [#2064](https://github.com/tigerbeetle/tigerbeetle/pull/2064), + [#2079](https://github.com/tigerbeetle/tigerbeetle/pull/2079) + + Clean up github actions workflows. + +- [#2071](https://github.com/tigerbeetle/tigerbeetle/pull/2071) + + Make cfo supervisor robust to network errors. + +### TigerTracks 🎧 + +- [Линия жизни](https://open.spotify.com/track/2dpGc40PtSLEeNAGrTnJGI) + +## 2024-07-01 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#2058](https://github.com/tigerbeetle/tigerbeetle/pull/2058) + + `tigerbeetle benchmark` command can now simulate few "hot" accounts which account for most of + transfers, the distribution expected in a typical deployment. + +### Features + +- [#2040](https://github.com/tigerbeetle/tigerbeetle/pull/2040) + + Add a recipe for accounts with bounded balance + +### Internals + +- [#2033](https://github.com/tigerbeetle/tigerbeetle/pull/2033), + [#2041](https://github.com/tigerbeetle/tigerbeetle/pull/2041) + + Rewrite `build.zig` to introduce a more regular naming scheme for top-level steps. + +- [#2057](https://github.com/tigerbeetle/tigerbeetle/pull/2057) + + Our internal dashboard, [devhub](https://devhub.tigerbeetle.com/) now has dark mode 😎. + +- [#2052](https://github.com/tigerbeetle/tigerbeetle/pull/2052), + [#2032](https://github.com/tigerbeetle/tigerbeetle/pull/2032), + [#2044](https://github.com/tigerbeetle/tigerbeetle/pull/2044) + + Ensure that the generated `tb_client.h` C header is in sync with Zig code. + + +### TigerTracks 🎧 + +- [Wish You Were Here](https://open.spotify.com/track/7aE5WXu5sFeNRh3Z05wwu4) + +## 2024-06-24 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#2034](https://github.com/tigerbeetle/tigerbeetle/pull/2034), + [#2022](https://github.com/tigerbeetle/tigerbeetle/pull/2022), + [#2023](https://github.com/tigerbeetle/tigerbeetle/pull/2023) + + Fuzzer Fixing For Fun! Particularly around random number generation and number sequences. + +- [#2004](https://github.com/tigerbeetle/tigerbeetle/pull/2004) + + Add simulator coverage for `get_account_transfers` and `get_account_balances`. + +### Features + +- [#2010](https://github.com/tigerbeetle/tigerbeetle/pull/2010) + + Reduce the default `--limit-pipeline-requests` value, dropping RSS memory consumption. + +### Internals + +- [#2024](https://github.com/tigerbeetle/tigerbeetle/pull/2024), + [#2018](https://github.com/tigerbeetle/tigerbeetle/pull/2018), + [#2027](https://github.com/tigerbeetle/tigerbeetle/pull/2027) + + Build system simplifications. + +- [#2026](https://github.com/tigerbeetle/tigerbeetle/pull/2026), + [#2020](https://github.com/tigerbeetle/tigerbeetle/pull/2020), + [#2030](https://github.com/tigerbeetle/tigerbeetle/pull/2030), + [#2031](https://github.com/tigerbeetle/tigerbeetle/pull/2031), + [#2008](https://github.com/tigerbeetle/tigerbeetle/pull/2008) + + Tidying up (now) unused symbols and functionality. + +- [#2016](https://github.com/tigerbeetle/tigerbeetle/pull/2016) + + Rename docs section from "Develop" to "Coding". + +### TigerTracks 🎧 + +- [On The Riverbank](https://open.spotify.com/track/0zfluauTutYrU13nEV2zyc) + +## 2024-06-17 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#2000](https://github.com/tigerbeetle/tigerbeetle/pull/2000) + + Fix a case where an early return could result in a partially inserted transfer persisting. + +- [#2011](https://github.com/tigerbeetle/tigerbeetle/pull/2011), + [#2009](https://github.com/tigerbeetle/tigerbeetle/pull/2009), + [#1981](https://github.com/tigerbeetle/tigerbeetle/pull/1981) + + Big improvements to allowing TigerBeetle to run with less memory! You can now run TigerBeetle + in `--development` mode by default with an RSS of under 1GB. Most of these gains came from #1981 + which allows running with a smaller runtime request size. + +- [#2014](https://github.com/tigerbeetle/tigerbeetle/pull/2014), + [#2012](https://github.com/tigerbeetle/tigerbeetle/pull/2012), + [#2006](https://github.com/tigerbeetle/tigerbeetle/pull/2006) + + Devhub improvements - make it harder to miss failures due to visualization bugs, show the PR + author in fuzzer table and color canary "failures" as success. + +### Features + +- [#2001](https://github.com/tigerbeetle/tigerbeetle/pull/2001) + + Add `--account-batch-size` to the benchmark, mirroring `--transfer-batch-size`. + +- [#2017](https://github.com/tigerbeetle/tigerbeetle/pull/2017), + [#1992](https://github.com/tigerbeetle/tigerbeetle/pull/1992), + [#1993](https://github.com/tigerbeetle/tigerbeetle/pull/1993) + + Rename the Deploy section to Operating, add a new correcting transfer recipe, and note that + `lookup_accounts` shouldn't be used before creating transfers to avoid potential TOCTOUs. + +### Internals + +- [#1878](https://github.com/tigerbeetle/tigerbeetle/pull/1878), + [#1997](https://github.com/tigerbeetle/tigerbeetle/pull/1997) + + ⚡ Update Zig from 0.11.0 to 0.13.0! As part of this, replace non-mutated `var`s with `const`. + +- [#1999](https://github.com/tigerbeetle/tigerbeetle/pull/1999) + + Similar to #1991, adds the async `io_uring_prep_statx` syscall for Linux's IO implementation, + allowing non-blocking `statx()`s while serving requests - to determine when the binary on + disk has changed. + + +### TigerTracks 🎧 + +- [Canon in D](https://www.youtube.com/watch?v=Ptk_1Dc2iPY) + +## 2024-06-10 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#1986](https://github.com/tigerbeetle/tigerbeetle/pull/1986) + + Refactor an internal iterator to expose a mutable pointer instead of calling `@constCast` on it. + There was a comment justifying the operation's safety, but it turned out to be safer to expose + it as a mutable pointer (avoiding misusage from the origin) rather than performing an unsound + mutation over a constant pointer. + +- [#1985](https://github.com/tigerbeetle/tigerbeetle/pull/1985) + + Implement a random Grid/Scrubber tour origin, where each replica starts scrubbing the local + storage in a different place, covering more blocks across the entire cluster. + +- [#1990](https://github.com/tigerbeetle/tigerbeetle/pull/1990) + + Model and calculate the probability of data loss in terms of the Grid/Scrubber cycle interval, + allowing to reduce the read bandwidth dedicated for scrubbing. + +- [#1987](https://github.com/tigerbeetle/tigerbeetle/pull/1987) + + Fix a simulator bug where all the WAL sectors get corrupted when a replica crashes while writing + them simultaneously. + +### Internals + +- [#1991](https://github.com/tigerbeetle/tigerbeetle/pull/1991) + + As part of multiversioning binaries, adds the async `io_uring_prep_openat`syscall for Linux's IO + implementation, allowing non-blocking `open()`s while serving requests (which will be necessary + during upgrade checks). + +- [#1982](https://github.com/tigerbeetle/tigerbeetle/pull/1982) + + Require the `--experimental` flag when starting TigerBeetle with flags that aren't considered + stable, that is, flags not explicitly documented in the help message, limiting the surface area + for future compatibility. + +### TigerTracks 🎧 + +- [O Rappa - A feira](https://www.youtube.com/watch?v=GmaFGnUnM1U) + +## 2024-06-03 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#1980](https://github.com/tigerbeetle/tigerbeetle/pull/1980) + + Fix crash when upgrading solo replica. + +- [#1952](https://github.com/tigerbeetle/tigerbeetle/pull/1952) + + Pin points crossing Go client FFI boundary to prevent memory corruption. + +### Internals + +- [#1931](https://github.com/tigerbeetle/tigerbeetle/pull/1931), + [#1933](https://github.com/tigerbeetle/tigerbeetle/pull/1933) + + Improve Go client tests. + +- [#1946](https://github.com/tigerbeetle/tigerbeetle/pull/1946) + + Add `vsr.Client.register()`. + +## 2024-05-27 (No release: Queued up for upcoming multi-version binary release) + +### Features + +- [#1975](https://github.com/tigerbeetle/tigerbeetle/pull/1975) + + Build our .NET client for .NET 8, the current LTS version. Thanks @woksin! + +### Internals + +- [#1971](https://github.com/tigerbeetle/tigerbeetle/pull/1971) + + Document recovery case `@L` in VSR. + +- [#1965](https://github.com/tigerbeetle/tigerbeetle/pull/1965) + + We implicitly supported underscores in numerical CLI flags. Add tests to make this explicit. + +- [#1974](https://github.com/tigerbeetle/tigerbeetle/pull/1974), + [#1970](https://github.com/tigerbeetle/tigerbeetle/pull/1970) + + Add the size of an empty data file to [devhub](https://devhub.tigerbeetle.com/), + tweak the benchmark to always generate the same sized batches, and speed up loading the + devhub itself. + +### TigerTracks 🎧 + +- [Fight Song](https://www.youtube.com/watch?v=xo1VInw-SKc) + +## 2024-05-20 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#1938](https://github.com/tigerbeetle/tigerbeetle/pull/1938) + + Ease restriction which guarded against unnecessary pulses. + +### Internals + +- [#1949](https://github.com/tigerbeetle/tigerbeetle/pull/1949), + [#1964](https://github.com/tigerbeetle/tigerbeetle/pull/1964) + + Docs fixes and cleanup. + +- [#1957](https://github.com/tigerbeetle/tigerbeetle/pull/1957) + + Fix determinism bug in test workload checker. + +- [#1955](https://github.com/tigerbeetle/tigerbeetle/pull/1955) + + Expose `ticks_max` as runtime CLI argument. + +- [#1956](https://github.com/tigerbeetle/tigerbeetle/pull/1956), + [#1959](https://github.com/tigerbeetle/tigerbeetle/pull/1959), + [#1960](https://github.com/tigerbeetle/tigerbeetle/pull/1960), + [#1963](https://github.com/tigerbeetle/tigerbeetle/pull/1963) + + Devhub/benchmark improvements. + +## 2024-05-13 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance +- [#1918](https://github.com/tigerbeetle/tigerbeetle/pull/1918), + [#1916](https://github.com/tigerbeetle/tigerbeetle/pull/1916), + [#1913](https://github.com/tigerbeetle/tigerbeetle/pull/1913), + [#1921](https://github.com/tigerbeetle/tigerbeetle/pull/1921), + [#1922](https://github.com/tigerbeetle/tigerbeetle/pull/1922), + [#1920](https://github.com/tigerbeetle/tigerbeetle/pull/1920), + [#1945](https://github.com/tigerbeetle/tigerbeetle/pull/1945), + [#1941](https://github.com/tigerbeetle/tigerbeetle/pull/1941), + [#1934](https://github.com/tigerbeetle/tigerbeetle/pull/1934), + [#1927](https://github.com/tigerbeetle/tigerbeetle/pull/1927) + + Lots of CFO enhancements - the CFO can now do simple minimization, fuzz PRs and orchestrate the + VOPR directly. See the output on our [devhub](https://devhub.tigerbeetle.com/)! + +- [#1948](https://github.com/tigerbeetle/tigerbeetle/pull/1948), + [#1929](https://github.com/tigerbeetle/tigerbeetle/pull/1929), + [#1924](https://github.com/tigerbeetle/tigerbeetle/pull/1924) + + Fix a bug in the VOPR, add simple minimization, and remove the voprhub code. Previously, the + voprhub is what took care of running the VOPR. Now, it's handled by the CFO and treated much + the same as other fuzzers. + +- [#1947](https://github.com/tigerbeetle/tigerbeetle/pull/1947) + + Prevent time-travel in our replica test code. + +- [#1943](https://github.com/tigerbeetle/tigerbeetle/pull/1943) + + Fix a fuzzer bug around checkpoint / commit ratios. + +### Features + +- [#1898](https://github.com/tigerbeetle/tigerbeetle/pull/1898) + + Add the ability to limit the VSR pipeline size at runtime to save memory. + +### Internals +- [#1925](https://github.com/tigerbeetle/tigerbeetle/pull/1925) + + Fix path handling on Windows by switching to `NtCreateFile`. Before, TigerBeetle would silently + treat all paths as relative on Windows. + +- [#1917](https://github.com/tigerbeetle/tigerbeetle/pull/1917) + + In preparation for multiversion binaries, make `release_client_min` a parameter, set by + `release.zig`. This allows us to ensure backwards compatibility with older clients. + +- [#1827](https://github.com/tigerbeetle/tigerbeetle/pull/1827) + + Add some additional asserts around block lifetimes in compaction. + +- [#1939](https://github.com/tigerbeetle/tigerbeetle/pull/1939) + + Fix parsing of multiple CLI positional fields. + +- [#1923](https://github.com/tigerbeetle/tigerbeetle/pull/1923) + + Remove `main_pkg_path = src/` early, to help us be compatible with Zig 0.12. + +- [#1937](https://github.com/tigerbeetle/tigerbeetle/pull/1937), + [#1912](https://github.com/tigerbeetle/tigerbeetle/pull/1912), + [#1852](https://github.com/tigerbeetle/tigerbeetle/pull/1852) + + Docs organization and link fixes. + +### TigerTracks 🎧 + +- [Thank You (Not So Bad)](https://www.youtube.com/watch?v=fQWNeIiFf_s) + +## 2024-05-06 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#1906](https://github.com/tigerbeetle/tigerbeetle/pull/1906), + [#1904](https://github.com/tigerbeetle/tigerbeetle/pull/1904), + [#1903](https://github.com/tigerbeetle/tigerbeetle/pull/1903), + [#1901](https://github.com/tigerbeetle/tigerbeetle/pull/1901), + [#1899](https://github.com/tigerbeetle/tigerbeetle/pull/1899), + [#1886](https://github.com/tigerbeetle/tigerbeetle/pull/1886) + + Fixes and performance improvements to fuzzers. + +- [#1897](https://github.com/tigerbeetle/tigerbeetle/pull/1897) + + Reduces cache size for the `--development` flag, which was originally created to bypass direct + I/O requirements but can also aggregate other convenient options for non-production environments. + +- [#1895](https://github.com/tigerbeetle/tigerbeetle/pull/1895) + + Reduction in memory footprint, calculating the maximum number of messages from runtime-known + configurations. + +### Features + +- [#1896](https://github.com/tigerbeetle/tigerbeetle/pull/1896) + + Removes the `bootstrap.{sh,bat}` scripts, replacing them with a more transparent instruction for + downloading the binary release or building from source. + +- [#1890](https://github.com/tigerbeetle/tigerbeetle/pull/1890) + + Nicely handles "illegal instruction" crashes, printing a friendly message when the CPU running a + binary release is too old and does not support some modern instructions such as AES-NI and AVX2. + +### Internals + +- [#1892](https://github.com/tigerbeetle/tigerbeetle/pull/1892) + + Include micro-benchmarks as part of the unit tests, so there's no need for a special case in the + CI while we still compile and check them. + +- [#1902](https://github.com/tigerbeetle/tigerbeetle/pull/1902) + + A TigerStyle addition on "why prefer a explicitly sized integer over `usize`". + +- [#1894](https://github.com/tigerbeetle/tigerbeetle/pull/1894) + + Rename "Getting Started" to "Quick Start" for better organization and clarifications. + +- [#1900](https://github.com/tigerbeetle/tigerbeetle/pull/1900) + + While TigerBeetle builds are deterministic, Zip files include a timestamp that makes the build + output non-deterministic! This PR sets an explicit timestamp for entirely reproducible releases. + +- [1909](https://github.com/tigerbeetle/tigerbeetle/pull/1909) + + Extracts the zig compiler path into a `ZIG_EXE` environment variable, allowing easier sharing of + the same compiler across multiple git work trees. + +### TigerTracks 🎧 + +- [Thank You](https://www.youtube.com/watch?v=1TO48Cnl66w) + +## 2024-04-29 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#1883](https://github.com/tigerbeetle/tigerbeetle/pull/1883) + + Move message allocation farther down into the `tigerbeetle start` code path. + `tigerbeetle format` is now faster, since it no longer allocates these messages. + +- [#1880](https://github.com/tigerbeetle/tigerbeetle/pull/1880) + + Reduce the connection limit, which was unnecessarily high. + +### Features + +- [#1848](https://github.com/tigerbeetle/tigerbeetle/pull/1848) + + Implement zig-zag merge join for merging index scans. + (Note that this functionality is not yet exposed to TigerBeetle's API.) + +- [#1882](https://github.com/tigerbeetle/tigerbeetle/pull/1882) + + Print memory usage more accurately during `tigerbeetle start`. + +### Internals + +- [#1874](https://github.com/tigerbeetle/tigerbeetle/pull/1874) + + Fix blob-size CI check with respect to shallow clones. + +- [#1870](https://github.com/tigerbeetle/tigerbeetle/pull/1870), + [#1869](https://github.com/tigerbeetle/tigerbeetle/pull/1869) + + Add more fuzzers to CFO (Continuous Fuzzing Orchestrator). + +- [#1868](https://github.com/tigerbeetle/tigerbeetle/pull/1868), + [#1875](https://github.com/tigerbeetle/tigerbeetle/pull/1875) + + Improve fuzzer performance. + +- [#1864](https://github.com/tigerbeetle/tigerbeetle/pull/1864) + + On the devhub, show at most one failing seed per fuzzer. + +- [#1820](https://github.com/tigerbeetle/tigerbeetle/pull/1820), + [#1867](https://github.com/tigerbeetle/tigerbeetle/pull/1867), + [#1877](https://github.com/tigerbeetle/tigerbeetle/pull/1877), + [#1873](https://github.com/tigerbeetle/tigerbeetle/pull/1873), + [#1853](https://github.com/tigerbeetle/tigerbeetle/pull/1853), + [#1872](https://github.com/tigerbeetle/tigerbeetle/pull/1872), + [#1845](https://github.com/tigerbeetle/tigerbeetle/pull/1845), + [#1871](https://github.com/tigerbeetle/tigerbeetle/pull/1871) + + Documentation improvements. + +### TigerTracks 🎧 + +- [The Core](https://open.spotify.com/track/62DOxN9FeTsR0J0ccnBhMu) + +## 2024-04-22 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#1851](https://github.com/tigerbeetle/tigerbeetle/pull/1851) + + Implement grid scrubbing --- a background job that periodically reads the entire data file, + verifies its correctness and repairs any corrupted blocks. + +- [#1855](https://github.com/tigerbeetle/tigerbeetle/pull/1855), + [#1854](https://github.com/tigerbeetle/tigerbeetle/pull/1854). + + Turn on continuous fuzzing and integrate it with + [devhub](https://devhub.tigerbeetle.com/). + +### Internals + +- [#1849](https://github.com/tigerbeetle/tigerbeetle/pull/1849) + + Improve navigation on the docs website. + +### TigerTracks 🎧 + +A very special song from our friend [MEGAHIT](https://www.megahit.hu)! + +- [TigerBeetle](https://open.spotify.com/track/66pxevn7ImjMDozcs1TE3Q) + +## 2024-04-15 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#1810](https://github.com/tigerbeetle/tigerbeetle/pull/1810) + + Incrementally recompute the number values to compact in the storage engine. This smooths out I/O + latency, giving a nice bump to transaction throughput under load. + +### Features + +- [#1843](https://github.com/tigerbeetle/tigerbeetle/pull/1843) + + Add `--development` flag to `format` and `start` commands in production binaries to downgrade + lack of Direct I/O support from a hard error to a warning. + + TigerBeetle uses Direct I/O for certain safety guarantees, but this feature is not available on + all development environments due to varying file systems. This serves as a compromise between + providing a separate development release binary and strictly requiring Direct I/O to be present. + +### Internals + +- [#1833](https://github.com/tigerbeetle/tigerbeetle/pull/1833) + + Add fixed upper bound to loop in the StorageChecker. + +- [#1836](https://github.com/tigerbeetle/tigerbeetle/pull/1836) + + Orchestrate continuous fuzzing of tigerbeetle components straight from the build system! This + gives us some flexibility on configuring our set of machines which test and report errors. + +- [#1842](https://github.com/tigerbeetle/tigerbeetle/pull/1842), + [#1844](https://github.com/tigerbeetle/tigerbeetle/pull/1844), + [#1832](https://github.com/tigerbeetle/tigerbeetle/pull/1832) + + Styling updates and fixes. + +### TigerTracks 🎧 + +- [CHERRY PEPSI](https://www.youtube.com/watch?v=D5Avlh980k4) + + +## 2024-04-08 (No release: Queued up for upcoming multi-version binary release) + +### Safety And Performance + +- [#1821](https://github.com/tigerbeetle/tigerbeetle/pull/1821) + + Fix a case the VOPR found where a replica recovers into `recovering_head` unexpectedly. + +### Features + +- [#1565](https://github.com/tigerbeetle/tigerbeetle/pull/1565) + + Improve CLI errors around sizing by providing human readable (1057MiB vs 1108344832) values. + +- [#1818](https://github.com/tigerbeetle/tigerbeetle/pull/1818), + [#1831](https://github.com/tigerbeetle/tigerbeetle/pull/1831), + [#1829](https://github.com/tigerbeetle/tigerbeetle/pull/1829), + [#1817](https://github.com/tigerbeetle/tigerbeetle/pull/1817), + [#1826](https://github.com/tigerbeetle/tigerbeetle/pull/1826), + [#1825](https://github.com/tigerbeetle/tigerbeetle/pull/1825) + + Documentation improvements. + +### Internals + +- [#1806](https://github.com/tigerbeetle/tigerbeetle/pull/1806) + + Additional LSM compaction comments and assertions. + +- [#1824](https://github.com/tigerbeetle/tigerbeetle/pull/1824) + + Clarify some scan internals and add additional assertions. + +- [#1828](https://github.com/tigerbeetle/tigerbeetle/pull/1828) + + Some of our comments had duplicate words - thanks @divdeploy for + for noticing! + +### TigerTracks 🎧 + +- [All The Small Things](https://www.youtube.com/watch?v=Sn0gVjPrUj0) + +## 2024-04-01 (Placeholder: no release yet) + +### Safety And Performance + +- [#1766](https://github.com/tigerbeetle/tigerbeetle/pull/1766) + + Reject incoming client requests that have an unexpected message length. + +- [#1768](https://github.com/tigerbeetle/tigerbeetle/pull/1768) + + Fix message alignment. + +- [#1772](https://github.com/tigerbeetle/tigerbeetle/pull/1772), + [#1786](https://github.com/tigerbeetle/tigerbeetle/pull/1786) + + `StorageChecker` now verifies grid determinism at bar boundaries. + +- [#1776](https://github.com/tigerbeetle/tigerbeetle/pull/1776) + + Fix VOPR liveness false positive when standby misses an op. + +- [#1814](https://github.com/tigerbeetle/tigerbeetle/pull/1814) + + Assert that the type-erased LSM block metadata matches the comptime one, specialized over `Tree`. + +- [#1797](https://github.com/tigerbeetle/tigerbeetle/pull/1797) + + Use a FIFO as a block_pool instead of trying to slice arrays during compaction. + +### Features + +- [#1774](https://github.com/tigerbeetle/tigerbeetle/pull/1774) + + Implement `get_account_transfers` and `get_account_balances` in the REPL. + +- [#1781](https://github.com/tigerbeetle/tigerbeetle/pull/1781), + [#1784](https://github.com/tigerbeetle/tigerbeetle/pull/1784), + [#1765](https://github.com/tigerbeetle/tigerbeetle/pull/1765), + [#1816](https://github.com/tigerbeetle/tigerbeetle/pull/1816), + [#1808](https://github.com/tigerbeetle/tigerbeetle/pull/1808), + [#1802](https://github.com/tigerbeetle/tigerbeetle/pull/1802), + [#1798](https://github.com/tigerbeetle/tigerbeetle/pull/1798), + [#1793](https://github.com/tigerbeetle/tigerbeetle/pull/1793), + [#1805](https://github.com/tigerbeetle/tigerbeetle/pull/1805) + + Documentation improvements. + +- [#1813](https://github.com/tigerbeetle/tigerbeetle/pull/1813) + + Improve Docker experience by handling `SIGTERM` through [tini](https://github.com/krallin/tini). + +- [#1800](https://github.com/tigerbeetle/tigerbeetle/pull/1800) + + For reproducible benchmarks, allow setting `--seed` on the CLI. + +### Internals + +- [#1640](https://github.com/tigerbeetle/tigerbeetle/pull/1640), + [#1782](https://github.com/tigerbeetle/tigerbeetle/pull/1782), + [#1788](https://github.com/tigerbeetle/tigerbeetle/pull/1788) + + Move `request_queue` outside of `vsr.Client`. + +- [#1775](https://github.com/tigerbeetle/tigerbeetle/pull/1775) + + Extract `CompactionPipeline` to a dedicated function. + +- [#1773](https://github.com/tigerbeetle/tigerbeetle/pull/1773) + + Replace compaction interface with comptime dispatch. + +- [#1796](https://github.com/tigerbeetle/tigerbeetle/pull/1796) + + Remove the duplicated `CompactionInfo` value stored in `PipelineSlot`, + referencing it from the `Compaction` by its coordinates. + +- [#1809](https://github.com/tigerbeetle/tigerbeetle/pull/1809), + [#1807](https://github.com/tigerbeetle/tigerbeetle/pull/1807) + + CLI output improvements. + +- [#1804](https://github.com/tigerbeetle/tigerbeetle/pull/1804), + [#1812](https://github.com/tigerbeetle/tigerbeetle/pull/1812), + [#1799](https://github.com/tigerbeetle/tigerbeetle/pull/1799), + [#1767](https://github.com/tigerbeetle/tigerbeetle/pull/1767) + + Improvements in the client libraries CI. + +- [#1771](https://github.com/tigerbeetle/tigerbeetle/pull/1771), + [#1770](https://github.com/tigerbeetle/tigerbeetle/pull/1770), + [#1792](https://github.com/tigerbeetle/tigerbeetle/pull/1792) + + Metrics adjustments for Devhub and Nyrkio integration. + +- [#1811](https://github.com/tigerbeetle/tigerbeetle/pull/1811), + [#1803](https://github.com/tigerbeetle/tigerbeetle/pull/1803), + [#1801](https://github.com/tigerbeetle/tigerbeetle/pull/1801), + [#1762](https://github.com/tigerbeetle/tigerbeetle/pull/1762) + + Various bug fixes in the build script and removal of the "Do not use in production" warning. + +## 2024-03-19 + +- Bump version to 0.15.x +- Starting with 0.15.x, TigerBeetle is ready for production use, preserves durability and + provides a forward upgrade path through storage stability. + +### Safety And Performance +- [#1755](https://github.com/tigerbeetle/tigerbeetle/pull/1755) + + Set TigerBeetle's block size to 512KB. + + Previously, we used to have a block size of 1MB to help with approximate pacing. Now that pacing + can be tuned independently of block size, reduce this value (but not too much - make the roads + wider than you think) to help with read amplification on queries. + +### TigerTracks 🎧 + +- [Immigrant Song - Live 1972](https://open.spotify.com/track/2aH2dcPnwoQwhLsXFezU2r) + +## 2024-03-18 + +### Safety And Performance + +- [#1660](https://github.com/tigerbeetle/tigerbeetle/pull/1660) + + Implement compaction pacing: traditionally LSM databases run compaction on a background thread. + In contrast compaction in tigerbeetle is deterministically interleaved with normal execution + process, to get predictable latencies and to guarantee that ingress can never outrun compaction. + + In this PR, this "deterministic scheduling" is greatly improved, slicing compaction work into + smaller bites which are more evenly distributed across a bar of batched requests. + +- [#1722](https://github.com/tigerbeetle/tigerbeetle/pull/1722) + + Include information about tigerbeetle version into the VSR protocol and the data file. + +- [#1732](https://github.com/tigerbeetle/tigerbeetle/pull/1732), + [#1743](https://github.com/tigerbeetle/tigerbeetle/pull/1743), + [#1742](https://github.com/tigerbeetle/tigerbeetle/pull/1742), + [#1720](https://github.com/tigerbeetle/tigerbeetle/pull/1720), + [#1719](https://github.com/tigerbeetle/tigerbeetle/pull/1719), + [#1705](https://github.com/tigerbeetle/tigerbeetle/pull/1705), + [#1708](https://github.com/tigerbeetle/tigerbeetle/pull/1708), + [#1707](https://github.com/tigerbeetle/tigerbeetle/pull/1707), + [#1723](https://github.com/tigerbeetle/tigerbeetle/pull/1723), + [#1706](https://github.com/tigerbeetle/tigerbeetle/pull/1706), + [#1700](https://github.com/tigerbeetle/tigerbeetle/pull/1700), + [#1696](https://github.com/tigerbeetle/tigerbeetle/pull/1696), + [#1686](https://github.com/tigerbeetle/tigerbeetle/pull/1686). + + Many availability issues found by the simulator fixed! + +- [#1734](https://github.com/tigerbeetle/tigerbeetle/pull/1734) + + Fix a buffer leak when `get_account_balances` is called on an invalid account. + + +### Features + +- [#1671](https://github.com/tigerbeetle/tigerbeetle/pull/1671), + [#1713](https://github.com/tigerbeetle/tigerbeetle/pull/1713), + [#1709](https://github.com/tigerbeetle/tigerbeetle/pull/1709), + [#1688](https://github.com/tigerbeetle/tigerbeetle/pull/1688), + [#1691](https://github.com/tigerbeetle/tigerbeetle/pull/1691), + [#1690](https://github.com/tigerbeetle/tigerbeetle/pull/1690). + + Many improvements to the documentation! + +- [#1733](https://github.com/tigerbeetle/tigerbeetle/pull/1733) + + Rename `get_account_history` to `get_account_balances`. + +- [#1657](https://github.com/tigerbeetle/tigerbeetle/pull/1657) + + Automatically expire pending transfers. + +- [#1682](https://github.com/tigerbeetle/tigerbeetle/pull/1682) + + Implement in-place upgrades, so that the version of tigerbeetle binary can be updated without + recreating the data file from scratch. + +- [#1674](https://github.com/tigerbeetle/tigerbeetle/pull/1674) + + Consistently use `MiB` rather than `MB` in the CLI interface. + +- [#1678](https://github.com/tigerbeetle/tigerbeetle/pull/1678) + + Mark `--standby` and `benchmark` CLI arguments as experimental. + +### Internals + +- [#1726](https://github.com/tigerbeetle/tigerbeetle/pull/1726) + + Unify PostedGroove and the index pending_status. + +- [#1681](https://github.com/tigerbeetle/tigerbeetle/pull/1681) + + Include an entire header into checkpoint state to ease recovery after state sync. + + +### TigerTracks 🎧 + +- [Are You Gonna Go My Way](https://open.spotify.com/track/4LQOa4kXu0QAD88nMpr4fA) + +## 2024-03-11 + +### Safety And Performance + +- [#1663](https://github.com/tigerbeetle/tigerbeetle/pull/1663) + + Fetching account history and transfers now has unit tests, helping detect and fix a reported bug + with posting and voiding transfers. + +### Internals + +- [#1648](https://github.com/tigerbeetle/tigerbeetle/pull/1648), + [#1665](https://github.com/tigerbeetle/tigerbeetle/pull/1665), + [#1654](https://github.com/tigerbeetle/tigerbeetle/pull/1654), + [#1651](https://github.com/tigerbeetle/tigerbeetle/pull/1651) + + Testing and Timer logic was subject to some spring cleaning. + +### Features + +- [#1656](https://github.com/tigerbeetle/tigerbeetle/pull/1656), + [#1659](https://github.com/tigerbeetle/tigerbeetle/pull/1659), + [#1666](https://github.com/tigerbeetle/tigerbeetle/pull/1666), + [#1667](https://github.com/tigerbeetle/tigerbeetle/pull/1667), + [#1667](https://github.com/tigerbeetle/tigerbeetle/pull/1670) + + Preparation for in-place upgrade support. + +- [#1633](https://github.com/tigerbeetle/tigerbeetle/pull/1633), + [#1661](https://github.com/tigerbeetle/tigerbeetle/pull/1661), + [#1652](https://github.com/tigerbeetle/tigerbeetle/pull/1652), + [#1647](https://github.com/tigerbeetle/tigerbeetle/pull/1647), + [#1637](https://github.com/tigerbeetle/tigerbeetle/pull/1637), + [#1638](https://github.com/tigerbeetle/tigerbeetle/pull/1638), + [#1655](https://github.com/tigerbeetle/tigerbeetle/pull/1655) + + [Documentation](https://docs.tigerbeetle.com/) has received some very welcome organizational + and clarity changes. Go check them out! + +### TigerTracks 🎧 + +- [Você Chegou](https://open.spotify.com/track/5Ns9a6JKX4sdUlaAh4SSGy) + +## 2024-03-04 + +### Safety And Performance + +- [#1584](https://github.com/tigerbeetle/tigerbeetle/pull/1584) + Lower our memory usage by removing a redundant stash and not requiring a non-zero object cache + size for Grooves. + + The object cache is designed to help things like Account lookups, where the positive case can + skip all the prefetch machinery, but it doesn't make as much sense for other Grooves. + +- [#1581](https://github.com/tigerbeetle/tigerbeetle/pull/1581) + [#1611](https://github.com/tigerbeetle/tigerbeetle/pull/1611) + + Hook [nyrkiö](https://nyrkio.com/) up to our CI! You can find our dashboard + [here](https://nyrkio.com/public/https%3A%2F%2Fgithub.com%2Ftigerbeetle%2Ftigerbeetle/main/devhub) + in addition to our [devhub](https://devhub.tigerbeetle.com/). + +- [#1635](https://github.com/tigerbeetle/tigerbeetle/pull/1635) + [#1634](https://github.com/tigerbeetle/tigerbeetle/pull/1634) + [#1623](https://github.com/tigerbeetle/tigerbeetle/pull/1623) + [#1619](https://github.com/tigerbeetle/tigerbeetle/pull/1619) + [#1609](https://github.com/tigerbeetle/tigerbeetle/pull/1609) + [#1608](https://github.com/tigerbeetle/tigerbeetle/pull/1608) + [#1595](https://github.com/tigerbeetle/tigerbeetle/pull/1595) + + Lots of small VSR changes, including a VOPR crash fix. + +- [#1598](https://github.com/tigerbeetle/tigerbeetle/pull/1598) + + Fix a VOPR failure where state sync would cause a break in the hash chain. + +### Internals + +- [#1599](https://github.com/tigerbeetle/tigerbeetle/pull/1599) + [#1597](https://github.com/tigerbeetle/tigerbeetle/pull/1597) + + Use Expand-Archive over unzip in PowerShell - thanks @felipevalerio for reporting! + +- [#1607](https://github.com/tigerbeetle/tigerbeetle/pull/1607) + [#1620](https://github.com/tigerbeetle/tigerbeetle/pull/1620) + + Implement [explicit coverage marks](https://ferrous-systems.com/blog/coverage-marks/). + +- [#1621](https://github.com/tigerbeetle/tigerbeetle/pull/1621) + [#1625](https://github.com/tigerbeetle/tigerbeetle/pull/1625) + [#1622](https://github.com/tigerbeetle/tigerbeetle/pull/1622) + [#1600](https://github.com/tigerbeetle/tigerbeetle/pull/1600) + [#1605](https://github.com/tigerbeetle/tigerbeetle/pull/1605) + [#1618](https://github.com/tigerbeetle/tigerbeetle/pull/1618) + [#1606](https://github.com/tigerbeetle/tigerbeetle/pull/1606) + + Minor doc fixups. + +- [#1636](https://github.com/tigerbeetle/tigerbeetle/pull/1636) + [#1626](https://github.com/tigerbeetle/tigerbeetle/pull/1626) + + Default the VOPR to short log, and fix a false assertion in the liveness checker. + +- [#1596](https://github.com/tigerbeetle/tigerbeetle/pull/1596) + + Fix a memory leak in our Java tests. + +### TigerTracks 🎧 + +- [Auffe aufn Berg](https://www.youtube.com/watch?v=eRbkRNaqy9Y) + +## 2024-02-26 + +### Safety And Performance + +- [#1591](https://github.com/tigerbeetle/tigerbeetle/pull/1591) + [#1589](https://github.com/tigerbeetle/tigerbeetle/pull/1589) + [#1579](https://github.com/tigerbeetle/tigerbeetle/pull/1579) + [#1576](https://github.com/tigerbeetle/tigerbeetle/pull/1576) + + Rework the log repair logic to never repair beyond a "confirmed" checkpoint, fixing a + [liveness issue](https://github.com/tigerbeetle/tigerbeetle/issues/1378) where it was impossible + for the primary to repair its entire log, even with a quorum of replicas at a recent checkpoint. + +- [#1572](https://github.com/tigerbeetle/tigerbeetle/pull/1572) + + Some Java unit tests created native client instances without the proper deinitialization, + causing an `OutOfMemoryError` during CI. + +- [#1569](https://github.com/tigerbeetle/tigerbeetle/pull/1569) + [#1570](https://github.com/tigerbeetle/tigerbeetle/pull/1570) + + Fix Vopr's false alarms. + +### Internals + +- [#1585](https://github.com/tigerbeetle/tigerbeetle/pull/1585) + + Document how assertions should be used, especially those with complexity _O(n)_ under + the `constants.verify` conditional. + +- [#1580](https://github.com/tigerbeetle/tigerbeetle/pull/1580) + + Harmonize and automate the logging pattern by using the `@src` built-in to retrieve the + function name. + +- [#1568](https://github.com/tigerbeetle/tigerbeetle/pull/1568) + + Include the benchmark smoke as part of the `zig build test` command rather than a special case + during CI. + +- [#1574](https://github.com/tigerbeetle/tigerbeetle/pull/1574) + + Remove unused code coverage metrics from the CI. + +- [#1575](https://github.com/tigerbeetle/tigerbeetle/pull/1575) + [#1573](https://github.com/tigerbeetle/tigerbeetle/pull/1573) + [#1582](https://github.com/tigerbeetle/tigerbeetle/pull/1582) + + Re-enable Windows CI 🎉. + +### TigerTracks 🎧 + +- [Dos Margaritas](https://www.youtube.com/watch?v=Ts_7BYubYws) + + [(_versión en español_)](https://www.youtube.com/watch?v=B_VLegyguoI) + +## 2024-02-19 + +### Safety And Performance + +- [#1533](https://github.com/tigerbeetle/tigerbeetle/pull/1533) + + DVCs implicitly nack missing prepares from old log-views. + + (This partially addresses a liveness issue in the view change.) + +- [#1552](https://github.com/tigerbeetle/tigerbeetle/pull/1552) + + When a replica joins a view by receiving an SV message, some of the SV's headers may be too far + ahead to insert into the journal. (That is, they are beyond the replica's checkpoint trigger.) + + During a view change, those headers are now eligible to be DVC headers. + + (This partially addresses a liveness issue in the view change.) + +- [#1560](https://github.com/tigerbeetle/tigerbeetle/pull/1560) + + Fixes a bug in the C client that wasn't handling `error.TooManyOutstanding` correctly. + +### Internals + +- [#1482](https://github.com/tigerbeetle/tigerbeetle/pull/1482) + + Bring back Windows tests for .NET client in CI. + +- [#1540](https://github.com/tigerbeetle/tigerbeetle/pull/1540) + + Add script to scaffold changelog updates. + +- [#1542](https://github.com/tigerbeetle/tigerbeetle/pull/1542), + [#1553](https://github.com/tigerbeetle/tigerbeetle/pull/1553), + [#1559](https://github.com/tigerbeetle/tigerbeetle/pull/1559), + [#1561](https://github.com/tigerbeetle/tigerbeetle/pull/1561) + + Improve CI/test error reporting. + +- [#1551](https://github.com/tigerbeetle/tigerbeetle/pull/1551) + + Draw devhub graph as line graph. + +- [#1554](https://github.com/tigerbeetle/tigerbeetle/pull/1554) + + Simplify command to run a single test. + +- [#1555](https://github.com/tigerbeetle/tigerbeetle/pull/1555) + + Add client batching integration tests. + +- [#1557](https://github.com/tigerbeetle/tigerbeetle/pull/1557) + + Format default values into the CLI help message. + +- [#1558](https://github.com/tigerbeetle/tigerbeetle/pull/1558) + + Track commit timestamp to enable retrospective benchmarking in the devhub. + +- [#1562](https://github.com/tigerbeetle/tigerbeetle/pull/1562), + [#1563](https://github.com/tigerbeetle/tigerbeetle/pull/1563) + + Improve CI/test performance. + +- [#1567](https://github.com/tigerbeetle/tigerbeetle/pull/1567) + + Guarantee that the test runner correctly reports "zero tests run" when run with a filter that + matches no tests. + +### TigerTracks 🎧 + +- [Eye Of The Tiger](https://www.youtube.com/watch?v=btPJPFnesV4) + + (Hat tip to [iofthetiger](https://ziggit.dev/t/iofthetiger/3065)!) + +## 2024-02-12 + +### Safety And Performance + +- [#1519](https://github.com/tigerbeetle/tigerbeetle/pull/1519) + + Reduce checkpoint latency by checkpointing the grid concurrently with other trailers. + +- [#1515](https://github.com/tigerbeetle/tigerbeetle/pull/1515) + + Fix a logical race condition (which was caught by an assert) when reading and writing client + replies concurrently. + + +- [#1522](https://github.com/tigerbeetle/tigerbeetle/pull/1522) + + Double check that both checksum and request number match between a request and the corresponding + reply. + +- [#1520](https://github.com/tigerbeetle/tigerbeetle/pull/1520) + + Optimize fields with zero value by not adding them to an index. + +### Features + +- [#1526](https://github.com/tigerbeetle/tigerbeetle/pull/1526), + [#1531](https://github.com/tigerbeetle/tigerbeetle/pull/1531). + + Introduce `get_account_history` operation for querying the historical balances of a given account. + +- [#1523](https://github.com/tigerbeetle/tigerbeetle/pull/1523) + + Add helper function for generating approximately monotonic IDs to various language clients. + +### TigerTracks 🎧 + +- [Musique à Grande Vitesse](https://open.spotify.com/album/0pmrBIfqDn65p4FX9ubqXn) + +## 2024-02-05 + +### Safety And Performance + +- [#1489](https://github.com/tigerbeetle/tigerbeetle/pull/1489), + [#1496](https://github.com/tigerbeetle/tigerbeetle/pull/1496), + [#1501](https://github.com/tigerbeetle/tigerbeetle/pull/1501). + + Harden VSR against edge cases. + +- [#1508](https://github.com/tigerbeetle/tigerbeetle/pull/1508), + [#1509](https://github.com/tigerbeetle/tigerbeetle/pull/1509). + + Allows VSR to perform checkpoint steps concurrently to reduce latency spikes. + +- [#1505](https://github.com/tigerbeetle/tigerbeetle/pull/1505) + + Removed unused indexes on account balances for a nice bump in throughput and lower memory usage. + +- [#1512](https://github.com/tigerbeetle/tigerbeetle/pull/1512) + + Only zero-out the parts necessary for correctness of fresh storage buffers. "Defense in Depth" + without sacrificing performance! + +### Features + +- [#1491](https://github.com/tigerbeetle/tigerbeetle/pull/1491), + [#1503](https://github.com/tigerbeetle/tigerbeetle/pull/1503). + + TigerBeetle's [dev workbench](https://devhub.tigerbeetle.com/) now also tracks + memory usage (RSS), throughput, and latency benchmarks over time! + +### Internals + +- [#1481](https://github.com/tigerbeetle/tigerbeetle/pull/1481), + [#1493](https://github.com/tigerbeetle/tigerbeetle/pull/1493), + [#1495](https://github.com/tigerbeetle/tigerbeetle/pull/1495), + [#1498](https://github.com/tigerbeetle/tigerbeetle/pull/1498). + + Simplify assertions and tests for VSR and Replica. + +- [#1497](https://github.com/tigerbeetle/tigerbeetle/pull/1497), + [#1502](https://github.com/tigerbeetle/tigerbeetle/pull/1502), + [#1504](https://github.com/tigerbeetle/tigerbeetle/pull/1504). + + .NET CI fixups + +- [#1485](https://github.com/tigerbeetle/tigerbeetle/pull/1485), + [#1499](https://github.com/tigerbeetle/tigerbeetle/pull/1499), + [#1504](https://github.com/tigerbeetle/tigerbeetle/pull/1504). + + Spring Cleaning + +### TigerTracks 🎧 + +- [Bone Dry](https://open.spotify.com/track/0adZjn5WV3b0BcZbvSi0y9) + +## 2024-01-29 + +### Safety And Performance + +- [#1446](https://github.com/tigerbeetle/tigerbeetle/pull/1446) + + Panic on checkpoint divergence. Previously, if a replica's state on disk diverged, we'd + use state sync to bring it in line. Now, we don't allow any storage engine nondeterminism + (mixed version clusters are forbidden) and panic if we encounter any. + +- [#1476](https://github.com/tigerbeetle/tigerbeetle/pull/1476) + + Fix a liveness issues when starting a view across checkpoints in an idle cluster. + +- [#1460](https://github.com/tigerbeetle/tigerbeetle/pull/1460) + + Stop an isolated replica from locking a standby out of a cluster. + +### Features + +- [#1470](https://github.com/tigerbeetle/tigerbeetle/pull/1470) + + Change `get_account_transfers` to use `timestamp_min` and `timestamp_max` to allow filtering by + timestamp ranges. + +- [#1463](https://github.com/tigerbeetle/tigerbeetle/pull/1463) + + Allow setting `--addresses=0` when starting TigerBeetle to enable a mode helpful for integration + tests: + * A free port will be picked automatically. + * The port, and only the port, will be printed to stdout which will then be closed. + * TigerBeetle will [exit when its stdin is closed](https://matklad.github.io/2023/10/11/unix-structured-concurrency.html). + +- [#1402](https://github.com/tigerbeetle/tigerbeetle/pull/1402) + + TigerBeetle now has a [dev workbench](https://devhub.tigerbeetle.com/)! Currently we + track our build times and executable size over time. + +- [#1461](https://github.com/tigerbeetle/tigerbeetle/pull/1461) + + `tigerbeetle client ...` is now `tigerbeetle repl ...`. + +### Internals + +- [#1480](https://github.com/tigerbeetle/tigerbeetle/pull/1480) + + Deprecate support and testing for Node.js 16, which is EOL. + +- [#1477](https://github.com/tigerbeetle/tigerbeetle/pull/1477), + [#1469](https://github.com/tigerbeetle/tigerbeetle/pull/1469), + [#1475](https://github.com/tigerbeetle/tigerbeetle/pull/1475), + [#1457](https://github.com/tigerbeetle/tigerbeetle/pull/1457), + [#1452](https://github.com/tigerbeetle/tigerbeetle/pull/1452). + + Improve VOPR & VSR logging, docs, assertions and tests. + +- [#1474](https://github.com/tigerbeetle/tigerbeetle/pull/1474) + + Improve integration tests around Node.js and `pending_transfer_expired` - thanks to our friends at + Rafiki for reporting! + +### TigerTracks 🎧 + +- [Paint It, Black](https://www.youtube.com/watch?v=170sceOWWXc) + +## 2024-01-22 + +### Safety And Performance + +- [#1438](https://github.com/tigerbeetle/tigerbeetle/pull/1438) + + Avoid an extra copy of data when encoding the superblock during checkpoint. + +- [#1429](https://github.com/tigerbeetle/tigerbeetle/pull/1429) + + Use more precise upper bounds for static memory allocation, reducing memory usage by about 200MiB. + +- [#1439](https://github.com/tigerbeetle/tigerbeetle/pull/1439) + + When reading data past the end of the file, defensively zero-out the result buffer. + +### Features + +- [#1443](https://github.com/tigerbeetle/tigerbeetle/pull/1443) + + Upgrade C# client API to use `Span`. + +- [#1347](https://github.com/tigerbeetle/tigerbeetle/pull/1347) + + Add ID generation function to the Java client. TigerBeetle doesn't assign any meaning to IDs and + can use anything as long as it is unique. However, for optimal performance it is best if these + client-generated IDs are approximately monotonic. This can be achieved by, for example, using + client's current timestamp for high order bits of an ID. The new helper does just that. + +### Internals + +- [#1437](https://github.com/tigerbeetle/tigerbeetle/pull/1437), + [#1435](https://github.com/tigerbeetle/tigerbeetle/pull/1435), + [d7c3f46](https://github.com/tigerbeetle/tigerbeetle/commit/d7c3f4654ea7c65b6d141be33dadd29e869c3984). + + Rewrite git history to remove large files accidentally added to the repository during early quick + prototyping phase. To make this durable, add CI checks for unwanted files. The original history + is available at: + + + +- [#1421](https://github.com/tigerbeetle/tigerbeetle/pull/1421), + [#1401](https://github.com/tigerbeetle/tigerbeetle/pull/1401). + + New tips for the style guide: + + - [write code top-down](https://www.teamten.com/lawrence/programming/write-code-top-down.html) + - [pair up assertions](https://tigerbeetle.com/blog/2023-12-27-it-takes-two-to-contract) + +### TigerTracks 🎧 + +- [Don't Take No For An Answer](https://youtu.be/BUDe0bJAHjY) + +## 2024-01-15 + +Welcome to 2024! + +### Safety And Performance + +- [#1425](https://github.com/tigerbeetle/tigerbeetle/pull/1425), + [#1412](https://github.com/tigerbeetle/tigerbeetle/pull/1412), + [#1410](https://github.com/tigerbeetle/tigerbeetle/pull/1410), + [#1408](https://github.com/tigerbeetle/tigerbeetle/pull/1408), + [#1395](https://github.com/tigerbeetle/tigerbeetle/pull/1395). + + Run more fuzzers directly in CI as a part of not rocket science package. + +- [#1413](https://github.com/tigerbeetle/tigerbeetle/pull/1413) + + Formalize some ad-hoc testing practices as proper integration tests (that is, tests that interact + with a `tigerbeetle` binary through IPC). + +- [#1404](https://github.com/tigerbeetle/tigerbeetle/pull/1404) + + Add a lint check for unused Zig files. + +- [#1390](https://github.com/tigerbeetle/tigerbeetle/pull/1390) + + Improve cluster availability by including conservative information about the current view into + ping-pong messages. In particular, prevent the cluster from getting stuck when all replicas become + primaries for different views. + +- [#1365](https://github.com/tigerbeetle/tigerbeetle/pull/1365) + + Test both the latest and the oldest supported Java version on CI. + +- [#1389](https://github.com/tigerbeetle/tigerbeetle/pull/1389) + + Fix a data race on close in the Java client. + +### Features + +- [#1403](https://github.com/tigerbeetle/tigerbeetle/pull/1403) + + Make binaries on Linux about six times smaller (12MiB -> 2MiB). Turns `tigerbeetle` was + accidentally including 10 megabytes worth of debug info! Note that unfortunately stripping _all_ + debug info also prevents getting a nice stack trace in case of a crash. We are working on finding + the minimum amount of debug information required to get _just_ the stack traces. + +- [#1423](https://github.com/tigerbeetle/tigerbeetle/pull/1423), + [#1426](https://github.com/tigerbeetle/tigerbeetle/pull/1426). + + Cleanup error handling API for Java client to never surface internal errors as checked exceptions. + +- [#1405](https://github.com/tigerbeetle/tigerbeetle/pull/1405) + + Add example for setting up TigerBeetle as a systemd service. + +- [#1400](https://github.com/tigerbeetle/tigerbeetle/pull/1400) + + Drop support for .NET Standard 2.1. + +- [#1397](https://github.com/tigerbeetle/tigerbeetle/pull/1397) + + Don't exit repl on `help` command. + +### Internals + +- [#1422](https://github.com/tigerbeetle/tigerbeetle/pull/1422), + [#1420](https://github.com/tigerbeetle/tigerbeetle/pull/1420), + [#1417](https://github.com/tigerbeetle/tigerbeetle/pull/1417) + + Overhaul documentation-testing infrastructure to reduce code duplication. + +- [#1398](https://github.com/tigerbeetle/tigerbeetle/pull/1398) + + Don't test Node.js client on platforms for which there are no simple upstream installation + scripts. + +- [#1388](https://github.com/tigerbeetle/tigerbeetle/pull/1388) + + Use histogram in the benchmark script to reduce memory usage. + +### TigerTracks 🎧 + +- [Stripped](https://open.spotify.com/track/20BDMQu40KIUxUeFusq6eq) + +## 2023-12-20 + +_“The exception confirms the rule in cases not excepted."_ ― Cicero. + +Due to significant commits we had this last week, we decided to make an exception +in our release schedule and cut one more release in 2023! + +Still, **the TigerBeetle team wishes everyone happy holidays!** 🎁 + +### Internals + +- [#1362](https://github.com/tigerbeetle/tigerbeetle/pull/1362), + [#1367](https://github.com/tigerbeetle/tigerbeetle/pull/1367), + [#1374](https://github.com/tigerbeetle/tigerbeetle/pull/1374), + [#1375](https://github.com/tigerbeetle/tigerbeetle/pull/1375) + + Some CI-related stuff plus the `-Drelease` flag, which will bring back the joy of + using the compiler from the command line 🤓. + +- [#1373](https://github.com/tigerbeetle/tigerbeetle/pull/1373) + + Added value count to `TableInfo`, allowing future optimizations for paced compaction. + +### Safety And Performance + +- [#1346](https://github.com/tigerbeetle/tigerbeetle/pull/1346) + + The simulator found a failure when the WAL gets corrupted near a checkpoint boundary, leading us + to also consider scenarios where corrupted blocks in the grid end up "intersecting" with + corruption in the WAL, making the state unrecoverable where it should be. We fixed it by + extending the durability of "prepares", evicting them from the WAL only when there's a quorum of + checkpoints covering this "prepare". + +- [#1366](https://github.com/tigerbeetle/tigerbeetle/pull/1366) + + Fix a unit test that regressed after we changed an undesirable behavior that allowed `prefetch` + to invoke its callback synchronously. + +- [#1381](https://github.com/tigerbeetle/tigerbeetle/pull/1381) + + Relaxed a simulator's verification, allowing replicas of the core cluster to be missing some + prepares, as long as they are from a past checkpoint. + +### Features + +- [#1054](https://github.com/tigerbeetle/tigerbeetle/pull/1054) + + A highly anticipated feature lands on TigerBeetle: it's now possible to retrieve the transfers + involved with a given account by using the new operation `get_account_transfers`. + + Note that this feature itself is an ad-hoc API intended to be replaced once we have a proper + Querying API. The real improvement of this PR is the implementation of range queries, enabling + us to land exciting new features on the next releases. + +- [#1368](https://github.com/tigerbeetle/tigerbeetle/pull/1368) + + Bump the client's maximum limit and the default value of `concurrency_max` to fully take + advantage of the batching logic. + +### TigerTracks 🎧 + +- [Everybody needs somebody](https://www.youtube.com/watch?v=m1M5Tc7eLCo) + +## 2023-12-18 + +*As the last release of the year 2023, the TigerBeetle team wishes everyone happy holidays!* 🎁 + +### Internals + +- [#1359](https://github.com/tigerbeetle/tigerbeetle/pull/1359) + + We've established a rotation between the team for handling releases. As the one writing these + release notes, I am now quite aware. + +- [#1357](https://github.com/tigerbeetle/tigerbeetle/pull/1357) + + Fix panic in JVM unit test on Java 21. We test JNI functions even if they're not used by the Java + client and the semantics have changed a bit since Java 11. + +- [#1351](https://github.com/tigerbeetle/tigerbeetle/pull/1351), + [#1356](https://github.com/tigerbeetle/tigerbeetle/pull/1356), + [#1360](https://github.com/tigerbeetle/tigerbeetle/pull/1360) + + Move client sessions from the Superblock (database metadata) into the Grid (general storage). This + simplifies control flow for various sub-components like Superblock checkpointing and Replica state + sync. + +### Safety And Performance + +- [#1352](https://github.com/tigerbeetle/tigerbeetle/pull/1352) + + An optimization for removes on secondary indexes makes a return. Now tombstone values in the LSM + can avoid being compacted all the way down to the lowest level if they can be cancelled out by + inserts. + +- [#1257](https://github.com/tigerbeetle/tigerbeetle/pull/1257) + + Clients automatically batch pending similar requests 🎉! If a tigerbeetle client submits a + request, and one with the same operation is currently in-flight, they will be grouped and + processed together where possible (currently, only for `CreateAccount` and `CreateTransfers`). + This should [greatly improve the performance](https://github.com/tigerbeetle/tigerbeetle/pull/1257#issuecomment-1812648270) + of workloads which submit a single operation at a time. + +### TigerTracks 🎧 + +- [Carouselambra](https://open.spotify.com/track/0YZKbKo9i91i7LD0m1KASq) + +## 2023-12-11 + +### Safety And Performance + +- [#1339](https://github.com/tigerbeetle/tigerbeetle/pull/1339) + + Defense in depth: add checkpoint ID to prepare messages. Checkpoint ID is a hash that covers, via + hash chaining, the entire state stored in the data file. Verifying that checkpoint IDs match + provides a direct strong cryptographic guarantee that the state is the same across replicas, on + top of existing guarantee that the sequence of events leading to the state is identical. + +### Internals + +- [#1343](https://github.com/tigerbeetle/tigerbeetle/pull/1343), + [#1341](https://github.com/tigerbeetle/tigerbeetle/pull/1341), + [#1340](https://github.com/tigerbeetle/tigerbeetle/pull/1340) + + Gate the main branch on more checks: unit-tests for Node.js and even more fuzzers. + +- [#1332](https://github.com/tigerbeetle/tigerbeetle/pull/1332), + [#1348](https://github.com/tigerbeetle/tigerbeetle/pull/1348) + + Code cleanups after removal of storage size limit. + +### TigerTracks 🎧 + +- [Concrete Reservation](https://open.spotify.com/track/1Li9HBLXG2LJSeD4fEhtcd) + +## 2023-12-04 + +### Safety And Performance + +- [#1330](https://github.com/tigerbeetle/tigerbeetle/pull/1330), + [#1319](https://github.com/tigerbeetle/tigerbeetle/pull/1319) + + Fix free set index. The free set is a bitset of free blocks in the grid. To speed up block + allocation, the free set also maintains an index --- a coarser-grained bitset where a single bit + corresponds to 1024 blocks. Maintaining consistency between a data structure and its index is + hard, and thorough assertions are crucial. When moving free set to the grid, we discovered that, + in fact, we don't have enough assertions in this area and, as a result, even have a bug! + Assertions added, bug removed! + +- [#1323](https://github.com/tigerbeetle/tigerbeetle/pull/1323), + [#1336](https://github.com/tigerbeetle/tigerbeetle/pull/1336), + [#1324](https://github.com/tigerbeetle/tigerbeetle/pull/1324) + + LSM tree fuzzer found a couple of bugs in its own code. + +### Features + +- [#1331](https://github.com/tigerbeetle/tigerbeetle/pull/1331), + [#1322](https://github.com/tigerbeetle/tigerbeetle/pull/1322), + [#1328](https://github.com/tigerbeetle/tigerbeetle/pull/1328) + + Remove format-time limit on the size of the data file. Before, the maximum size of the data file + affected the layout of the superblock, and there wasn't any good way to increase this limit, short + of recreating the cluster from scratch. Now, this limit only applies to the in-memory data + structures: when a data files grows large, it is sufficient to just restart its replica with a + larger amount of RAM. + +- [#1321](https://github.com/tigerbeetle/tigerbeetle/pull/1321). + + We finally have the "installation" page in our docs! + +### Internals + +- [#1334](https://github.com/tigerbeetle/tigerbeetle/pull/1334) + + Use Zig's new `if (@inComptime())` builtin to compute checksum of an empty byte slice at compile + time. + +- [#1315](https://github.com/tigerbeetle/tigerbeetle/pull/1315) + + Fix unit tests for the Go client and add them to + [not rocket science](https://graydon2.dreamwidth.org/1597.html) + set of checks. + +### TigerTracks 🎧 + +- [Times Like These](https://www.youtube.com/watch?v=cvCUXXsP5WE) + +## 2023-11-27 + +### Internals + +- [#1306](https://github.com/tigerbeetle/tigerbeetle/pull/1306), + [#1308](https://github.com/tigerbeetle/tigerbeetle/pull/1308) + + When validating our releases, use the `release` branch instead of `main` to ensure everything is + in sync, and give the Java validation some retry logic to allow for delays in publishing to + Central. + +- [#1310](https://github.com/tigerbeetle/tigerbeetle/pull/1310) + + Pad storage checksums from 128-bit to 256-bit. These are currently unused, but we're reserving + the space for AEAD tags in future. + +- [#1312](https://github.com/tigerbeetle/tigerbeetle/pull/1312) + + Remove a trailing comma in our Java client sample code. + +- [#1313](https://github.com/tigerbeetle/tigerbeetle/pull/1313) + + Switch `bootstrap.sh` to use spaces only for indentation and ensure it's checked by our + shellcheck lint. + +- [#1314](https://github.com/tigerbeetle/tigerbeetle/pull/1314) + + Update our `DESIGN.md` to better reflect storage fault probabilities and add in a reference. + +- [#1316](https://github.com/tigerbeetle/tigerbeetle/pull/1316) + + Add `CHANGELOG.md` validation to our tidy lint script. We now check line length limits and + trailing whitespace. + +- [#1317](https://github.com/tigerbeetle/tigerbeetle/pull/1317) + + In keeping with TigerStyle rename `reserved_nonce` to `nonce_reserved`. + +- [#1318](https://github.com/tigerbeetle/tigerbeetle/pull/1318) + + Note in TigerStyle that callbacks go last in the list of parameters. + +- [#1325](https://github.com/tigerbeetle/tigerbeetle/pull/1325) + + Add an exception for line length limits if there's a link in said line. + +### TigerTracks 🎧 + +- [Space Trash](https://www.youtube.com/watch?v=tmcVAJd87Wk) + +## 2023-11-20 + +### Safety And Performance + +- [#1300](https://github.com/tigerbeetle/tigerbeetle/pull/1300) + + Recursively check for padding in structs used for data serialization, ensuring that no + uninitialized bytes can be stored or transmitted over the network. Previously, we checked only + if the struct had no padding, but not its fields. + +### Internals + +- [#1299](https://github.com/tigerbeetle/tigerbeetle/pull/1299) + + Minor adjustments in the release process, making it easier to track updates in the documentation + website when a new version is released, even if there are no changes in the documentation itself. + +- [#1301](https://github.com/tigerbeetle/tigerbeetle/pull/1301) + + Fix outdated documentation regarding 128-bit balances. + +- [#1302](https://github.com/tigerbeetle/tigerbeetle/pull/1302) + + Fix a [bug](https://github.com/tigerbeetle/tigerbeetle/issues/1290) discovered and reported + during the [Hackathon 2023](https://github.com/tigerbeetle/hackathon-2023), where the Node.js + client's error messages were truncated due to an incorrect string concatenation adding a null + byte `0x00` in the middle of the string. + +- [#1291](https://github.com/tigerbeetle/tigerbeetle/pull/1291) + + Update the Node.js samples instructions, guiding the user to install all dependencies before + the sample project. + +- [#1295](https://github.com/tigerbeetle/tigerbeetle/pull/1295) + + We've doubled the `Header`s size to 256 bytes, paving the way for future improvements that will + require extra space. Concurrently, this change also refactors a great deal of code. + Some of the `Header`'s fields are shared by all messages, however, each `Command` also requires + specific pieces of information that are only used by its kind of message, and it was necessary to + repurpose and reinterpret fields so that the same header could hold different data depending on + the context. Now, commands have their own specialized data type containing the fields that are + only pertinent to the context, making the API much safer and intent-clear. + +- [#1304](https://github.com/tigerbeetle/tigerbeetle/pull/1304) + + With larger headers (see #1295) we have enough room to make the cluster ID a 128-bit integer, + allowing operators to generate random cluster IDs without the cost of having a centralized ID + coordinator. Also updates the documentation and sample programs to reflect the new maximum batch + size, which was reduced from 8191 to 8190 items after we doubled the header. + +### TigerTracks 🎧 + +- [She smiled sweetly](https://www.youtube.com/watch?v=fB1EpEFz6Lg) + +## 2023-11-13 + +### Safety And Performance + +- [#1264](https://github.com/tigerbeetle/tigerbeetle/pull/1264) + + Implement last-mile release artifact verification in CI. + +- [#1268](https://github.com/tigerbeetle/tigerbeetle/pull/1268) + + Bump the simulator's safety phase max-ticks to avoid false positives from the liveness check. + +- [#1270](https://github.com/tigerbeetle/tigerbeetle/pull/1270) + + Fix a crash caused by a race between a commit and a repair acquiring a client-reply `Write`. + +- [#1278](https://github.com/tigerbeetle/tigerbeetle/pull/1278) + + Fix a crash caused by a race between state (table) sync and a move-table compaction. + + Both bugs didn't stand a chance in the [Line of Fire](https://www.youtube.com/watch?v=pq-G3EWO9XM) + of our deterministic simulator! + +### Internals + +- [#1244](https://github.com/tigerbeetle/tigerbeetle/pull/1244) + + Specify which CPU features are supported in builds. + +- [#1275](https://github.com/tigerbeetle/tigerbeetle/pull/1275) + + Improve `shell.zig`'s directory handling, to guard against mistakes with respect to the current + working directory. + +- [#1277](https://github.com/tigerbeetle/tigerbeetle/pull/1277) + + Interpret a git hash as a VOPR seed, to enable reproducible simulator smoke tests in CI. + +- [#1288](https://github.com/tigerbeetle/tigerbeetle/pull/1288) + + Explicitly target glibc 2.7 when building client libraries, to make sure TigerBeetle clients are + compatible with older distributions. + +## 2023-11-06 + +### Safety And Performance + +- [#1263](https://github.com/tigerbeetle/tigerbeetle/pull/1263) + + Revive the TigerBeetle [VOPRHub](https://github.com/tigerbeetle-vopr)! Some previous changes left + it on it's [Last Stand](https://open.spotify.com/track/1ibHApXtb0pgplmNDRLHrJ), but the bot is + back in business finding liveness bugs: + [#1266](https://github.com/tigerbeetle/tigerbeetle/issues/1266) + +### Features + +- [#1260](https://github.com/tigerbeetle/tigerbeetle/pull/1260) + + Set the latest Docker image to track the latest release. Avoids language clients going out of sync + with your default docker replica installations. + +### Internals + +- [#1261](https://github.com/tigerbeetle/tigerbeetle/pull/1261) + + Move website doc generation for https://docs.tigerbeetle.com/ into the main repo. + +- [#1265](https://github.com/tigerbeetle/tigerbeetle/pull/1265), + [#1243](https://github.com/tigerbeetle/tigerbeetle/pull/1243) + + Addressed some release quirks with the .NET and Go client builds. + +## 2023-10-30 + +### Safety And Performance + +- [#1251](https://github.com/tigerbeetle/tigerbeetle/pull/1251) + + Prove a tighter upper bound for the size of manifest log. With this new bound, manifest log is + guaranteed to fit in allocated memory and is smaller. Additionally, manifest log compaction is + paced depending on the current length of the log, balancing throughput and time-to-recovery. + +- [#1198](https://github.com/tigerbeetle/tigerbeetle/pull/1198) + + Recommend using [ULID](https://github.com/ulid/spec) for event IDs. ULIDs are approximately + sorted, which significantly improves common-case performance. + +### Internals + +- [#1218](https://github.com/tigerbeetle/tigerbeetle/pull/1218) + + Rewrite Node.js client implementation to use the common C client underneath. While clients for + other languages already use the underlying C library, the Node.js client duplicated some code for + historical reasons, but now we can leave that duplication in the past. [This Is A + Photograph](https://www.youtube.com/watch?v=X0i7whWLW8M). + +## 2023-10-25 + +### Safety And Performance + +- [#1240](https://github.com/tigerbeetle/tigerbeetle/pull/1240) + + Increase block size to reduce latencies due to compaction work. Today, we use a simplistic + schedule for compaction, which causes latency spikes at the end of the bar. While the future + solution will implement a smarter compaction pacing to distribute the work more evenly, we can + get a quick win by tweaking the block and the bar size, which naturally evens out latency spikes. + +- [#1246](https://github.com/tigerbeetle/tigerbeetle/pull/1246) + + The new release process changed the names of the published artifacts (the version is no longer + included in the name). This broke our quick start scripts, which we have fixed. Note that we are + in the process of rolling out the new release process, so some unexpected breakage is expected. + +- [#1239](https://github.com/tigerbeetle/tigerbeetle/pull/1239), + [#1243](https://github.com/tigerbeetle/tigerbeetle/pull/1243) + + Speed up secondary index maintenance by statically distinguishing between insertions and + updates. [Faster than the speed of night!](https://open.spotify.com/track/30oZqbcUROFLSru3WcN3bx) + +### Features + +- [#1245](https://github.com/tigerbeetle/tigerbeetle/pull/1245) + + Include Docker images in the release. + +### Internals + +- [#1234](https://github.com/tigerbeetle/tigerbeetle/pull/1234) + + Simplify superblock layout by using a linked list of blocks for manifest log, so that the + superblock needs to store only two block references. + + P.S. Note the PR number! + +## 2023-10-23 + +This is the start of the changelog. A lot happened before this point and is lost in the mist of git +history, but any notable change from this point on shall be captured by this document. + +### Safety And Performance + +- [#1225](https://github.com/tigerbeetle/tigerbeetle/pull/1225) + + Remove bloom filters. TigerBeetle implements more targeted optimizations for + both positive and negative lookups, making bloom filters a net loss. + +### Features + +- [#1228](https://github.com/tigerbeetle/tigerbeetle/pull/1228) + + Increase alignment of data blocks to 128KiB (from 512 bytes). Larger alignment gives operators + better control over physical layout of data on disk. + +### Internals + +- [#1201](https://github.com/tigerbeetle/tigerbeetle/pull/1201), + [#1232](https://github.com/tigerbeetle/tigerbeetle/pull/1232) + + Overhaul of CI and release infrastructure. CI and releases are now driven by Zig code. The main + branch is gated on integration tests for all clients. + + This is done in preparation for the first TigerBeetle release. + +## Prehistory + +For archeological inquiries, check out the state of the repository at the time of the first +changelog: + +[https://github.com/tigerbeetle/tigerbeetle/]( +https://github.com/tigerbeetle/tigerbeetle/tree/d2d6484188ecc57680e8bde446b5d09b6f2d83ca) diff --git a/ocam/LICENSE b/ocam/LICENSE new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/ocam/LICENSE @@ -0,0 +1,177 @@ + + 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 diff --git a/ocam/README.md b/ocam/README.md new file mode 100644 index 00000000..d0289f7d --- /dev/null +++ b/ocam/README.md @@ -0,0 +1,72 @@ +# tigerbeetle + +*TigerBeetle is the financial transactions database designed for mission critical safety and performance to power the next 30 years of [OLTP](https://docs.tigerbeetle.com/concepts/oltp).* + +## Documentation + +* +* [The Primeagen](https://www.youtube.com/watch?v=sC1B3d9C_sI) video introduction to our + design decisions regarding performance, safety, and debit/credit primitives. +* [Redesigning OLTP for a New Order of Magnitude (QCon SF)](https://www.infoq.com/presentations/redesign-oltp/) + talk with a deeper dive into TigerBeetle’s local storage engine and global consensus protocol. +* [TIGER_STYLE.md](./docs/TIGER_STYLE.md), the engineering methodology behind TigerBeetle. + +## Start + +Run a single-replica cluster on Linux (or [other platforms](https://docs.tigerbeetle.com/start/)): + +```console +$ curl -Lo tigerbeetle.zip https://linux.tigerbeetle.com && unzip tigerbeetle.zip +$ ./tigerbeetle version +$ ./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 --development 0_0.tigerbeetle +$ ./tigerbeetle start --addresses=3000 --development 0_0.tigerbeetle +``` + +Connect to the cluster and make a transfer: + +```console +$ ./tigerbeetle repl --cluster=0 --addresses=3000 +> create_accounts id=1 code=10 ledger=700, id=2 code=10 ledger=700; +{ + "timestamp": "1761605367595515148", + "status": "tigerbeetle.CreateAccountStatus.created" +} +{ + "timestamp": "1761605367595515149", + "status": "tigerbeetle.CreateAccountStatus.created" +} +> create_transfers id=1 debit_account_id=1 credit_account_id=2 amount=10 ledger=700 code=10; +{ + "timestamp": "1761605382476666870", + "status": "tigerbeetle.CreateTransferStatus.created" +} +> lookup_accounts id=1, id=2; +{ + "id": "1", + "user_data": "0", + "ledger": "700", + "code": "10", + "flags": "", + "debits_pending": "0", + "debits_posted": "10", + "credits_pending": "0", + "credits_posted": "0" +} +{ + "id": "2", + "user_data": "0", + "ledger": "700", + "code": "10", + "flags": "", + "debits_pending": "0", + "debits_posted": "0", + "credits_pending": "0", + "credits_posted": "10" +} +``` + +Want to learn more? See . + +--- + +If you discover a security vulnerability in TigerBeetle, please send the details to `security@tigerbeetle.com`. diff --git a/ocam/build.zig b/ocam/build.zig new file mode 100644 index 00000000..f2e800d0 --- /dev/null +++ b/ocam/build.zig @@ -0,0 +1,2511 @@ +const std = @import("std"); +const builtin = @import("builtin"); +// NB: Don't import anything from `./src` to keep compile times low. + +const assert = std.debug.assert; +const Query = std.Target.Query; + +const VoprStateMachine = enum { testing, accounting }; +const VoprLog = enum { short, full }; + +// The minimum client version allowed to connect. This has implications for backwards +// compatibility and the upgrade path for replicas and clients. If there's no overlap +// between a replica version and minimum client version - eg, replica 0.15.4 requires +// client 0.15.4 - it means that upgrading requires coordination with clients, which +// will be very inconvenient for operators. +// +// NB: grep for 'TODO(client_release)' after changing! +const release_client_min = "0.16.4"; + +// TigerBeetle binary requires certain CPU feature and supports a closed set of CPUs. Here, we +// specify exactly which features the binary needs. +fn resolve_target(b: *std.Build, target_requested: ?[]const u8) !std.Build.ResolvedTarget { + const target_host = @tagName(builtin.target.cpu.arch) ++ "-" ++ @tagName(builtin.target.os.tag); + const target = target_requested orelse target_host; + const triples = .{ + "aarch64-linux", + "aarch64-macos", + "x86_64-linux", + "x86_64-macos", + "x86_64-windows", + }; + const cpus = .{ + "baseline+aes+neon", + "baseline+aes+neon", + "x86_64_v3+aes", + "x86_64_v3+aes", + "x86_64_v3+aes", + }; + + const arch_os, const cpu = inline for (triples, cpus) |triple, cpu| { + if (std.mem.eql(u8, target, triple)) break .{ triple, cpu }; + } else { + std.log.err("unsupported target: '{s}'", .{target}); + return error.UnsupportedTarget; + }; + const query = try Query.parse(.{ + .arch_os_abi = arch_os, + .cpu_features = cpu, + }); + return b.resolveTargetQuery(query); +} + +const zig_version = std.SemanticVersion{ + .major = 0, + .minor = 14, + .patch = 1, +}; + +comptime { + const zig_version_equal = + zig_version.major == builtin.zig_version.major and + zig_version.minor == builtin.zig_version.minor and + zig_version.patch == builtin.zig_version.patch; + if (!zig_version_equal) { + @compileError(std.fmt.comptimePrint( + "unsupported zig version: expected {}, found {}", + .{ zig_version, builtin.zig_version }, + )); + } +} + +pub fn build(b: *std.Build) !void { + // A compile error stack trace of 10 is arbitrary in size but helps with debugging. + b.reference_trace = 10; + + // Top-level steps you can invoke on the command line. + const build_steps = .{ + .aof = b.step("aof", "Run TigerBeetle AOF Utility"), + .check = b.step("check", "Check if TigerBeetle compiles"), + .clients_c = b.step("clients:c", "Build C client library"), + .clients_c_sample = b.step("clients:c:sample", "Build C client sample"), + .clients_dotnet = b.step("clients:dotnet", "Build dotnet client shared library"), + .clients_rust = b.step("clients:rust", "Build Rust client shared library"), + .clients_go = b.step("clients:go", "Build Go client shared library"), + .clients_java = b.step("clients:java", "Build Java client shared library"), + .clients_node = b.step("clients:node", "Build Node client shared library"), + .clients_python = b.step("clients:python", "Build Python client library"), + .clients_ruby = b.step("clients:ruby", "Build Ruby client library"), + .docs = b.step("docs", "Build docs"), + .fuzz = b.step("fuzz", "Run non-VOPR fuzzers"), + .fuzz_build = b.step("fuzz:build", "Build non-VOPR fuzzers"), + .run = b.step("run", "Run TigerBeetle"), + .ci = b.step("ci", "Run the full suite of CI checks"), + .scripts = b.step("scripts", "Free form automation scripts"), + .scripts_build = b.step("scripts:build", "Build automation scripts"), + .vortex = b.step("vortex", "Full system tests with pluggable client drivers"), + .vortex_build = b.step("vortex:build", "Build the Vortex"), + .vortex_driver_zig_build = b.step("vortex:driver:zig", "Build the Vortex Zig driver"), + .@"test" = b.step("test", "Run all tests"), + .test_fmt = b.step("test:fmt", "Check formatting"), + .test_integration = b.step("test:integration", "Run integration tests"), + .test_integration_build = b.step("test:integration:build", "Build integration tests"), + .test_unit = b.step("test:unit", "Run unit tests"), + .test_unit_build = b.step("test:unit:build", "Build unit tests"), + .test_jni = b.step("test:jni", "Run Java JNI tests"), + .vopr = b.step("vopr", "Run the VOPR"), + .vopr_build = b.step("vopr:build", "Build the VOPR"), + }; + + const mode = b.standardOptimizeOption(.{ .preferred_optimize_mode = .ReleaseSafe }); + + // Build options passed with `-D` flags. + const build_options = .{ + .target = b.option([]const u8, "target", "The CPU architecture and OS to build for"), + .multiversion = b.option( + []const u8, + "multiversion", + "Past version to include for upgrades (\"latest\" or \"x.y.z\")", + ), + .multiversion_file = b.option( + []const u8, + "multiversion-file", + "Past version to include for upgrades (local binary file)", + ), + .config_verify = b.option(bool, "config_verify", "Enable extra assertions.") orelse + // If `config_verify` isn't set, disable it for `release` builds; otherwise, enable it. + (mode == .Debug), + .config_release = b.option([]const u8, "config-release", "Release triple."), + .config_release_client_min = b.option( + []const u8, + "config-release-client-min", + "Minimum client release triple.", + ), + .emit_llvm_ir = b.option(bool, "emit-llvm-ir", "Emit LLVM IR (.ll file)") orelse false, + // The "tigerbeetle version" command includes the build-time commit hash. + .git_commit = b.option( + []const u8, + "git-commit", + "The git commit revision of the source code.", + ) orelse std.mem.trimRight(u8, b.run(&.{ "git", "rev-parse", "--verify", "HEAD" }), "\n"), + .vopr_state_machine = b.option( + VoprStateMachine, + "vopr-state-machine", + "State machine.", + ) orelse .accounting, + .vopr_log = b.option( + VoprLog, + "vopr-log", + "Log only state transitions (short) or everything (full).", + ) orelse .short, + .llvm_objcopy = b.option( + []const u8, + "llvm-objcopy", + "Use this llvm-objcopy instead of downloading one", + ), + .print_exe = b.option( + bool, + "print-exe", + "Build tasks print the path of the executable", + ) orelse false, + }; + + if (build_options.config_release == null and build_options.config_release_client_min != null) { + @panic("must set config-release if setting config-release-client-min"); + } + if (build_options.config_release_client_min == null and build_options.config_release != null) { + @panic("must set config-release-client-min if setting config-release"); + } + assert((build_options.config_release == null) == + (build_options.config_release_client_min == null)); + + const target = try resolve_target(b, build_options.target); + + const test_options = b.addOptions(); + // Benchmark run in two modes. + // - ./zig/zig build test + // - ./zig/zig build -Drelease test -- "benchmark: name" + // The former uses small parameter values and is silent. + // The latter is the real benchmark, which prints the output. + test_options.addOption(bool, "benchmark", for (b.args orelse &.{}) |arg| { + if (std.mem.indexOf(u8, arg, "benchmark") != null) break true; + } else false); + + const stdx_module = b.addModule("stdx", .{ .root_source_file = b.path("src/stdx/stdx.zig") }); + stdx_module.addOptions("test_options", test_options); + + assert(build_options.git_commit.len == 40); + const vsr_options, const vsr_module = build_vsr_module(b, .{ + .stdx_module = stdx_module, + .git_commit = build_options.git_commit[0..40].*, + .config_verify = build_options.config_verify, + .config_release = build_options.config_release orelse "65535.0.0", + .config_release_client_min = build_options.config_release_client_min orelse + release_client_min, + }); + + // For integration tests and vortex, we build an independent copy of TigerBeetle with "real" + // config and multiversioning. + const vsr_options_test, const vsr_module_test = build_vsr_module(b, .{ + .stdx_module = stdx_module, + .git_commit = "bee71e0000000000000000000000000000bee71e".*, // Beetle-hash! + .config_verify = true, + .config_release = "65535.0.0", + .config_release_client_min = release_client_min, + }); + + // 65535.0.0 + 1, to test both sides of upgrades. + const vsr_options_next_test, const vsr_module_next_test = build_vsr_module(b, .{ + .stdx_module = stdx_module, + .git_commit = "bee71e0000000000000000000000000000bee71e".*, // Beetle-hash! + .config_verify = true, + .config_release = "65535.0.1", + .config_release_client_min = release_client_min, + }); + + var releases_previous = release_history(b); + const tigerbeetle_test_previous = fetch_release(b, releases_previous.next().?, target, mode); + const tigerbeetle_test = build_tigerbeetle_executable_multiversion(b, .{ + .stdx_module = stdx_module, + .vsr_module = vsr_module_test, + .vsr_options = vsr_options_test, + .llvm_objcopy = build_options.llvm_objcopy, + .tigerbeetle_previous = tigerbeetle_test_previous, + .target = target, + .mode = mode, + }); + + const tigerbeetle_next_test = build_tigerbeetle_executable_multiversion(b, .{ + .stdx_module = stdx_module, + .vsr_module = vsr_module_next_test, + .vsr_options = vsr_options_next_test, + .llvm_objcopy = build_options.llvm_objcopy, + .tigerbeetle_previous = tigerbeetle_test, + .target = target, + .mode = mode, + }); + + const tb_client = build_tb_client(b, .{ + .vsr_module = vsr_module, + .vsr_options = vsr_options, + .mode = mode, + }); + + // zig build check + build_check(b, build_steps.check, .{ + .stdx_module = stdx_module, + .vsr_module = vsr_module, + .target = target, + .mode = mode, + }); + + // zig build, zig build run + build_tigerbeetle(b, .{ + .run = build_steps.run, + .install = b.getInstallStep(), + }, .{ + .stdx_module = stdx_module, + .vsr_module = vsr_module, + .vsr_options = vsr_options, + .llvm_objcopy = build_options.llvm_objcopy, + .target = target, + .mode = mode, + .emit_llvm_ir = build_options.emit_llvm_ir, + .multiversion = build_options.multiversion, + .multiversion_file = build_options.multiversion_file, + }); + + // zig build aof + build_aof(b, build_steps.aof, .{ + .stdx_module = stdx_module, + .vsr_options = vsr_options, + .target = target, + .mode = mode, + }); + + // zig build vortex:drivers:zig + const vortex_driver_zig = build_vortex_driver_zig(b, .{ + .vortex_driver_zig_build = build_steps.vortex_driver_zig_build, + }, .{ + .stdx_module = stdx_module, + .tb_client_header = tb_client.header, + .vsr_module = vsr_module, + .vsr_options = vsr_options, + .target = target, + .mode = mode, + .print_exe = build_options.print_exe, + }); + + const vortex_options = build_vortex_options(b, .{ + .target = target, + .mode = mode, + .tigerbeetle_test = tigerbeetle_test, + .tigerbeetle_next_test = tigerbeetle_next_test, + .vortex_driver_zig = vortex_driver_zig, + }); + + // zig build test -- "test filter" + try build_test(b, .{ + .test_unit = build_steps.test_unit, + .test_unit_build = build_steps.test_unit_build, + .test_integration = build_steps.test_integration, + .test_integration_build = build_steps.test_integration_build, + .test_fmt = build_steps.test_fmt, + .@"test" = build_steps.@"test", + }, .{ + .stdx_module = stdx_module, + .llvm_objcopy = build_options.llvm_objcopy, + .tb_client_header = tb_client.header, + .target = target, + .mode = mode, + .vsr_module_test = vsr_module_test, + .vsr_options_test = vsr_options_test, + .tigerbeetle_test = tigerbeetle_test, + .vortex_options = vortex_options, + .test_options = test_options, + }); + + // zig build test:jni + try build_test_jni(b, build_steps.test_jni, .{ + .target = target, + .mode = mode, + }); + + // zig build vopr -- 42 + build_vopr(b, .{ + .vopr_build = build_steps.vopr_build, + .vopr_run = build_steps.vopr, + }, .{ + .stdx_module = stdx_module, + .vsr_options_test = vsr_options_test, + .target = target, + .mode = mode, + .print_exe = build_options.print_exe, + .vopr_state_machine = build_options.vopr_state_machine, + .vopr_log = build_options.vopr_log, + }); + + // zig build fuzz -- --events-max=100 lsm_tree 123 + build_fuzz(b, .{ + .fuzz = build_steps.fuzz, + .fuzz_build = build_steps.fuzz_build, + }, .{ + .stdx_module = stdx_module, + .vsr_options_test = vsr_options_test, + .target = target, + .mode = mode, + .print_exe = build_options.print_exe, + }); + + // zig build scripts -- ci --language=java + const scripts = build_scripts(b, .{ + .scripts = build_steps.scripts, + .scripts_build = build_steps.scripts_build, + }, .{ + .stdx_module = stdx_module, + .vsr_options = vsr_options, + .target = target, + }); + + // zig build vortex -- --replica-count=3 --test-duration=1m + build_vortex(b, .{ + .vortex_build = build_steps.vortex_build, + .vortex_run = build_steps.vortex, + }, .{ + .target = target, + .mode = mode, + .stdx_module = stdx_module, + .vsr_module_test = vsr_module_test, + .vsr_options_test = vsr_options_test, + .vortex_options = vortex_options, + .print_exe = build_options.print_exe, + }); + + // zig build clients:$lang + build_rust_client(b, build_steps.clients_rust, .{ + .vsr_module = vsr_module, + .vsr_options = vsr_options, + .tb_client_header = tb_client.header, + .mode = mode, + }); + build_go_client(b, build_steps.clients_go, .{ + .vsr_module = vsr_module, + .vsr_options = vsr_options, + .tb_client_header = tb_client.header, + .mode = mode, + }); + build_java_client(b, build_steps.clients_java, .{ + .vsr_module = vsr_module, + .vsr_options = vsr_options, + .mode = mode, + }); + build_dotnet_client(b, build_steps.clients_dotnet, .{ + .vsr_module = vsr_module, + .vsr_options = vsr_options, + .tb_client = tb_client, + .mode = mode, + }); + build_node_client(b, build_steps.clients_node, .{ + .vsr_module = vsr_module, + .vsr_options = vsr_options, + .mode = mode, + }); + build_python_client(b, build_steps.clients_python, .{ + .vsr_module = vsr_module, + .vsr_options = vsr_options, + .tb_client = tb_client, + .mode = mode, + }); + build_ruby_client(b, build_steps.clients_ruby, .{ + .vsr_module = vsr_module, + .vsr_options = vsr_options, + .tb_client_header = tb_client.header, + .tb_client = tb_client, + .mode = mode, + }); + build_c_client(b, build_steps.clients_c, .{ + .vsr_module = vsr_module, + .vsr_options = vsr_options, + .tb_client_header = tb_client.header, + .mode = mode, + }); + + // zig build clients:c:sample + build_clients_c_sample(b, build_steps.clients_c_sample, .{ + .vsr_module = vsr_module, + .vsr_options = vsr_options, + .target = target, + .mode = mode, + }); + + // zig build docs + build_steps.docs.dependOn(blk: { + const nested_build = b.addSystemCommand(&.{ b.graph.zig_exe, "build" }); + nested_build.setCwd(b.path("./src/docs_website/")); + break :blk &nested_build.step; + }); + + // zig build ci + build_ci(b, build_steps.ci, .{ + .scripts = scripts, + .git_commit = build_options.git_commit, + }); +} + +fn build_vsr_module(b: *std.Build, options: struct { + stdx_module: *std.Build.Module, + git_commit: [40]u8, + config_verify: bool, + config_release: []const u8, + config_release_client_min: []const u8, +}) struct { *std.Build.Step.Options, *std.Build.Module } { + // Ideally, we would return _just_ the module here, and keep options an implementation detail. + // However, currently Zig makes it awkward to provide multiple entry points for a module: + // https://ziggit.dev/t/suggested-project-layout-for-multiple-entry-point-for-zig-0-12/4219 + // + // For this reason, we have to return options as well, so that other entry points can + // essentially re-create identical module. + const vsr_options = b.addOptions(); + vsr_options.addOption(?[40]u8, "git_commit", options.git_commit[0..40].*); + vsr_options.addOption(bool, "config_verify", options.config_verify); + vsr_options.addOption([]const u8, "release", options.config_release); + vsr_options.addOption([]const u8, "release_client_min", options.config_release_client_min); + + const vsr_module = b.createModule(.{ + .root_source_file = b.path("src/vsr.zig"), + }); + vsr_module.addImport("stdx", options.stdx_module); + vsr_module.addOptions("vsr_options", vsr_options); + + return .{ vsr_options, vsr_module }; +} + +/// This is what is called by CI infrastructure, but you can also use it locally. In particular, +/// +/// ./zig/zig build ci +/// +/// is useful to run locally to get a set of somewhat comprehensive checks without needing many +/// external dependencies. +/// +/// Various CI machines pass filters to select a subset of checks: +/// +/// ./zig/zig build ci -- all +fn build_ci( + b: *std.Build, + step_ci: *std.Build.Step, + options: struct { + scripts: *std.Build.Step.Compile, + git_commit: []const u8, + }, +) void { + const CIMode = enum { + smoke, // Quickly check formatting and such. + @"test", // Main test suite + VOPR + fuzzers, excluding clients. + aof, // Dedicated test for AOF, which is somewhat slow to run. + + clients, // Tests for all language clients below. + dotnet, + go, + rust, + java, + node, + python, + ruby, + + devhub, // Things that run on known-good commit on main branch after merge. + @"devhub-dry-run", + amqp, + default, // smoke + test + building Zig parts of clients. + all, + }; + + const mode: CIMode = if (b.args) |args| mode: { + if (args.len != 1) { + step_ci.dependOn(&b.addFail("invalid CIMode").step); + return; + } + if (std.meta.stringToEnum(CIMode, args[0])) |m| { + break :mode m; + } else { + step_ci.dependOn(&b.addFail("invalid CIMode").step); + return; + } + } else .default; + + const all = mode == .all; + const default = all or mode == .default; + + if (default or mode == .smoke) { + build_ci_step(b, step_ci, .{"test:fmt"}, .{}); + build_ci_step(b, step_ci, .{"check"}, .{}); + + const build_docs = b.addSystemCommand(&.{ b.graph.zig_exe, "build" }); + build_docs.has_side_effects = true; + build_docs.cwd = b.path("./src/docs_website"); + step_ci.dependOn(&build_docs.step); + } + if (default or mode == .@"test") { + build_ci_step(b, step_ci, .{"test"}, .{ .max_rss = 4 * GiB }); + build_ci_step(b, step_ci, .{"clients:c:sample"}, .{}); + build_ci_script(b, step_ci, options.scripts, &.{"--help"}); + + build_ci_step(b, step_ci, .{ "fuzz", "--", "smoke" }, .{ .max_rss = 3 * GiB }); + inline for (.{ "testing", "accounting" }) |state_machine| { + build_ci_step(b, step_ci, .{ + "vopr", + "-Dvopr-state-machine=" ++ state_machine, + "-Drelease", + "--", + options.git_commit, + }, .{ .max_rss = 3 * GiB }); + } + } + if (default or mode == .amqp) { + // Smoke test the AMQP integration. + build_ci_script(b, step_ci, options.scripts, &.{ + "amqp", + "--transfer-count=100", + }); + } + + if (all or mode == .aof) { + const aof = b.addSystemCommand(&.{"./.github/ci/test_aof.sh"}); + hide_stderr(aof); + step_ci.dependOn(&aof.step); + } + inline for (&.{ CIMode.dotnet, .go, .rust, .java, .node, .python, .ruby }) |language| { + if (default or mode == .clients or mode == language) { + // Client tests expect vortex to exist. + build_ci_step(b, step_ci, .{"vortex:build"}, .{}); + build_ci_step(b, step_ci, .{"clients:" ++ @tagName(language)}, .{}); + } + if (all or mode == .clients or mode == language) { + build_ci_script(b, step_ci, options.scripts, &.{ + "ci", + "--language=" ++ @tagName(language), + }); + } + } + + if (all or mode == .@"devhub-dry-run") { + build_ci_script(b, step_ci, options.scripts, &.{ + "devhub", + b.fmt("--sha={s}", .{options.git_commit}), + "--skip-kcov", + }); + } + if (mode == .devhub) { + build_ci_script(b, step_ci, options.scripts, &.{ + "devhub", + b.fmt("--sha={s}", .{options.git_commit}), + }); + } +} + +fn build_ci_step( + b: *std.Build, + step_ci: *std.Build.Step, + command: anytype, + options: struct { max_rss: u64 = 0 }, +) void { + const argv = .{ b.graph.zig_exe, "build" } ++ command; + const system_command = b.addSystemCommand(&argv); + const name = std.mem.join(b.allocator, " ", &command) catch @panic("OOM"); + system_command.max_stdio_size = 128 * MiB; // Prevent error.StreamTooLong. + system_command.setName(name); + system_command.step.max_rss = options.max_rss; + hide_stderr(system_command); + step_ci.dependOn(&system_command.step); +} + +fn build_ci_script( + b: *std.Build, + step_ci: *std.Build.Step, + scripts: *std.Build.Step.Compile, + argv: []const []const u8, +) void { + const run_artifact = b.addRunArtifact(scripts); + run_artifact.addArgs(argv); + run_artifact.setEnvironmentVariable("ZIG_EXE", b.graph.zig_exe); + run_artifact.max_stdio_size = 128 * MiB; // Prevent error.StreamTooLong. + hide_stderr(run_artifact); + step_ci.dependOn(&run_artifact.step); +} + +// Hide step's stderr unless it fails, to prevent zig build ci output being dominated by VOPR logs. +// Sadly, this requires "overriding" Build.Step.Run make function. +fn hide_stderr(run: *std.Build.Step.Run) void { + const b = run.step.owner; + + run.addCheck(.{ .expect_term = .{ .Exited = 0 } }); + run.has_side_effects = true; + + const override = struct { + var global_map: std.AutoHashMapUnmanaged(usize, std.Build.Step.MakeFn) = .{}; + + fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) anyerror!void { + const original = global_map.get(@intFromPtr(step)).?; + try original(step, options); + assert(step.result_error_msgs.items.len == 0); + step.result_stderr = ""; + } + }; + + const original = run.step.makeFn; + override.global_map.put(b.allocator, @intFromPtr(&run.step), original) catch @panic("OOM"); + run.step.makeFn = &override.make; +} + +// Run a tigerbeetle build without running codegen and waiting for llvm +// see +// how it's supposed to work. +// In short, codegen only runs if zig build sees a dependency on the binary output of +// the step. So we duplicate the build definition so that it doesn't get polluted by +// b.installArtifact. +// TODO(zig): https://github.com/ziglang/zig/issues/18877 +fn build_check( + b: *std.Build, + step_check: *std.Build.Step, + options: struct { + stdx_module: *std.Build.Module, + vsr_module: *std.Build.Module, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + }, +) void { + const tigerbeetle = b.addExecutable(.{ + .name = "tigerbeetle", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/tigerbeetle/main.zig"), + .target = options.target, + .optimize = options.mode, + }), + }); + tigerbeetle.root_module.addImport("stdx", options.stdx_module); + tigerbeetle.root_module.addImport("vsr", options.vsr_module); + step_check.dependOn(&tigerbeetle.step); +} + +fn build_tigerbeetle( + b: *std.Build, + steps: struct { + run: *std.Build.Step, + install: *std.Build.Step, + }, + options: struct { + stdx_module: *std.Build.Module, + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + llvm_objcopy: ?[]const u8, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + multiversion: ?[]const u8, + multiversion_file: ?[]const u8, + emit_llvm_ir: bool, + }, +) void { + const multiversion_file: ?std.Build.LazyPath = if (options.multiversion_file) |path| + .{ .cwd_relative = path } + else if (options.multiversion) |version_past| + fetch_release(b, version_past, options.target, options.mode) + else + null; + + const tigerbeetle_bin = if (multiversion_file) |multiversion_lazy_path| bin: { + assert(!options.emit_llvm_ir); + break :bin build_tigerbeetle_executable_multiversion(b, .{ + .stdx_module = options.stdx_module, + .vsr_module = options.vsr_module, + .vsr_options = options.vsr_options, + .llvm_objcopy = options.llvm_objcopy, + .tigerbeetle_previous = multiversion_lazy_path, + .target = options.target, + .mode = options.mode, + }); + } else bin: { + const tigerbeetle_exe = build_tigerbeetle_executable(b, .{ + .vsr_module = options.vsr_module, + .vsr_options = options.vsr_options, + .target = options.target, + .mode = options.mode, + }); + if (options.emit_llvm_ir) { + steps.install.dependOn(&b.addInstallBinFile( + tigerbeetle_exe.getEmittedLlvmIr(), + "tigerbeetle.ll", + ).step); + } + break :bin tigerbeetle_exe.getEmittedBin(); + }; + + const out_filename = if (options.target.result.os.tag == .windows) + "tigerbeetle.exe" + else + "tigerbeetle"; + + steps.install.dependOn(&b.addInstallBinFile(tigerbeetle_bin, out_filename).step); + // "zig build install" moves the server executable to the root folder: + steps.install.dependOn(&b.addInstallFile( + tigerbeetle_bin, + b.pathJoin(&.{ "../", out_filename }), + ).step); + + const run_cmd = std.Build.Step.Run.create(b, b.fmt("run tigerbeetle", .{})); + run_cmd.addFileArg(tigerbeetle_bin); + if (b.args) |args| run_cmd.addArgs(args); + steps.run.dependOn(&run_cmd.step); +} + +fn build_tigerbeetle_executable(b: *std.Build, options: struct { + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, +}) *std.Build.Step.Compile { + const root_module = b.createModule(.{ + .root_source_file = b.path("src/tigerbeetle/main.zig"), + .target = options.target, + .optimize = options.mode, + }); + root_module.addImport("vsr", options.vsr_module); + root_module.addOptions("vsr_options", options.vsr_options); + if (options.mode == .ReleaseSafe) strip_root_module(root_module); + + const tigerbeetle = b.addExecutable(.{ + .name = "tigerbeetle", + .root_module = root_module, + }); + + return tigerbeetle; +} + +fn build_tigerbeetle_executable_multiversion(b: *std.Build, options: struct { + stdx_module: *std.Build.Module, + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + llvm_objcopy: ?[]const u8, + tigerbeetle_previous: std.Build.LazyPath, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, +}) std.Build.LazyPath { + // build_multiversion a custom step that would take care of packing several releases into one + const build_multiversion_exe = b.addExecutable(.{ + .name = "build_multiversion", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/build_multiversion.zig"), + // Enable aes extensions for vsr.checksum on the host. + .target = resolve_target(b, null) catch @panic("unsupported host"), + }), + }); + build_multiversion_exe.root_module.addImport("stdx", options.stdx_module); + // Ideally, we should pass `vsr_options` here at runtime. Making them comptime + // parameters is inelegant, but practical! + build_multiversion_exe.root_module.addOptions("vsr_options", options.vsr_options); + + const build_multiversion = b.addRunArtifact(build_multiversion_exe); + if (options.llvm_objcopy) |path| { + build_multiversion.addArg(b.fmt("--llvm-objcopy={s}", .{path})); + } else { + build_multiversion.addPrefixedFileArg("--llvm-objcopy=", fetch_objcopy(b)); + } + if (options.target.result.os.tag == .macos) { + build_multiversion.addArg("--target=macos"); + inline for (.{ "x86_64", "aarch64" }, .{ "x86-64", "aarch64" }) |arch, flag| { + build_multiversion.addPrefixedFileArg( + "--tigerbeetle-current-" ++ flag ++ "=", + build_tigerbeetle_executable(b, .{ + .vsr_module = options.vsr_module, + .vsr_options = options.vsr_options, + .target = resolve_target(b, arch ++ "-macos") catch unreachable, + .mode = options.mode, + }).getEmittedBin(), + ); + } + } else { + build_multiversion.addArg(b.fmt("--target={s}-{s}", .{ + @tagName(options.target.result.cpu.arch), + @tagName(options.target.result.os.tag), + })); + build_multiversion.addPrefixedFileArg( + "--tigerbeetle-current=", + build_tigerbeetle_executable(b, .{ + .vsr_module = options.vsr_module, + .vsr_options = options.vsr_options, + .target = options.target, + .mode = options.mode, + }).getEmittedBin(), + ); + } + + if (options.mode == .Debug) { + build_multiversion.addArg("--debug"); + } + + build_multiversion.addPrefixedFileArg("--tigerbeetle-past=", options.tigerbeetle_previous); + build_multiversion.addArg(b.fmt( + "--tmp={s}", + .{b.cache_root.join(b.allocator, &.{"tmp"}) catch @panic("OOM")}, + )); + const basename = if (options.target.result.os.tag == .windows) + "tigerbeetle.exe" + else + "tigerbeetle"; + return build_multiversion.addPrefixedOutputFileArg("--output=", basename); +} + +fn build_aof( + b: *std.Build, + step_aof: *std.Build.Step, + options: struct { + stdx_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + }, +) void { + const aof = b.addExecutable(.{ + .name = "aof", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/aof.zig"), + .target = options.target, + .optimize = options.mode, + }), + }); + aof.root_module.addImport("stdx", options.stdx_module); + aof.root_module.addOptions("vsr_options", options.vsr_options); + const run_cmd = b.addRunArtifact(aof); + if (b.args) |args| run_cmd.addArgs(args); + step_aof.dependOn(&run_cmd.step); +} + +fn build_test( + b: *std.Build, + steps: struct { + test_unit: *std.Build.Step, + test_unit_build: *std.Build.Step, + test_integration: *std.Build.Step, + test_integration_build: *std.Build.Step, + test_fmt: *std.Build.Step, + @"test": *std.Build.Step, + }, + options: struct { + llvm_objcopy: ?[]const u8, + stdx_module: *std.Build.Module, + tb_client_header: std.Build.LazyPath, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + vsr_module_test: *std.Build.Module, + vsr_options_test: *std.Build.Step.Options, + tigerbeetle_test: std.Build.LazyPath, + vortex_options: *std.Build.Step.Options, + test_options: *std.Build.Step.Options, + }, +) !void { + const stdx_unit_tests = b.addTest(.{ + .name = "test-stdx", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/stdx/stdx.zig"), + .target = options.target, + .optimize = options.mode, + }), + .filters = b.args orelse &.{}, + }); + stdx_unit_tests.root_module.addOptions("test_options", options.test_options); + + const unit_tests = b.addTest(.{ + .name = "test-unit", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/unit_tests.zig"), + .target = options.target, + .optimize = options.mode, + }), + .filters = b.args orelse &.{}, + }); + unit_tests.root_module.addImport("stdx", options.stdx_module); + unit_tests.root_module.addOptions("vsr_options", options.vsr_options_test); + unit_tests.root_module.addOptions("test_options", options.test_options); + + steps.test_unit_build.dependOn(&b.addInstallArtifact(stdx_unit_tests, .{}).step); + steps.test_unit_build.dependOn(&b.addInstallArtifact(unit_tests, .{}).step); + + const run_stdx_unit_tests = b.addRunArtifact(stdx_unit_tests); + const run_unit_tests = b.addRunArtifact(unit_tests); + run_stdx_unit_tests.setEnvironmentVariable("ZIG_EXE", b.graph.zig_exe); + run_unit_tests.setEnvironmentVariable("ZIG_EXE", b.graph.zig_exe); + if (b.args != null) { // Don't cache test results if running a specific test. + run_stdx_unit_tests.has_side_effects = true; + run_unit_tests.has_side_effects = true; + } + steps.test_unit.dependOn(&run_stdx_unit_tests.step); + steps.test_unit.dependOn(&run_unit_tests.step); + + run_unit_tests.setCwd(b.path(".")); + + build_test_integration(b, .{ + .test_integration = steps.test_integration, + .test_integration_build = steps.test_integration_build, + }, .{ + .tb_client_header = options.tb_client_header, + .llvm_objcopy = options.llvm_objcopy, + .stdx_module = options.stdx_module, + .target = options.target, + .mode = options.mode, + .vsr_module_test = options.vsr_module_test, + .vsr_options_test = options.vsr_options_test, + .tigerbeetle_test = options.tigerbeetle_test, + .vortex_options = options.vortex_options, + }); + + const run_fmt = b.addFmt(.{ .paths = &.{"."}, .check = true }); + steps.test_fmt.dependOn(&run_fmt.step); + + steps.@"test".dependOn(&run_stdx_unit_tests.step); + steps.@"test".dependOn(&run_unit_tests.step); + if (b.args == null) { + steps.@"test".dependOn(steps.test_integration); + steps.@"test".dependOn(steps.test_fmt); + } +} + +fn build_test_integration( + b: *std.Build, + steps: struct { + test_integration: *std.Build.Step, + test_integration_build: *std.Build.Step, + }, + options: struct { + tb_client_header: std.Build.LazyPath, + llvm_objcopy: ?[]const u8, + stdx_module: *std.Build.Module, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + vsr_module_test: *std.Build.Module, + vsr_options_test: *std.Build.Step.Options, + tigerbeetle_test: std.Build.LazyPath, + vortex_options: *std.Build.Step.Options, + }, +) void { + const vortex = build_vortex_executable(b, .{ + .stdx_module = options.stdx_module, + .target = options.target, + .mode = options.mode, + .vsr_module_test = options.vsr_module_test, + .vsr_options_test = options.vsr_options_test, + .vortex_options = options.vortex_options, + }); + const vortex_artifact = b.addInstallArtifact(vortex, .{}); + + const integration_tests_options = b.addOptions(); + integration_tests_options.addOptionPath("tigerbeetle_exe", options.tigerbeetle_test); + integration_tests_options.addOptionPath("vortex_exe", vortex_artifact.emitted_bin.?); + const integration_tests = b.addTest(.{ + .name = "test-integration", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/integration_tests.zig"), + .target = options.target, + .optimize = options.mode, + }), + .filters = b.args orelse &.{}, + }); + integration_tests.root_module.addImport("stdx", options.stdx_module); + integration_tests.root_module.addOptions("vsr_options", options.vsr_options_test); + integration_tests.root_module.addOptions("test_options", integration_tests_options); + integration_tests.root_module.addOptions("vortex_options", options.vortex_options); + integration_tests.addIncludePath(options.tb_client_header.dirname()); + steps.test_integration_build.dependOn(&b.addInstallArtifact(integration_tests, .{}).step); + + const run_integration_tests = b.addRunArtifact(integration_tests); + if (b.args != null) { // Don't cache test results if running a specific test. + run_integration_tests.has_side_effects = true; + } + run_integration_tests.has_side_effects = true; + steps.test_integration.dependOn(&run_integration_tests.step); +} + +fn build_test_jni( + b: *std.Build, + step_test_jni: *std.Build.Step, + options: struct { + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + }, +) !void { + const java_home = b.graph.env_map.get("JAVA_HOME") orelse { + step_test_jni.dependOn(&b.addFail( + "can't build jni tests tests, JAVA_HOME is not set", + ).step); + return; + }; + + // JNI test require JVM to be present, and are _not_ run as a part of `zig build test`. + // We need libjvm.so both at build time and at a runtime, so use `FailStep` when that is not + // available. + const libjvm_path = b.pathJoin(&.{ + java_home, + if (builtin.os.tag == .windows) "/lib" else "/lib/server", + }); + + const tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/clients/java/src/jni_tests.zig"), + .target = options.target, + // TODO(zig): The function `JNI_CreateJavaVM` tries to detect + // the stack size and causes a SEGV that is handled by Zig's panic handler. + // https://bugzilla.redhat.com/show_bug.cgi?id=1572811#c7 + // + // The workaround is to run the tests in "ReleaseFast" mode. + .optimize = if (builtin.os.tag == .windows) .ReleaseFast else options.mode, + }), + }); + tests.linkLibC(); + + tests.linkSystemLibrary("jvm"); + tests.addLibraryPath(.{ .cwd_relative = libjvm_path }); + if (builtin.os.tag == .linux) { + // On Linux, detects the abi by calling `ldd` to check if + // the libjvm.so is linked against libc or musl. + // It's reasonable to assume that ldd will be present. + var exit_code: u8 = undefined; + const stderr_behavior = .Ignore; + const ldd_result = try b.runAllowFail( + &.{ "ldd", b.pathJoin(&.{ libjvm_path, "libjvm.so" }) }, + &exit_code, + stderr_behavior, + ); + + if (std.mem.indexOf(u8, ldd_result, "musl") != null) { + tests.root_module.resolved_target.?.query.abi = .musl; + tests.root_module.resolved_target.?.result.abi = .musl; + } else if (std.mem.indexOf(u8, ldd_result, "libc") != null) { + tests.root_module.resolved_target.?.query.abi = .gnu; + tests.root_module.resolved_target.?.result.abi = .gnu; + } else { + std.log.err("{s}", .{ldd_result}); + return error.JavaAbiUnrecognized; + } + } + + switch (builtin.os.tag) { + .windows => set_windows_dll(b.allocator, java_home), + .macos => try b.graph.env_map.put("DYLD_LIBRARY_PATH", libjvm_path), + .linux => try b.graph.env_map.put("LD_LIBRARY_PATH", libjvm_path), + else => unreachable, + } + + step_test_jni.dependOn(&b.addRunArtifact(tests).step); +} + +fn build_vopr( + b: *std.Build, + steps: struct { + vopr_build: *std.Build.Step, + vopr_run: *std.Build.Step, + }, + options: struct { + stdx_module: *std.Build.Module, + vsr_options_test: *std.Build.Step.Options, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + print_exe: bool, + vopr_state_machine: VoprStateMachine, + vopr_log: VoprLog, + }, +) void { + const vopr_options = b.addOptions(); + + vopr_options.addOption(VoprStateMachine, "state_machine", options.vopr_state_machine); + vopr_options.addOption(VoprLog, "log", options.vopr_log); + + const vopr = b.addExecutable(.{ + .name = "vopr", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/vopr.zig"), + .target = options.target, + // When running without a SEED, default to release. + .optimize = if (b.args == null) .ReleaseSafe else options.mode, + }), + }); + vopr.stack_size = 4 * MiB; + vopr.root_module.addImport("stdx", options.stdx_module); + vopr.root_module.addOptions("vsr_options", options.vsr_options_test); + vopr.root_module.addOptions("vsr_vopr_options", vopr_options); + // Ensure that we get stack traces even in release builds. + vopr.root_module.omit_frame_pointer = false; + steps.vopr_build.dependOn(print_or_install(b, vopr, options.print_exe)); + + const run_cmd = b.addRunArtifact(vopr); + if (b.args) |args| run_cmd.addArgs(args); + steps.vopr_run.dependOn(&run_cmd.step); +} + +fn build_fuzz( + b: *std.Build, + steps: struct { + fuzz: *std.Build.Step, + fuzz_build: *std.Build.Step, + }, + options: struct { + stdx_module: *std.Build.Module, + vsr_options_test: *std.Build.Step.Options, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + print_exe: bool, + }, +) void { + const fuzz_exe = b.addExecutable(.{ + .name = "fuzz", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/fuzz_tests.zig"), + .target = options.target, + .optimize = options.mode, + }), + }); + fuzz_exe.stack_size = 4 * MiB; + fuzz_exe.root_module.addImport("stdx", options.stdx_module); + fuzz_exe.root_module.addOptions("vsr_options", options.vsr_options_test); + fuzz_exe.root_module.omit_frame_pointer = false; + steps.fuzz_build.dependOn(print_or_install(b, fuzz_exe, options.print_exe)); + + const fuzz_run = b.addRunArtifact(fuzz_exe); + if (b.args) |args| fuzz_run.addArgs(args); + steps.fuzz.dependOn(&fuzz_run.step); +} + +fn build_scripts( + b: *std.Build, + steps: struct { + scripts: *std.Build.Step, + scripts_build: *std.Build.Step, + }, + options: struct { + stdx_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + target: std.Build.ResolvedTarget, + }, +) *std.Build.Step.Compile { + const scripts_exe = b.addExecutable(.{ + .name = "scripts", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/scripts.zig"), + .target = options.target, + .optimize = .Debug, + }), + }); + scripts_exe.root_module.addImport("stdx", options.stdx_module); + scripts_exe.root_module.addOptions("vsr_options", options.vsr_options); + steps.scripts_build.dependOn( + &b.addInstallArtifact(scripts_exe, .{}).step, + ); + + const scripts_run = b.addRunArtifact(scripts_exe); + scripts_run.setEnvironmentVariable("ZIG_EXE", b.graph.zig_exe); + if (b.args) |args| scripts_run.addArgs(args); + steps.scripts.dependOn(&scripts_run.step); + + return scripts_exe; +} + +fn build_vortex( + b: *std.Build, + steps: struct { + vortex_build: *std.Build.Step, + vortex_run: *std.Build.Step, + }, + options: struct { + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + stdx_module: *std.Build.Module, + vsr_module_test: *std.Build.Module, + vsr_options_test: *std.Build.Step.Options, + vortex_options: *std.Build.Step.Options, + print_exe: bool, + }, +) void { + const vortex = build_vortex_executable(b, .{ + .target = options.target, + .mode = options.mode, + .stdx_module = options.stdx_module, + .vsr_module_test = options.vsr_module_test, + .vsr_options_test = options.vsr_options_test, + .vortex_options = options.vortex_options, + }); + + const install_step = print_or_install(b, vortex, options.print_exe); + steps.vortex_build.dependOn(install_step); + + const run_cmd = b.addRunArtifact(vortex); + if (b.args) |args| run_cmd.addArgs(args); + steps.vortex_run.dependOn(&run_cmd.step); +} + +fn build_vortex_executable( + b: *std.Build, + options: struct { + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + stdx_module: *std.Build.Module, + vsr_module_test: *std.Build.Module, + vsr_options_test: *std.Build.Step.Options, + vortex_options: *std.Build.Step.Options, + }, +) *std.Build.Step.Compile { + const vortex = b.addExecutable(.{ + .name = "vortex", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/vortex.zig"), + .omit_frame_pointer = false, + .target = options.target, + .optimize = options.mode, + }), + }); + vortex.root_module.addImport("stdx", options.stdx_module); + vortex.root_module.addOptions("vsr_options", options.vsr_options_test); + vortex.root_module.addOptions("vortex_options", options.vortex_options); + return vortex; +} + +fn build_vortex_options( + b: *std.Build, + options: struct { + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + tigerbeetle_test: std.Build.LazyPath, + tigerbeetle_next_test: std.Build.LazyPath, + vortex_driver_zig: std.Build.LazyPath, + }, +) *std.Build.Step.Options { + const driver_exes = b.allocator.alloc(std.Build.LazyPath, 6) catch @panic("OOM"); + const server_exes = b.allocator.alloc(std.Build.LazyPath, 6) catch @panic("OOM"); + + // The "tigerbeetle_next_test" and "tigerbeetle_test" are both builds of the current HEAD's + // code, but their release numbers differ. This allows us to test upgrading *from* the current + // release, not just upgrading *to* it. + // Use the same driver for the "next", since it will be identical anyway. + server_exes[0] = options.tigerbeetle_next_test; + driver_exes[0] = options.vortex_driver_zig; + + server_exes[1] = options.tigerbeetle_test; + driver_exes[1] = options.vortex_driver_zig; + + if (options.target.result.os.tag == .linux) { + var tags_iterator = release_history(b); + for (server_exes[2..], driver_exes[2..]) |*server, *driver| { + const tag = tags_iterator.next().?; + server.* = fetch_release(b, tag, options.target, options.mode); + driver.* = fetch_vortex_driver_zig(b, tag, options.target, options.mode); + } + } + + // TODO: Debug builds only include 1 extra release. Since Vortex can't detect whether upgrades + // have accurately finished, it is limited to testing a single upgrade, so we prioritize testing + // the previous release (skipping past our phony 65535.0.1 release). + const release_offset, const release_count = blk: { + // Currently we only publish drivers built for Linux. + if (options.target.result.os.tag == .linux) { + break :blk switch (options.mode) { + .ReleaseSafe => .{ @as(u32, 0), @as(u32, 3) }, + .Debug => .{ 1, 2 }, + else => unreachable, + }; + } else { + break :blk .{ 0, 2 }; + } + }; + + const vortex_options = b.addOptions(); + const vortex_dir = b.fmt("vortex/{s}", .{@tagName(options.mode)}); + vortex_options.addOption(u32, "dependencies_count", release_count); + vortex_options.addOptionPath( + "dependencies_path", + b.path(b.fmt("./zig-out/{s}", .{vortex_dir})), + ); + + const server_exes_select = server_exes[release_offset..][0..release_count]; + const driver_exes_select = driver_exes[release_offset..][0..release_count]; + for (server_exes_select, 0..) |executable, i| { + const destination = b.fmt("../{s}/tigerbeetle-{d}", .{ vortex_dir, i }); + vortex_options.step.dependOn(&b.addInstallBinFile(executable, destination).step); + } + for (driver_exes_select, 0..) |executable, i| { + const destination = b.fmt("../{s}/vortex-driver-zig-{d}", .{ vortex_dir, i }); + vortex_options.step.dependOn(&b.addInstallBinFile(executable, destination).step); + } + return vortex_options; +} + +fn release_history(b: *std.Build) std.mem.SplitIterator(u8, .scalar) { + const tags_string = b.run(&.{ + "git", "tag", + // Only list ancestors of the current commit. + // Use "HEAD^" instead of "HEAD" so that if our current commit is a release commit, we don't + // include that release, since it is already tigerbeetle-0. + "--merged", "HEAD^", + "--sort=-committerdate", // Sort from newest to oldest. + "--list", "[0-9]*.[0-9]*.[0-9]*", // NB: This is not anchored (^$). + }); + return std.mem.splitScalar(u8, tags_string, '\n'); +} + +fn build_vortex_driver_zig( + b: *std.Build, + steps: struct { + vortex_driver_zig_build: *std.Build.Step, + }, + options: struct { + tb_client_header: std.Build.LazyPath, + stdx_module: *std.Build.Module, + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + print_exe: bool, + }, +) std.Build.LazyPath { + const tb_client = b.addLibrary(.{ + .name = "tb_client", + .linkage = .static, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/tigerbeetle/libtb_client.zig"), + .target = options.target, + .optimize = options.mode, + }), + }); + tb_client.linkLibC(); + tb_client.pie = true; + tb_client.bundle_compiler_rt = true; + tb_client.root_module.addImport("vsr", options.vsr_module); + tb_client.root_module.addOptions("vsr_options", options.vsr_options); + if (options.target.result.os.tag == .windows) { + tb_client.linkSystemLibrary("ws2_32"); + tb_client.linkSystemLibrary("advapi32"); + } + + const vortex_driver = b.addExecutable(.{ + .name = "vortex-driver-zig", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/testing/vortex/zig_driver.zig"), + .omit_frame_pointer = false, + .target = options.target, + .optimize = options.mode, + }), + }); + vortex_driver.linkLibC(); + vortex_driver.linkLibrary(tb_client); + vortex_driver.addIncludePath(options.tb_client_header.dirname()); + vortex_driver.root_module.addImport("stdx", options.stdx_module); + vortex_driver.root_module.addImport("vsr", options.vsr_module); + + const install_step = print_or_install(b, vortex_driver, options.print_exe); + steps.vortex_driver_zig_build.dependOn(install_step); + return vortex_driver.getEmittedBin(); +} + +// TigerBeetle clients ship as precompiled binaries and support this closed set of targets: +const Platform = enum { + @"aarch64-linux-gnu.2.27", + @"aarch64-linux-musl", + @"aarch64-macos", + @"x86_64-linux-gnu.2.27", + @"x86_64-linux-musl", + @"x86_64-macos", + @"x86_64-windows", + + const all: []const Platform = std.enums.values(Platform); + + pub fn target(platform: Platform) []const u8 { + return @tagName(platform); + } + + pub fn target_no_glibc_version(platform: Platform) []const u8 { + if (std.mem.endsWith(u8, platform.target(), "gnu.2.27")) { + return platform.target()[0 .. platform.target().len - ".2.27".len]; + } + assert(std.mem.indexOf(u8, platform.target(), "gnu") == null); + return platform.target(); + } + + pub fn target_resolved(platform: Platform, b: *std.Build) std.Build.ResolvedTarget { + const query = Query.parse(.{ + .arch_os_abi = platform.target(), + .cpu_features = platform.cpu_features(), + }) catch unreachable; + return b.resolveTargetQuery(query); + } + + pub fn cpu_features(platform: Platform) []const u8 { + return switch (platform) { + .@"aarch64-linux-gnu.2.27", + .@"aarch64-linux-musl", + .@"aarch64-macos", + => "baseline+aes+neon", + + .@"x86_64-linux-gnu.2.27", + .@"x86_64-linux-musl", + .@"x86_64-macos", + .@"x86_64-windows", + => "x86_64_v3+aes", + }; + } + + pub fn dotnet_RID(platform: Platform) []const u8 { + return switch (platform) { + .@"aarch64-linux-gnu.2.27" => "linux-arm64", + .@"aarch64-linux-musl" => "linux-musl-arm64", + .@"aarch64-macos" => "osx-arm64", + + .@"x86_64-linux-gnu.2.27" => "linux-x64", + .@"x86_64-linux-musl" => "linux-musl-x64", + .@"x86_64-macos" => "osx-x64", + .@"x86_64-windows" => "win-x64", + }; + } + + pub fn go_target(platform: Platform) []const u8 { + return switch (platform) { + // Go statically links musl. + .@"aarch64-linux-gnu.2.27", .@"x86_64-linux-gnu.2.27" => unreachable, + .@"aarch64-linux-musl" => "aarch64-linux", + .@"x86_64-linux-musl" => "x86_64-linux", + + .@"aarch64-macos", + .@"x86_64-macos", + .@"x86_64-windows", + => platform.target(), + }; + } +}; + +/// Produces a directory with tb_client precompiled as dynamic libraries +/// for all platforms we support. These precompiled libraries are then +/// redistributed with our language clients. +const TBClientPrebuilt = struct { + header: std.Build.LazyPath, + all_platforms: std.Build.LazyPath, + per_platform: []PerPlatform, + + const PerPlatform = struct { + platform: Platform, + file_name: []const u8, + lazy_path: std.Build.LazyPath, + }; +}; + +fn build_tb_client( + b: *std.Build, + options: struct { + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + mode: std.builtin.OptimizeMode, + }, +) TBClientPrebuilt { + var per_platform: std.ArrayListUnmanaged(TBClientPrebuilt.PerPlatform) = .empty; + const all_platforms = b.addWriteFiles(); + for (Platform.all) |platform| { + const resolved_target = platform.target_resolved(b); + + const root_module = b.createModule(.{ + .root_source_file = b.path("src/tigerbeetle/libtb_client.zig"), + .target = resolved_target, + .optimize = options.mode, + }); + root_module.addImport("vsr", options.vsr_module); + root_module.addOptions("vsr_options", options.vsr_options); + if (options.mode == .ReleaseSafe) strip_root_module(root_module); + + const shared_lib = b.addLibrary(.{ + .name = "tb_client", + .linkage = .dynamic, + .root_module = root_module, + }); + shared_lib.linkLibC(); + if (resolved_target.result.os.tag == .windows) { + shared_lib.linkSystemLibrary("ws2_32"); + shared_lib.linkSystemLibrary("advapi32"); + } + + per_platform.append(b.allocator, .{ + .platform = platform, + .file_name = shared_lib.out_filename, + .lazy_path = all_platforms.addCopyFile( + shared_lib.getEmittedBin(), + b.pathJoin(&.{ platform.target(), shared_lib.out_filename }), + ), + }) catch @panic("OOM"); + } + + const tb_client_header_generator = b.addExecutable(.{ + .name = "tb_client_header", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/clients/c/tb_client_header.zig"), + .target = b.graph.host, + }), + }); + tb_client_header_generator.root_module.addImport("vsr", options.vsr_module); + tb_client_header_generator.root_module.addOptions("vsr_options", options.vsr_options); + const header = Generated.file(b, .{ + .generator = tb_client_header_generator, + .path = "./src/clients/c/tb_client.h", + }); + + return .{ + .header = header.path, + .all_platforms = all_platforms.getDirectory(), + .per_platform = per_platform.items, + }; +} + +fn build_rust_client( + b: *std.Build, + step_clients_rust: *std.Build.Step, + options: struct { + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + tb_client_header: std.Build.LazyPath, + mode: std.builtin.OptimizeMode, + }, +) void { + // The Rust test suite runs tigerbeetle directly. This ensures it is available. + step_clients_rust.dependOn(b.getInstallStep()); + + // Copy the generated header file to the Rust client assets directory: + const tb_client_header_copy = Generated.file_copy(b, .{ + .from = options.tb_client_header, + .path = "./src/clients/rust/assets/tb_client.h", + }); + step_clients_rust.dependOn(&tb_client_header_copy.step); + + for (Platform.all) |platform| { + const resolved_target = platform.target_resolved(b); + + const root_module = b.createModule(.{ + .root_source_file = b.path("src/tigerbeetle/libtb_client.zig"), + .target = resolved_target, + .optimize = options.mode, + }); + root_module.addImport("vsr", options.vsr_module); + root_module.addOptions("vsr_options", options.vsr_options); + if (options.mode == .ReleaseSafe) strip_root_module(root_module); + + const static_lib = b.addLibrary(.{ + .name = "tb_client", + .linkage = .static, + .root_module = root_module, + }); + static_lib.bundle_compiler_rt = true; + static_lib.pie = true; + static_lib.linkLibC(); + + step_clients_rust.dependOn(&b.addInstallFile(static_lib.getEmittedBin(), b.pathJoin(&.{ + "../src/clients/rust/assets/lib/", + platform.target(), + static_lib.out_filename, + })).step); + } + + const rust_bindings_generator = b.addExecutable(.{ + .name = "rust_bindings", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/clients/rust/rust_bindings.zig"), + .target = b.graph.host, + }), + }); + rust_bindings_generator.root_module.addImport("vsr", options.vsr_module); + rust_bindings_generator.root_module.addOptions("vsr_options", options.vsr_options); + const bindings = Generated.file(b, .{ + .generator = rust_bindings_generator, + .path = "./src/clients/rust/src/tb_client.rs", + }); + + step_clients_rust.dependOn(&bindings.step); +} + +fn build_go_client( + b: *std.Build, + step_clients_go: *std.Build.Step, + options: struct { + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + tb_client_header: std.Build.LazyPath, + mode: std.builtin.OptimizeMode, + }, +) void { + // Updates the generated header file: + const tb_client_header_copy = Generated.file_copy(b, .{ + .from = options.tb_client_header, + .path = "./src/clients/go/native/tb_client.h", + }); + + const go_bindings_generator = b.addExecutable(.{ + .name = "go_bindings", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/clients/go/go_bindings.zig"), + .target = b.graph.host, + }), + }); + go_bindings_generator.root_module.addImport("vsr", options.vsr_module); + go_bindings_generator.root_module.addOptions("vsr_options", options.vsr_options); + go_bindings_generator.step.dependOn(&tb_client_header_copy.step); + const bindings = Generated.file(b, .{ + .generator = go_bindings_generator, + .path = "./src/clients/go/bindings.go", + }); + + for (Platform.all) |platform| { + // We don't need the linux-gnu builds. + if (platform == .@"aarch64-linux-gnu.2.27" or platform == .@"x86_64-linux-gnu.2.27") { + continue; + } + + const resolved_target = platform.target_resolved(b); + + const root_module = b.createModule(.{ + .root_source_file = b.path("src/tigerbeetle/libtb_client.zig"), + .target = resolved_target, + .optimize = options.mode, + .stack_protector = false, + }); + root_module.addImport("vsr", options.vsr_module); + root_module.addOptions("vsr_options", options.vsr_options); + if (options.mode == .ReleaseSafe) strip_root_module(root_module); + + const lib = b.addLibrary(.{ + .name = "tb_client", + .linkage = .static, + .root_module = root_module, + }); + lib.linkLibC(); + lib.pie = true; + lib.bundle_compiler_rt = true; + lib.step.dependOn(&bindings.step); + + const file_name: []const u8, const extension: []const u8 = cut: { + assert(std.mem.count(u8, lib.out_lib_filename, ".") == 1); + var it = std.mem.splitScalar(u8, lib.out_lib_filename, '.'); + defer assert(it.next() == null); + break :cut .{ it.next().?, it.next().? }; + }; + + // NB: New way to do lib.setOutputDir(). The ../ is important to escape zig-cache/. + step_clients_go.dependOn(&b.addInstallFile( + lib.getEmittedBin(), + b.fmt("../src/clients/go/native/{s}_{s}.{s}", .{ + file_name, + platform.go_target(), + extension, + }), + ).step); + } +} + +fn build_java_client( + b: *std.Build, + step_clients_java: *std.Build.Step, + options: struct { + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + mode: std.builtin.OptimizeMode, + }, +) void { + const java_bindings_generator = b.addExecutable(.{ + .name = "java_bindings", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/clients/java/java_bindings.zig"), + .target = b.graph.host, + }), + }); + java_bindings_generator.root_module.addImport("vsr", options.vsr_module); + java_bindings_generator.root_module.addOptions("vsr_options", options.vsr_options); + const bindings = Generated.directory(b, .{ + .generator = java_bindings_generator, + .path = "./src/clients/java/src/main/java/com/tigerbeetle/", + }); + + for (Platform.all) |platform| { + const resolved_target = platform.target_resolved(b); + + const root_module = b.createModule(.{ + .root_source_file = b.path("src/clients/java/src/client.zig"), + .target = resolved_target, + .optimize = options.mode, + }); + root_module.addImport("vsr", options.vsr_module); + root_module.addOptions("vsr_options", options.vsr_options); + if (options.mode == .ReleaseSafe) strip_root_module(root_module); + + const lib = b.addLibrary(.{ + .name = "tb_jniclient", + .linkage = .dynamic, + .root_module = root_module, + }); + lib.linkLibC(); + if (resolved_target.result.os.tag == .windows) { + lib.linkSystemLibrary("ws2_32"); + lib.linkSystemLibrary("advapi32"); + } + lib.step.dependOn(&bindings.step); + + // NB: New way to do lib.setOutputDir(). The ../ is important to escape zig-cache/. + step_clients_java.dependOn(&b.addInstallFile(lib.getEmittedBin(), b.pathJoin(&.{ + "../src/clients/java/src/main/resources/lib/", + platform.target_no_glibc_version(), + lib.out_filename, + })).step); + } +} + +fn build_dotnet_client( + b: *std.Build, + step_clients_dotnet: *std.Build.Step, + options: struct { + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + tb_client: TBClientPrebuilt, + mode: std.builtin.OptimizeMode, + }, +) void { + const dotnet_bindings_generator = b.addExecutable(.{ + .name = "dotnet_bindings", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/clients/dotnet/dotnet_bindings.zig"), + .target = b.graph.host, + }), + }); + dotnet_bindings_generator.root_module.addImport("vsr", options.vsr_module); + dotnet_bindings_generator.root_module.addOptions("vsr_options", options.vsr_options); + const bindings = Generated.file(b, .{ + .generator = dotnet_bindings_generator, + .path = "./src/clients/dotnet/TigerBeetle/Bindings.cs", + }); + + step_clients_dotnet.dependOn(&bindings.step); + for (options.tb_client.per_platform) |platform| { + step_clients_dotnet.dependOn(&b.addInstallFile(platform.lazy_path, b.pathJoin(&.{ + "../src/clients/dotnet/TigerBeetle/runtimes/", + platform.platform.dotnet_RID(), + "native", + platform.file_name, + })).step); + } +} + +fn build_node_client( + b: *std.Build, + step_clients_node: *std.Build.Step, + options: struct { + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + mode: std.builtin.OptimizeMode, + }, +) void { + const node_bindings_generator = b.addExecutable(.{ + .name = "node_bindings", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/clients/node/node_bindings.zig"), + .target = b.graph.host, + }), + }); + node_bindings_generator.root_module.addImport("vsr", options.vsr_module); + node_bindings_generator.root_module.addOptions("vsr_options", options.vsr_options); + const bindings = Generated.file(b, .{ + .generator = node_bindings_generator, + .path = "./src/clients/node/src/bindings.ts", + }); + + // Run `npm install` to get access to node headers. + var npm_install = b.addRunArtifact(b.addExecutable(.{ + .name = "npm-install", + .root_module = b.createModule(.{ + .root_source_file = b.path("./src/build/npm_install.zig"), + .target = b.graph.host, + }), + })); + npm_install.cwd = b.path("./src/clients/node"); + + // For windows, compile a set of all symbols that could be exported by node and write it to a + // `.def` file for `zig dlltool` to generate a `.lib` file from. + var write_def_file = b.addSystemCommand(&.{ + "node", "--eval", + \\const headers = require('node-api-headers') + \\ + \\const allSymbols = new Set() + \\for (const ver of Object.values(headers.symbols)) { + \\ for (const sym of ver.node_api_symbols) { + \\ allSymbols.add(sym) + \\ } + \\ for (const sym of ver.js_native_api_symbols) { + \\ allSymbols.add(sym) + \\ } + \\} + \\ + \\process.stdout.write('EXPORTS\n ' + Array.from(allSymbols).join('\n ')) + }); + write_def_file.cwd = b.path("./src/clients/node"); + write_def_file.step.dependOn(&npm_install.step); + + var run_dll_tool = b.addSystemCommand(&.{ + b.graph.zig_exe, "dlltool", + "-m", "i386:x86-64", + "-D", "node.exe", + "-l", "node.lib", + "-d", + }); + run_dll_tool.addFileArg(write_def_file.captureStdOut()); + run_dll_tool.cwd = b.path("./src/clients/node"); + + for (Platform.all) |platform| { + const resolved_target = platform.target_resolved(b); + + const root_module = b.createModule(.{ + .root_source_file = b.path("src/clients/node/node.zig"), + .target = resolved_target, + .optimize = options.mode, + }); + root_module.addImport("vsr", options.vsr_module); + root_module.addOptions("vsr_options", options.vsr_options); + if (options.mode == .ReleaseSafe) strip_root_module(root_module); + + const lib = b.addLibrary(.{ + .name = "tb_nodeclient", + .linkage = .dynamic, + .root_module = root_module, + }); + lib.linkLibC(); + + lib.step.dependOn(&npm_install.step); + lib.addSystemIncludePath(b.path("src/clients/node/node_modules/node-api-headers/include")); + lib.linker_allow_shlib_undefined = true; + + if (resolved_target.result.os.tag == .windows) { + lib.linkSystemLibrary("ws2_32"); + lib.linkSystemLibrary("advapi32"); + + lib.step.dependOn(&run_dll_tool.step); + lib.addLibraryPath(b.path("src/clients/node")); + lib.linkSystemLibrary("node"); + } + + lib.step.dependOn(&bindings.step); + step_clients_node.dependOn(&b.addInstallFile(lib.getEmittedBin(), b.pathJoin(&.{ + "../src/clients/node/dist/bin", + platform.target_no_glibc_version(), + "/client.node", + })).step); + } +} + +fn build_python_client( + b: *std.Build, + step_clients_python: *std.Build.Step, + options: struct { + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + tb_client: TBClientPrebuilt, + mode: std.builtin.OptimizeMode, + }, +) void { + const python_bindings_generator = b.addExecutable(.{ + .name = "python_bindings", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/clients/python/python_bindings.zig"), + .target = b.graph.host, + }), + }); + python_bindings_generator.root_module.addImport("vsr", options.vsr_module); + python_bindings_generator.root_module.addOptions("vsr_options", options.vsr_options); + const bindings = Generated.file(b, .{ + .generator = python_bindings_generator, + .path = "./src/clients/python/src/tigerbeetle/bindings.py", + }); + step_clients_python.dependOn(&bindings.step); + + step_clients_python.dependOn(&b.addInstallDirectory(.{ + .source_dir = options.tb_client.all_platforms, + .install_dir = .prefix, + .install_subdir = "../src/clients/python/src/tigerbeetle/lib/", + }).step); +} + +fn build_ruby_client( + b: *std.Build, + step_clients_ruby: *std.Build.Step, + options: struct { + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + tb_client_header: std.Build.LazyPath, + tb_client: TBClientPrebuilt, + mode: std.builtin.OptimizeMode, + }, +) void { + // Ruby bindings for flags, structs, etc. + step_clients_ruby.dependOn(&build_ruby_client_generate(b, .ruby, .{ + .path = "./src/clients/ruby/src/tigerbeetle/bindings.rb", + .vsr_module = options.vsr_module, + .vsr_options = options.vsr_options, + }).step); + + // Serializer/deserializer code for Ruby C extension + step_clients_ruby.dependOn(&build_ruby_client_generate(b, .c_header, .{ + .path = "./src/clients/ruby/src/ext/tigerbeetle/rb_tb_gen.h", + .vsr_module = options.vsr_module, + .vsr_options = options.vsr_options, + }).step); + + // Ruby types + step_clients_ruby.dependOn(&build_ruby_client_generate(b, .rbs, .{ + .path = "./src/clients/ruby/sig/tigerbeetle.rbs", + .vsr_module = options.vsr_module, + .vsr_options = options.vsr_options, + }).step); + + const tb_client_header_copy = Generated.file_copy(b, .{ + .from = options.tb_client_header, + .path = "./src/clients/ruby/src/ext/tigerbeetle/tb_client.h", + }); + step_clients_ruby.dependOn(&tb_client_header_copy.step); + + step_clients_ruby.dependOn(&b.addInstallDirectory(.{ + .source_dir = options.tb_client.all_platforms, + .install_dir = .prefix, + .install_subdir = "../src/clients/ruby/src/ext/tigerbeetle/lib/", + }).step); +} + +fn build_ruby_client_generate( + b: *std.Build, + what: enum { ruby, rbs, c_header }, + options: struct { + path: []const u8, + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + }, +) *Generated { + const ruby_bindings_options = b.addOptions(); + ruby_bindings_options.addOption([]const u8, "output", @tagName(what)); + const generator = b.addExecutable(.{ + .name = "ruby_bindings", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/clients/ruby/ruby_bindings.zig"), + .target = b.graph.host, + }), + }); + generator.root_module.addImport("vsr", options.vsr_module); + generator.root_module.addOptions("vsr_options", options.vsr_options); + generator.root_module.addOptions("ruby_bindings_options", ruby_bindings_options); + return Generated.file(b, .{ + .generator = generator, + .path = options.path, + }); +} + +fn build_c_client( + b: *std.Build, + step_clients_c: *std.Build.Step, + options: struct { + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + tb_client_header: std.Build.LazyPath, + mode: std.builtin.OptimizeMode, + }, +) void { + options.tb_client_header.addStepDependencies(step_clients_c); + + for (Platform.all) |platform| { + const resolved_target = platform.target_resolved(b); + + const root_module = b.createModule(.{ + .root_source_file = b.path("src/tigerbeetle/libtb_client.zig"), + .target = resolved_target, + .optimize = options.mode, + }); + root_module.addImport("vsr", options.vsr_module); + root_module.addOptions("vsr_options", options.vsr_options); + if (options.mode == .ReleaseSafe) strip_root_module(root_module); + + const shared_lib = b.addLibrary(.{ + .name = "tb_client", + .linkage = .dynamic, + .root_module = root_module, + }); + + const static_lib = b.addLibrary(.{ + .name = "tb_client", + .linkage = .static, + .root_module = root_module, + }); + static_lib.bundle_compiler_rt = true; + static_lib.pie = true; + + for ([_]*std.Build.Step.Compile{ shared_lib, static_lib }) |lib| { + lib.linkLibC(); + if (resolved_target.result.os.tag == .windows) { + lib.linkSystemLibrary("ws2_32"); + lib.linkSystemLibrary("advapi32"); + } + + step_clients_c.dependOn(&b.addInstallFile(lib.getEmittedBin(), b.pathJoin(&.{ + "../src/clients/c/lib/", + platform.target(), + lib.out_filename, + })).step); + } + } +} + +fn build_clients_c_sample( + b: *std.Build, + step_clients_c_sample: *std.Build.Step, + options: struct { + vsr_module: *std.Build.Module, + vsr_options: *std.Build.Step.Options, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, + }, +) void { + const static_lib = b.addLibrary(.{ + .name = "tb_client", + .linkage = .static, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/tigerbeetle/libtb_client.zig"), + .target = options.target, + .optimize = options.mode, + }), + }); + static_lib.linkLibC(); + static_lib.pie = true; + static_lib.bundle_compiler_rt = true; + static_lib.root_module.addImport("vsr", options.vsr_module); + static_lib.root_module.addOptions("vsr_options", options.vsr_options); + step_clients_c_sample.dependOn(&static_lib.step); + + const sample = b.addExecutable(.{ + .name = "c_sample", + .root_module = b.createModule(.{ + .target = options.target, + .optimize = options.mode, + }), + }); + sample.root_module.addCSourceFile(.{ + .file = b.path("src/clients/c/samples/main.c"), + }); + sample.linkLibrary(static_lib); + sample.linkLibC(); + + if (options.target.result.os.tag == .windows) { + static_lib.linkSystemLibrary("ws2_32"); + static_lib.linkSystemLibrary("advapi32"); + + // TODO: Illegal instruction on Windows: + sample.root_module.sanitize_c = false; + } + + const install_step = b.addInstallArtifact(sample, .{}); + step_clients_c_sample.dependOn(&install_step.step); +} + +fn strip_root_module(root_module: *std.Build.Module) void { + root_module.strip = true; + // Ensure that we get stack traces even in release builds. + root_module.omit_frame_pointer = false; + root_module.unwind_tables = .none; +} + +/// Set the JVM DLL directory on Windows. +fn set_windows_dll(allocator: std.mem.Allocator, java_home: []const u8) void { + comptime assert(builtin.os.tag == .windows); + + const java_bin_path = std.fs.path.joinZ( + allocator, + &.{ java_home, "\\bin" }, + ) catch unreachable; + _ = SetDllDirectoryA(java_bin_path); + + const java_bin_server_path = std.fs.path.joinZ( + allocator, + &.{ java_home, "\\bin\\server" }, + ) catch unreachable; + _ = SetDllDirectoryA(java_bin_server_path); +} + +extern "kernel32" fn SetDllDirectoryA(path: [*:0]const u8) callconv(.c) std.os.windows.BOOL; + +fn print_or_install(b: *std.Build, compile: *std.Build.Step.Compile, print: bool) *std.Build.Step { + const PrintStep = struct { + step: std.Build.Step, + compile: *std.Build.Step.Compile, + + fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void { + const print_step: *@This() = @fieldParentPtr("step", step); + const path = print_step.compile.getEmittedBin().getPath2(step.owner, step); + try std.io.getStdOut().writer().print("{s}\n", .{path}); + } + }; + + if (print) { + const print_step = b.allocator.create(PrintStep) catch @panic("OOM"); + print_step.* = .{ + .step = std.Build.Step.init(.{ + .id = .custom, + .name = "print exe", + .owner = b, + .makeFn = PrintStep.make, + }), + .compile = compile, + }; + print_step.step.dependOn(&print_step.compile.step); + return &print_step.step; + } else { + return &b.addInstallArtifact(compile, .{}).step; + } +} + +/// Code generation for files which must also be committed to the repository. +/// +/// Runs the generator program to produce a file or a directory and copies the result to the +/// destination directory within the source tree. +/// +/// On CI (when CI env var is set), the files are not updated, and merely checked for freshness. +const Generated = struct { + step: std.Build.Step, + path: std.Build.LazyPath, + + destination: []const u8, + generated_file: std.Build.GeneratedFile, + source: std.Build.LazyPath, + mode: enum { file, directory }, + + /// The `generator` program prints the file to stdout. + pub fn file(b: *std.Build, options: struct { + generator: *std.Build.Step.Compile, + path: []const u8, + }) *Generated { + return create(b, options.path, .{ + .file = options.generator, + }); + } + + pub fn file_copy(b: *std.Build, options: struct { + from: std.Build.LazyPath, + path: []const u8, + }) *Generated { + return create(b, options.path, .{ + .copy = options.from, + }); + } + + /// The `generator` program creates several files in the output directory, which is passed in + /// as an argument. + /// + /// NB: there's no check that there aren't extra file at the destination. In other words, this + /// API can be used for mixing generated and hand-written files in a single directory. + pub fn directory(b: *std.Build, options: struct { + generator: *std.Build.Step.Compile, + path: []const u8, + }) *Generated { + return create(b, options.path, .{ + .directory = options.generator, + }); + } + + fn create(b: *std.Build, destination: []const u8, generator: union(enum) { + file: *std.Build.Step.Compile, + directory: *std.Build.Step.Compile, + copy: std.Build.LazyPath, + }) *Generated { + assert(std.mem.startsWith(u8, destination, "./src")); + const result = b.allocator.create(Generated) catch @panic("OOM"); + result.* = .{ + .step = std.Build.Step.init(.{ + .id = .custom, + .name = b.fmt("generate {s}", .{std.fs.path.basename(destination)}), + .owner = b, + .makeFn = make, + }), + .path = .{ .generated = .{ .file = &result.generated_file } }, + + .destination = destination, + .generated_file = .{ .step = &result.step }, + .source = switch (generator) { + .file => |compile| b.addRunArtifact(compile).captureStdOut(), + .directory => |compile| b.addRunArtifact(compile).addOutputDirectoryArg("out"), + .copy => |lazy_path| lazy_path, + }, + .mode = switch (generator) { + .file, .copy => .file, + .directory => .directory, + }, + }; + result.source.addStepDependencies(&result.step); + + return result; + } + + fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void { + const b = step.owner; + const generated: *Generated = @fieldParentPtr("step", step); + const ci = try std.process.hasEnvVar(b.allocator, "CI"); + const source_path = generated.source.getPath2(b, step); + + if (ci) { + const fresh = switch (generated.mode) { + .file => file_fresh(b, source_path, generated.destination), + .directory => directory_fresh(b, source_path, generated.destination), + } catch |err| { + return step.fail("unable to check '{s}': {s}", .{ + generated.destination, @errorName(err), + }); + }; + + if (!fresh) { + return step.fail("file '{s}' is outdated", .{ + generated.destination, + }); + } + step.result_cached = true; + } else { + const prev = switch (generated.mode) { + .file => file_update(b, source_path, generated.destination), + .directory => directory_update(b, source_path, generated.destination), + } catch |err| { + return step.fail("unable to update '{s}': {s}", .{ + generated.destination, @errorName(err), + }); + }; + step.result_cached = prev == .fresh; + } + + generated.generated_file.path = generated.destination; + } + + fn file_fresh( + b: *std.Build, + source_path: []const u8, + target_path: []const u8, + ) !bool { + const want = try b.build_root.handle.readFileAlloc( + b.allocator, + source_path, + std.math.maxInt(usize), + ); + defer b.allocator.free(want); + + const got = b.build_root.handle.readFileAlloc( + b.allocator, + target_path, + std.math.maxInt(usize), + ) catch return false; + defer b.allocator.free(got); + + return std.mem.eql(u8, want, got); + } + + fn file_update( + b: *std.Build, + source_path: []const u8, + target_path: []const u8, + ) !std.fs.Dir.PrevStatus { + return std.fs.Dir.updateFile( + b.build_root.handle, + source_path, + b.build_root.handle, + target_path, + .{}, + ); + } + + fn directory_fresh( + b: *std.Build, + source_path: []const u8, + target_path: []const u8, + ) !bool { + var source_dir = try b.build_root.handle.openDir(source_path, .{ .iterate = true }); + defer source_dir.close(); + + var target_dir = b.build_root.handle.openDir(target_path, .{}) catch return false; + defer target_dir.close(); + + var source_iter = source_dir.iterate(); + while (try source_iter.next()) |entry| { + assert(entry.kind == .file); + const want = try source_dir.readFileAlloc( + b.allocator, + entry.name, + std.math.maxInt(usize), + ); + defer b.allocator.free(want); + + const got = target_dir.readFileAlloc( + b.allocator, + entry.name, + std.math.maxInt(usize), + ) catch return false; + defer b.allocator.free(got); + + if (!std.mem.eql(u8, want, got)) return false; + } + + return true; + } + + fn directory_update( + b: *std.Build, + source_path: []const u8, + target_path: []const u8, + ) !std.fs.Dir.PrevStatus { + var result: std.fs.Dir.PrevStatus = .fresh; + var source_dir = try b.build_root.handle.openDir(source_path, .{ .iterate = true }); + defer source_dir.close(); + + var target_dir = try b.build_root.handle.makeOpenPath(target_path, .{}); + defer target_dir.close(); + + var source_iter = source_dir.iterate(); + while (try source_iter.next()) |entry| { + assert(entry.kind == .file); + const status = try std.fs.Dir.updateFile( + source_dir, + entry.name, + target_dir, + entry.name, + .{}, + ); + if (status == .stale) result = .stale; + } + + return result; + } +}; + +// Use 'zig fetch' to download and unpack the specified URL, optionally verifying the checksum. +fn fetch(b: *std.Build, options: struct { + url: []const u8, + file_name: []const u8, + hash: ?[]const u8, +}) std.Build.LazyPath { + const fetch_step = b.addRunArtifact(b.addExecutable(.{ + .name = "fetch", + .root_module = b.createModule(.{ + .root_source_file = b.path("./src/build/fetch.zig"), + .target = b.graph.host, + }), + })); + fetch_step.setName(b.fmt("fetch {s}", .{options.url})); + + fetch_step.addArgs(&.{ + b.graph.zig_exe, + b.graph.global_cache_root.path orelse ".", + options.url, + options.file_name, + }); + const result = fetch_step.addOutputFileArg(options.file_name); + if (options.hash) |hash| fetch_step.addArg(hash); + + return result; +} + +fn fetch_release( + b: *std.Build, + version_or_latest: []const u8, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, +) std.Build.LazyPath { + const release_slug = if (std.mem.eql(u8, version_or_latest, "latest")) + "latest/download" + else + b.fmt("download/{s}", .{version_or_latest}); + + const arch = if (target.result.os.tag == .macos) + "universal" + else switch (target.result.cpu.arch) { + .x86_64 => "x86_64", + .aarch64 => "aarch64", + else => @panic("unsupported CPU"), + }; + + const os = switch (target.result.os.tag) { + .windows => "windows", + .linux => "linux", + .macos => "macos", + else => @panic("unsupported OS"), + }; + + const debug = switch (mode) { + .ReleaseSafe => "", + .Debug => "-debug", + else => @panic("unsupported mode"), + }; + + const url = b.fmt( + "https://github.com/tigerbeetle/tigerbeetle" ++ + "/releases/{s}/tigerbeetle-{s}-{s}{s}.zip", + .{ release_slug, arch, os, debug }, + ); + + return fetch(b, .{ + .url = url, + .file_name = if (target.result.os.tag == .windows) "tigerbeetle.exe" else "tigerbeetle", + .hash = null, + }); +} + +fn fetch_vortex_driver_zig( + b: *std.Build, + version: []const u8, + target: std.Build.ResolvedTarget, + mode: std.builtin.OptimizeMode, +) std.Build.LazyPath { + assert(target.result.os.tag == .linux); + _ = mode; + + const arch = switch (target.result.cpu.arch) { + .x86_64 => "x86_64", + .aarch64 => "aarch64", + else => @panic("unsupported CPU"), + }; + + const url = b.fmt( + "https://github.com/tigerbeetle/tigerbeetle" ++ + "/releases/download/{s}/vortex-driver-zig-{s}-linux.zip", + .{ version, arch }, + ); + return fetch(b, .{ .url = url, .file_name = "vortex-driver-zig", .hash = null }); +} + +// Downloads a pre-build llvm-objcopy from . +fn fetch_objcopy(b: *std.Build) std.Build.LazyPath { + switch (b.graph.host.result.os.tag) { + .linux => { + switch (b.graph.host.result.cpu.arch) { + .x86_64 => { + return fetch(b, .{ + .url = "https://github.com/tigerbeetle/dependencies/releases/download/" ++ + "18.1.8/llvm-objcopy-x86_64-linux.zip", + .file_name = "llvm-objcopy", + .hash = "N-V-__8AAFCWcgAxBPUOMe_uJrFGfQ2Ri_SsbNp77pPYZdAe", + }); + }, + .aarch64 => { + return fetch(b, .{ + .url = "https://github.com/tigerbeetle/dependencies/releases/download/" ++ + "18.1.8/llvm-objcopy-aarch64-linux.zip", + .file_name = "llvm-objcopy", + .hash = "N-V-__8AAIgJcQAG--KvT2zb1yNrlRtNEo3pW3aIgoppmbT-", + }); + }, + else => @panic("unsupported arch"), + } + }, + .windows => { + assert(b.graph.host.result.cpu.arch == .x86_64); + return fetch(b, .{ + .url = "https://github.com/tigerbeetle/dependencies/releases/download/" ++ + "18.1.8/llvm-objcopy-x86_64-windows.zip", + .file_name = "llvm-objcopy.exe", + .hash = "N-V-__8AAADuPABpdHRgl3oetSEQ6yq8i5kq9XJC73JDFtMH", + }); + }, + .macos => { + // TODO: this assert triggers, but the macOS tests on x86_64 work...? + // assert(b.graph.host.result.cpu.arch == .aarch64); + return fetch(b, .{ + .url = "https://github.com/tigerbeetle/dependencies/releases/download/" ++ + "18.1.8/llvm-objcopy-aarch64-macos.zip", + .file_name = "llvm-objcopy", + .hash = "N-V-__8AAFAsVgArdRpU50gjJhqaAUSXsTemKo2A9rCaewUV", + }); + }, + else => @panic("unsupported host"), + } +} + +const MiB = 1 << 20; +const GiB = 1 << 30; diff --git a/ocam/docs/ARCHITECTURE.md b/ocam/docs/ARCHITECTURE.md new file mode 100644 index 00000000..0bb9f4b1 --- /dev/null +++ b/ocam/docs/ARCHITECTURE.md @@ -0,0 +1,710 @@ +# TigerBeetle Architecture + +This document is a technical overview of the internals of TigerBeetle. It includes a problem +statement, overview, motivation for design decisions and list of references that have inspired us. + +## Problem Statement + +TigerBeetle is a database for workloads that: + +- have contended and parallelize/shard poorly due to Amdahl's Law +- consist mostly of writes +- need very high throughput and moderately low latency +- require strong consistency guarantees +- demand very high levels of durability. + +At present, TigerBeetle is focused on financial transactions (transfers). Each transfer touches two +accounts. Accounts are Pareto-distributed which means a few accounts are responsible for the bulk of +the transfers. Multi-object transactions in combination with contention make traditional database +architecture inefficient. Locking the two accounts and then crossing the network to the application +to compute the balances' update is slow. Due to contention, this work also can't be efficiently +distributed across separate machines. + +TigerBeetle implements accounting logic but the underlying state machine can be swapped. TigerBeetle +does double-entry bookkeeping but _can_ do anything that has the same requirements: safety and +performance under extreme contention. + +## Overview + +TigerBeetle is a distributed system. It is a cluster of six replicas. The state of each replica is a +single file on disk, called the data file (see [data_file.md](./internals/data_file.md)). Replicas +use a consensus protocol (see [vsr.md](./internals/vsr.md)) to ensure that their respective data +files store identical data. + +Specifically, TigerBeetle is a replicated state machine. The ground state of the system is an +immutable, hash-chained, append-only log of prepares. Each prepare is a batch of 8 thousand +individual transfer objects. The primary replica: + +- accepts requests from the clients +- decides on the order in which requests shall be processed +- converts the next request to a prepare +- appends the prepare to the Write Ahead Log (WAL), assigning it the next sequence number and + adding a checksum "pointer" to the previous log entry +- replicates prepare across backups. + +When a backup receives a prepare, it writes it to the WAL section of the data file and sends a +`prepare_ok` message to the primary. Once the primary receives a quorum of `prepare_ok` messages, +the prepare is considered committed. This means it can no longer be removed from or reordered in +the log. + +Replicas execute committed prepares in sequence number order by applying a batch of transfers to +local state. Because all replicas start with the same (empty) state and the state transition +function is deterministic, the replicas arrive at the same state. + +If a primary fails, the consensus algorithm proper ensures that a different replica becomes a +primary. The new primary correctly reconstructs the latest state of the log. + +The derived state of the system is the append-only log of immutable transfers and the current +balances of all accounts. Past transfers are stored for idempotence. Physically on disk, the state +is stored as a collection (forest) of LSM trees ([lsm.md](./internals/lsm.md)). Each LSM tree is a +sorted collection of objects. For example, transfer objects are stored in a tree sorted by a unique +timestamp which allows for efficient lookup. Auxiliary index trees are used to speed up other kinds +of lookups. For example, there's a tree which stores a tuple of each transfer's debit account id and +transfer's timestamp sorted by account id. This tree allows looking up all the timestamps for +transfers from a specific account. Knowing the timestamp, it is then possible to retrieve the +transfer object itself. + +The forest of LSM trees is implemented as an on-disk functional data structure. The data is +organized as a tree of on-disk blocks (a block is 0.5 MiB in size). Blocks refer to other blocks by +their on-disk address and checksum of their content. From a root block, called the superblock, +the rest of the state is reachable. + +When applying prepares to local state, replicas don't overwrite any existing blocks in the data file +and instead write new blocks only. The current superblock state is kept in memory. Once in a while, +the superblock is atomically flushed to storage forming a new checkpoint. When a replica crashes and +restarts it loses access to its previous in-memory superblock. It reads the previous +checkpoint/superblock from storage and reconstructs the lost state by replaying the suffix of the +log of prepares after that checkpoint. Determinism guarantees that the replica ends up in the exact +same state. + +TigerBeetle assumes that replica's storage can fail. If a replica writes a prepare to the WAL or a +block of an LSM trees and the corresponding `fsync` returns `0` it could still be the case that when +reading this data later it will be found to be corrupted. Given that the system is already replicated +for high-availability, it would be wasteful not to use redundancy to repair local storage failures. +This is exactly what TigerBeetle does. + +Logical _and_ physical determinism guarantees that deep hash-chains exactly agree on all the +replicas, which allows transparent recovery and repair of corrupted data. TigerBeetle doesn't make a +distinction whether checksummed data comes from a local disk or another replica, and guarantees +durability and availability even in the presence of latent sector errors and helical corruption +([Jepsen report](https://jepsen.io/analyses/tigerbeetle-0.16.11#disk-faults)). + +## Design Decisions + +### Intentionality in the Design + +A lot of software is designed primarily via empirical feedback loop. You apply a small change to the +software, judge the results and then revert or double down. For TigerBeetle, we are trying something +different. We think from the first principles what the right solution _should_ be then use +"experiments" to confirm or disprove the mental model. + +For example, our [TigerStyle](./TIGER_STYLE.md) is an explicitly engineered engineering process. + +### Systems Thinking + +TigerBeetle is designed to be a part of a larger data processing system. What happens outside of +TigerBeetle is as important as what's inside: + +- Each transfer carries an end-to-end idempotency key: a unique 128-bit ID generated and persisted + by the end application (e.g. a mobile phone or a website). +- Applications do not submit transfers to TigerBeetle directly, going instead through an API + gateway. +- The gateway provides an HTTP API for potentially untrusted clients. +- The gateway aggregates individual transfers from separate applications into large batches. +- Gateways are stateless and horizontally scalable. All state is managed by TigerBeetle. +- End-to-end idempotency keys guarantee that each transfer is processed at most once, even if, due + to retry and load-balancing logic, it gets routed through several gateways. +- TigerBeetle records high-volume business transactions using a debit-credit schema, but + transactions include a `user_data` field for linking up with a general purpose database (see + [_System Architecture_](https://docs.tigerbeetle.com/coding/system-architecture/) and + [_The Write Last, Read First Rule_](https://tigerbeetle.com/blog/2025-11-06-the-write-last-read-first-rule/)). + +### As Fast as a Hash Table + +Here's a mental model for TigerBeetle: a good way to solve financial transactions is an in-memory +hash map that stores accounts keyed by their IDs. Processing a transfer is then two hash-map +lookups, a balance check, and two balance updates. This is the "speed-of-light" for the problem --- +any other solution wouldn't be significantly faster. + +TigerBeetle improves on the in-memory hash-table across two axes: + +**Persistence and High Availability:** an in-memory hash table is good until you need to reboot the +computer. Data in TigerBeetle is stored on disk so power cycles are safe. What's more, the data is +replicated across six replicas. Even if some of them are down, the cluster as a whole remains +available. + +**Large Data Sets:** an in-memory hash table is good until your data stops fitting in RAM. +TigerBeetle allows working with larger-than-memory datasets. To keep optimal performance, the hot +working subset of data should still fit within RAM. + +### Don't Waste Durability + +The function of consensus algorithm is to convert durability into availability. Data placed on +durable storage is valuable and should be utilized fully. + +If an individual block on any replica bit rots, it is wasteful not to repair this block using +equivalent data from other replicas in the cluster. + +### Non-Interactive Transactions + +The primary paradigm for OLGP databases ([Online General Purpose](https://docs.tigerbeetle.com/concepts/oltp/)) +is interactive transactions. To implement a bank transfer in a general-purpose relational database, +the application: + +1. Opens a transaction +2. Fetches balances from the database +3. Computes the balance update in the application +4. Sends updates to the database +5. Commits the transaction. + +Crucially, step 2 acquires a lock on the balances which is not released until step 5. A lock is +held over a network round-trip. This approach gives good performance if most transactions are +independent (e.g. are mostly reads). + +For financial accounting, the opposite is true. Most transactions are writes and conflict due +to popular accounts. So TigerBeetle executes a transaction directly inside the database +to avoid moving data to code over the network. + +### Single Thread + +TigerBeetle is single-threaded. There are both positive and negative reasons for this design choice. + +The primary negative reason is that the underlying workload is inherently contentious. Some +accounts are just way more popular than others. Transfers between hot accounts inherently +sequentialize the system. Trying to make transactions parallel doesn't make it faster. The +overhead of synchronization tends to dominate useful work. Channeling [Frank +McSherry's paper](https://www.usenix.org/system/files/conference/hotos15/hotos15-paper-mcsherry.pdf), +our claim is that financial transaction processing is an infinite-COST problem. + +The positive reason for using a single thread is that CPUs are quite fast and the reports +of Moore's Law demise are somewhat overstated. A single core can easily scale to 1mil TPS if you: + +- use the core efficiently, keep it busy with useful work and move everything else off the hot path +- do your homework: cache line aligned data structures, memory prefetching, SIMD and other + performance engineering 101. + +Additionally, keeping the system single threaded greatly simplifies the programming model and makes +testing much more efficient. + +### Static Memory Allocation + +We state that TigerBeetle doesn't use `malloc` and `free` and instead does "static memory allocation". +This is somewhat idiosyncratic use of the term so let's be precise about what TigerBeetle does and +does not do. + +When TigerBeetle starts, for every "object type" in the system it computes the worst-case upper +bound for the number of objects needed, based on CLI arguments. Then TigerBeetle allocates exactly +that number of objects and enters the main event loop. After startup, no new objects are created. +Therefore no dynamic memory allocation or deallocation is needed. + +This is different from truly static allocation of some embedded systems. TigerBeetle doesn't use +global statics (`.bss` section) for allocation and memory usage depends on the runtime CLI arguments. + +This is also different from arena allocation. Some systems allocate a fixed-sized arena at the start +and fail with an 'out of memory' error if the limit is ever exceeded. That is, while the memory +usage is bounded, there's no guarantee that enough memory is reserved. In TigerBeetle, the amount of +memory used is a consequence of everything having an explicit upper bound. + +Knowing the limits ensures that the system continues to function correctly even when overloaded. For +example, TigerBeetle doesn't need to have _explicit_ code for handling backpressure. If everything +has a limit, there's nothing to grow without bound to begin with. Backpressure arises from the entire +system of components needing to honor each-other's limits. + +Another interesting consequence of static limits is runway concurrency. In highly concurrent +applications, there are more concurrent tasks than the number of underlying resources available. This +leads to oversubscription. A concurrent task is usually a closure allocated somewhere on the heap +and registered with an event loop (a `Box`). TigerBeetle _can't_ heap allocate +structures at runtime. Each TigerBeetle "future" is represented by an explicit struct which is +statically allocated as a field of a component that owns the structure. There's always a natural +limit of how many concurrent tasks can be in flight! + +In summary, **static allocation is a forcing function to ensure that everything has a limit**, +and a natural consequence of these limits. + +Somewhat surprisingly, our experience is that static allocation also simplifies the system greatly. +You spend more time thinking up-front but after the initial design the interplay tends to just work +out. + +Static allocation makes the system faster and greatly reduces latency variation. This is another +consequence of knowing the limits and "physics" of the underlying system, but it is not the main reason +for choosing static allocation. + +### No Dependencies + +TigerBeetle avoids dependencies. It depends on: + +- the Linux kernel API (in particular, on io_uring) to make hardware do things +- the Zig compiler, to convert from human-readable source code to machine code +- parts of Zig standard library, for various basic appliances like sorting algorithms or hash maps. + +The usefulness of dependencies is generally inversely-proportional to the lifetime of the project. +For longer lived projects, it makes sense to control more of the underlying moving parts. TigerBeetle +is explicitly engineered for the long-term. It makes sense to start building all the necessary +infrastructure today. This removes temptation to compromise --- static allocation is done throughout +the stack because we wrote most of the stack. It is a pleasant intentional coincidence that Zig's +standard library APIs are compatible with static allocation. + +Avoiding dependencies also acts as a forcing function for keeping the code simple and easy to +understand. Extra complexity can't sneak into the codebase hidden by a nifty API. It turns out that +most of the code in the database isn't _that_ hard. + +### Zig + +As follows from the [static allocation](#static-memory-allocation) section, TigerBeetle doesn't need +a garbage collector. While it is possible to do static allocation in a GC language, it makes it much +harder to guarantee that no allocation happens. This significantly narrows down the choice +of programming languages. Of the languages that remain, Zig makes the most sense, although Rust is a +close contender. + +Both Zig and Rust provide spatial memory safety. Rust has better temporal and thread safety but +static allocation and single-threaded execution reduce the relative importance of these benefits. +Additionally, mere memory safety would be a low bar for TigerBeetle. General correctness is table +stakes. Requiring a comprehensive testing strategy leaves little space for bugs to escape testing +but be caught by the Rust-style type system. + +The primary benefit of Zig is the favorable ratio of expressivity to language complexity. +"Expressivity" here means ability to produce the desired machine code versus source-level +abstractions. Zig is a DSL for machine code. Its comptime features makes it very easy to _directly_ +express what you want the computer to do. This comes at the cost of missing declaration-site +interfaces but it's less important in a zero-dependency context. + +Zig provides excellent control over layout, alignment and padding. Alignment-carrying pointer types +prevent subtle errors. Idiomatic Zig collections don't have an allocator as a parameter of +constructor. Instead they explicitly pass allocator to the specific methods that require allocation. +This is a perfect fit for the memory management strategy used in TigerBeetle. Cross compilation +that works and direct (glibc-less) bindings to the kernel help keep dependency count down. + +Most importantly, this is all possible using very frugal language machinery. Zig lends itself to +low-abstraction first-order code that does the work directly. This makes it easy to author +and debug performance-oriented code. + +### Determinism + +A meta principle above "static allocation" is determinism. Determinism means that given the same input +the software gives the same logical result and arrives at it using the same physical path. In general, +everything in TigerBeetle is deterministic. There's no single reason to demand determinism but it +consistently simplifies and improves the system. Here are some of the places where determinism leads +to big advantages: + +- Simplifies the implementation of a replicated state machine. It reduces the + problem of synchronizing mutable state to a much simpler problem of synchronizing an immutable, + append-only, hash-chained log. +- Allows for physical as opposed to logical repair. Consider the case where a single byte of the + storage of a particular LSM tree gets corrupted. If the replicas only guarantee logical consistency, + repairing this byte might entail re-transmitting the entire tree. This is slow and might fail in + presence of uncorrelated faults. + + If on top of logical consistency the replicas guarantee that the bytes on disk representing the data + are _also_ the same it becomes sufficient for repair to transfer just a single disk block containing + the problematic byte. This is the approach taken by TigerBeetle. It is guaranteed that replicas in the + cluster converge on a byte-for-byte identical LSM tree structure. +- Physical repair in turn massively simplifies error handling. The function that reads data from + storage doesn't have an error condition. It _always_ receives the block with the requested + checksum but the block can be transparently read from a different replica. +- Physical determinism requires that LSM compaction work is scheduled deterministically. Compaction work + is evenly spread throughout the operation, bounding the worst-case latencies. +- Determinism supercharges randomized testing. Any test failure can be reliably reproduced by + sharing a seed that lead to the failure. + +### Simulation Testing + +TigerBeetle uses a variety of techniques to ensure that the code is correct – from example-based +tests to strict style guides. The most important technique deployed is simulation testing, as seen +on [Sim TigerBeetle](https://sim.tigerbeetle.com). + +TigerBeetle's simulator, the VOPR ([vopr.md](./internals/vopr.md)), can run an entire cluster on a single +thread, injecting various storage faults and infinitely speeding up time. VOPR combines a smart +workload generator, swarm testing and a thousand CPU cores. It makes it easy to exercise all the +possible behaviors of the system. + +Crucially, unlike formal proofs and model checking, the simulation testing exercises a specific +implementation. Tools like TLA are invaluable to debug an algorithm. They are of little help if you +want to check if your code implements the algorithm correctly or verify underlying _assumptions_. + +_The VOPR_ stands for _The Viewstamped Operation Replicator_ and was inspired by the movie WarGames, +by our love of fuzzing over the years, by +[Dropbox's Nucleus testing](https://dropbox.tech/infrastructure/-testing-our-new-sync-engine), +and by [FoundationDB's deterministic simulation testing](https://www.youtube.com/watch?v=OJb8A6h9jQQ). + +### Mechanical Sympathy + +Mechanical Sympathy is the idea that although a CPU is a general purpose device and can execute +anything, it is often possible to re-formulate a particular algorithm in a CPU-friendly manner. This +makes it much faster to run. Small details matter a lot for speed and sometimes these small +details guide larger architecture. + +In the context of TigerBeetle, mechanical sympathy spans all four primary colors of computation: + +- Network +- Storage +- Memory +- CPU + +**Network** has limited bandwidth. This means that if one node in the network requires much higher +bandwidth than the rest, the network will be underutilized. For this reason the primary doesn't +broadcast prepares to all backups. Instead, it sends each prepare to just two backups, relying on +the backups to forward it further. + +**Storage** is typically capable of sustaining several parallel IO operations and TigerBeetle tries to +keep it saturated. For example, when compaction needs to read a table from disk it enqueues writes +for several of the table's blocks at a time. At the same time, care is taken to not oversubscribe +the storage and there is a limit on the maximum number of concurrent IO operations. + +**Memory** is not fast. Operations that miss the cache and hit the memory are dramatically slower. +TigerBeetle data structures are compact, to maximize the chance that the data is cached, and +organized around cache lines. All data for a particular operation can be found in a single cache +line as opposed to being spread out in memory. For example, the size of a Transfer object is two cache lines. + +**CPU** can both sprint and parkour. But it is so much better at sprinting! CPU is very fast at straight +line code and can sweep several lanes at once with SIMD but `if`s (especially unpredictable ones) +make it stagger. When processing events, where each event is either a new account or a new transfer, +TigerBeetle lifts the branching up. Events come in batches and each batch is homogeneous --- +either all transfers, or all accounts. The event-kind branching is moved out of the inner loop. + +### Batching + +One of the most effective optimization principles is the idea of amortizing the overhead. If you +have to do a costly operation, make sure that its results "pay for" many smaller useful operation. +TigerBeetle uses batching at many different levels: + +- Individual transfers are aggregated into a prepare. This amortizes replication and prefetch IO + overhead. +- Changes from 32 prepares are aggregated in memory before being written to disk together +- Changes from 1024 prepares are aggregated before the checkpoint is atomically advanced by overwriting + the superblock +- The LSM tree operates mostly in terms of tables and value blocks, each aggregating many individual + records. + +### LSM + +TigerBeetle organizes data on disk as a collection of +[Log Structured Merge Trees](https://www.youtube.com/watch?v=hkMkBZn2mGs). In the context of +TigerBeetle, LSM has several attractive properties: + +- Particularly good at writes and TigerBeetle's workload is write-heavy +- Organized around batches of data, just like the rest of TigerBeetle +- Keeps hot data near the root while colder data sinks down to the lower level. This matches + Pareto-distributed workload. + +Often, a database is just a single LSM tree and individual "tables" are constructed logically by +using key prefixes. This is not the case for TigerBeetle and instead many individual trees are used +(the LSM forest). These trees store fixed-sized values. For example, in the Transfer object tree the +value is 128 bytes and for id tree the size of value is 32 bytes (`u128` id, `u64` timestamp, `u64` +padding). Each tree can be specialized for specific value size. This improves performance +and storage efficiency. Zig's `comptime` makes it particularly easy to configure the LSM Forest +through meta programming. + +### Grid + +The bulk of the data file is organized as a uniform grid of equally-sized blocks. Each block is +0.5MiB large. Although LSM trees are type-specialized, they all use the same grid of blocks. Various +auxiliary persistent data structures (for example, `ManifestLog`) are also built on top of the `Grid`. + +Each `Grid` block is also a valid network [`Message`](#message-passing). Blocks are +[hash-chained](#hash-chaining) and [deterministic](#determinism) across replicas. This allows for +physical repair --- if a block gets corrupted, TigerBeetle uses its checksum to transparently +request the data from a peer replica. + +### Control Plane / Data Plane Separation + +Batching is a special case of the more general principle of separating control plane and data plane. +Imagine yourself as a pilot in a cabin of a powerful jet. The cabin has all sorts of dials, levers +and buttons. By pressing the buttons, you control the jet engines. Most of the work of moving the +plane is done by the engines, the data plane. The pilot doesn't do the heavy lifting, they direct +the power of the engine. + +In the context of TigerBeetle, deciding which prepare to apply is 'control plane' and going through +each individual transfer is 'data plane'. In general, control plane is O(1) to data plane's O(N). + +It is important to separate the two modes of operation. Keeping control plane `if`s outside +of data plane `for`s keeps the inner loop free of branching. This improves performance. Conversely, +the overhead of control plane is small so it can use very aggressive assertions (up to spending O(N) +time to verify O(1) operation). + +### Synchronous Execution + +StateMachine's `commit` function, the one that actually implements double-entry accounting, is +synchronous. It takes a slice of transfers as input and decides in a single tight CPU loop per +transfer whether it is applicable. Then it computes the desired balance change and records +a result in the output slice. Crucially, the `commit` function itself doesn't do any reading from +storage. This is the key to performance. + +All IO happens in the separate prefetch phase. Given a batch of transfers, it is possible to predict +which accounts need to be fetched without actually executing the transfers. What's more, while commit +execution has to happen sequentially, all prefetch IO can happen in parallel. + +### Embracing Concurrency + +TigerBeetle uses sequential execution as a simple and performant way to achieve strict +serializability semantics. This doesn't mean that _everything_ has to be sequential. For example, as +the previous section demonstrates, it is possible to simultaneously: + +- fetch data from storage in parallel +- apply double-entry accounting rules to transfers on the CPU sequentially. + +This pattern generalizes: TigerBeetle embraces concurrency. Sequential execution is the exception. + +- In the VSR protocol, prepares are replicated concurrently. When a primary receives a client request, + assigns it an op-number and starts a replication loop - it doesn't wait for the replication to finish + before starting to work on the next request. _Execution_ of requests has to be sequential but + replication can be concurrent. + +- Similarly, for a new prepare the primary concurrently: + - writes it to its local storage + - starts the replication loop. + + The prepare is considered committed when the primary receives a quorum of `prepare_ok`from a set + of replicas. This quorum doesn't need to include the primary. It can be the case that the primary + concurrently executes a prepare while still writing the corresponding message to the (WAL). + +- A similar pipelining structure works in LSM compaction. Compaction is a batched two-way merge + operation. It merges two sorted sequences of values, where values come from blocks on disk. The + resulting sequence of values is likewise split into blocks which are written to disk. Although the + merge needs to be sequential, it is possible to fetch several blocks from disk at the same time. + With some more legwork, it is possible to structure compaction so that reading blocks of values + from disk, merging them in memory, and writing the resulting blocks to disk happen concurrently. + +### io_uring + +TigerBeetle uses io_uring exclusively for IO. It is a perfect interface for TigerBeetle as it +combines [batching](#batching) and [concurrency](#embracing-concurrency). At micro level, the +code in TigerBeetle isn't a good fit for coroutines or threads. The concurrency is very +fine-grained at the level of individual syscall. For example, for pipelined compaction the natural +way to write code is to issue two concurrent syscalls for reading the data from the corresponding +levels. That's more or less exactly what io_uring exposes as an interface to the programmer. + +io_uring is _also_ the only reasonable way to have truly asynchronous disk io on Linux and comes +with improved throughput to boot. But these benefits are secondary to the interface being a natural +fit for the problem. + +io_uring interacts with static allocation in an interesting way. Any asynchronous operation requires +saving a resumption context somewhere. Usually the context is heap allocated, but this is +incompatible with static allocation. In TigerBeetle, each component stores its own resumption +contexts. For example, the `Journal` holds a fixed-size array of `Write` structures with callback +contexts. The contexts from different components are organized by `IO` into a single intrusive +linked list. This way, `IO` can manage arbitrary many in-flight IO operations without a hard-coded +upper bound. And yet, the total amount of concurrency is limited. The limit is implicit, it is the +sum of per-component explicit limits. + +### Time + +Accounting business logic needs access to wall-clock time for transfer timeouts. The state machine +can't access OS to get time directly as that would violate determinism. Instead, the TigerBeetle +primary injects a specific timestamp into the logic of the state machine when it converts a request to a +prepare. + +The ultimate source of time for the TigerBeetle cluster is Network Time Protocol (NTP). A potential +failure mode with NTP is primary partitioned from NTP servers. To make sure that the primary's clock +is within an acceptable error margin, the cluster aggregates timing information from a replication quorum +of replicas. TigerBeetle also guarantees that the time observed by the state machine is strictly monotonic. + +This high-quality time implementation is utilized for business logic (timeouts) and plays a key role +in the internal implementation. Every object in TigerBeetle has a globally unique `u64` creation +timestamp which plays the role of synthetic primary key. + +### Direct IO + +TigerBeetle bypasses the operating system's page cache and uses Direct IO. Normally, when an +application writes to a file, the operating system only updates the in-memory cache and the data +gets to disk later. This is a good default for the vast majority of the applications but TigerBeetle is +an exception. It instructs the operating system to read directly from and write directly to the disk, +bypassing any caches (refer to this [excellent article](https://transactional.blog/how-to-learn/disk-io) +for the overview of the relevant OS APIs). + +Bypassing page cache is required for correctness. While operating systems provide an `fsync` API to +flush page cache to disk, it doesn't allow handling errors reliably: +[Can Applications Recover from fsync Failures?](https://www.usenix.org/system/files/atc20-rebello.pdf) + +The second reason to bypass the cache is the general principle of avoiding dependencies and reducing +assumptions. Concretely, TigerBeetle require neither the OS to provide page cache nor a +file system by virtue of using only a single file. As a consequence, TigerBeetle can run directly +against a block device. + +### Flexible Quorums + +There's something odd about a TigerBeetle cluster. We recommend using an even number of `6` replicas. +Usually, consensus implementations use `2f + 1` nodes, `3` or `5`, to have a clear majority for +quorums. TigerBeetle uses so-called flexible quorums. For `6` replicas, the replication quorum is +only `3`. It is enough for only a half of the cluster to persist a prepare durably to disk +for it to be considered logically committed. On the other hand, for changing views (that is, rotating +the role of the primary to a different replica), at least `4` replicas are needed. Because any +subset of three replicas and any subset of four replicas intersect, the new primary is guaranteed to +know all potentially committed prepares. + +The upshot here is that it's enough for `3` replicas, including the primary, to be online to keep +the cluster available. If in a cluster of `6` three replicas crash simultaneously, there's a +50% chance that the cluster remains available. If the replicas are crashing one by one the chance +is higher. When only `4` replicas remain, the chance that the next one to crash would be a primary +is only 25%. + +### Protocol Aware Recovery + +TigerBeetle assumes that the disk can fail and that the data can be physically lost eventually even +if the original `write` and `fsync` completed successfully. If that happens, TigerBeetle +transparently repairs faulty disk sectors using identical data present on the other replicas. + +Faulty storage can not be fully encapsulated by the storage interface and requires consensus +cooperation to resolve. Here's a useful counter-example. + +The primary accepts a request from the client, converts it to a prepare by assigning it a specific +operation number and starts the replication procedure. During replication, the primary successfully +appends the prepare to its local WAL but fails to broadcast it to other replicas. Now, for whatever +reason, the primary restarts, the cluster switches to a different primary and the prepare gets corrupted +in the original primary's WAL. As the request was prepared, the prepare should make it into the new view. +But because there's only one copy of prepare in the cluster, and it got corrupted, it is impossible +to execute this prepare. + +The key difficulty: _potentially_ committed prepares are not necessarily replicated to a full +replication quorum. As such, their durability is reduced. Corruption of a potentially committed +prepare requires special handling. The solution is to use the NACK protocol. + +During a view change, participating replicas can positively state that they _never_ accepted a particular +prepare. If at least `4` out of `6` replicas NACK the prepare then it can be inferred that the prepare +was never replicated fully and it can be safely discarded _even if it is corrupted_. + +### Hash-Chaining + +Similar to how batching tends to improve performance across the board, a universal improvement for +safety is the idea of hash-chaining: + +- compute a checksum for each data unit +- include this checksum with some parent data, which is also checksummed. + +This is the same structure as in git commits, but applied more generally. + +Hash chaining **binds intent**: if you know the checksum, then, on receiving any data, you get +strong guarantees that this is exactly the data you were looking for. You get protection both from +hardware errors and corruption, as well as programming errors that lead to value confusion. +TigerBeetle hash-chains: + +**Blocks**. LSM is a functional tree of blocks. Parent blocks (index blocks) contain "pointers" to +child blocks (value blocks). A block "pointer" is a pair of an `u64` block address and an `u128` +checksum. On disk, an array of child pointers is stored as Struct-of-Arrays (SoA). Pointers to the +index blocks themselves are stored in manifest log blocks (see +[data_file.md](./internals/data_file.md) for a more thorough overview). Whenever a replica reads a +block from disk, it already knows its checksum: checksums are stored outside of blocks themselves. +This is important to protect from misdirected IO: one failure mode for disks is to store correct +data at a wrong offset, a failure which cannot be detected using only internal checksums. External +checksums also make transparent repair possible: if a replica fails to read a block from its local +storage, it doesn't report an error and instead automatically requests other replicas to send the +block, using the checksum as an identifier. The root checksum is stored on disk in the superblock, +where the hash-chain starts. To protect the integrity of the superblock itself, it is physically +duplicated across four copies on disk. Superblock changes over time, and each _version_ of a +superblock includes a checksum of the previous version --- if two versions co-exist on disk at the +same time, their ordering is constrained weakly by a sequence number and strongly by hash-chaining. + +**Prepares**. Prepare message (units of replication, Write Ahead Log (WAL) and consensus) are hash +chained. This gives strong ordering guarantees for two adjacent prepares, and, via a transitive +closure, gives a global consistency guarantee, that the entire sequence of prepares from the +beginning of history to the latest prepare is valid. Hash-chaining improves WAL repair. Backups need +extra care (and, during rare view change, an extra message from the primary) to ensure that the +latest prepare in their log is correct, but any prepares before that can be repaired by following +the hash chain. + +**Requests**. Each client request includes a checksum of the reply to the previous request. While +these checksums are not as crucial for data validation, they provide a strong proof that the proper +ordering of requests is observed. + +### Message Passing + +TigerBeetle is agnostic of the underlying transport protocol and requires only a very weak message +passing semantics. TigerBeetle assumes that messages might be dropped, duplicated, reordered, and +corrupted (in non-byzantine way). Although specific `MessageBus` is not tested in +[VOPR](#simulation-testing), bugs in `MessageBus` are unlikely to affect correctness, because the +contract for the transport layer is intentionally very weak. + +### Star Replication + +As per the +[8 Fallacies of Distributed Computing](https://en.wikipedia.org/wiki/Fallacies_of_distributed_computing), +the network topology may change over time. Therefore, replication must tolerate both latency +variations and faults. TigerBeetle uses star replication: the primary sends each prepare in parallel +to all other replicas. Combined with +[flexible quorums](https://fpaxos.github.io/), this means the primary only needs to wait for the two +fastest replies out of five replicas, making replication both latency-tolerant and resilient to +individual node failures. + +## Conclusion + +TigerBeetle is designed to deliver mission-critical safety and 1000x performance, and power the +world's transactions. Presently focused on financial transactions, it fundamentally solves the +challenge of cost-efficient OLTP at scale in a world becoming exponentially more transactional and +real-time. + +## References + +The collection of logical and magical art behind TigerBeetle: + +- [LMAX - How to Do 100K TPS at Less than 1ms Latency - + 2010](https://www.infoq.com/presentations/LMAX/) - Martin Thompson on mechanical sympathy and why + a relational database is not the right solution. + +- [The LMAX Exchange Architecture - High Throughput, Low Latency and Plain Old Java - + 2014](https://skillsmatter.com/skillscasts/5247-the-lmax-exchange-architecture-high-throughput-low-latency-and-plain-old-java) + - Sam Adams on the high-level design of LMAX. + +- [LMAX Disruptor](https://lmax-exchange.github.io/disruptor/files/Disruptor-1.0.pdf) - A high + performance alternative to bounded queues for exchanging data between concurrent threads. + +- [Evolution of Financial Exchange Architectures - + 2020](https://www.youtube.com/watch?v=qDhTjE0XmkE) - Martin Thompson looks at the evolution of + financial exchanges and explores the state of the art today. + +- [Gray Failure](https://www.microsoft.com/en-us/research/wp-content/uploads/2017/06/paper-1.pdf) - + "The major availability breakdowns and performance anomalies we see in cloud environments tend to + be caused by subtle underlying faults, i.e. gray failure rather than fail-stop failure." + +- [The Tail at Store: A Revelation from Millions of Hours of Disk and SSD + Deployments](https://www.usenix.org/system/files/conference/fast16/fast16-papers-hao.pdf) - "We + find that storage performance instability is not uncommon: 0.2% of the time, a disk is more than + 2x slower than its peer drives in the same RAID group (and 0.6% for SSD). As a consequence, disk + and SSD-based RAIDs experience at least one slow drive (i.e., storage tail) 1.5% and 2.2% of the + time." + +- [The Tail at + Scale](https://www2.cs.duke.edu/courses/cps296.4/fall13/838-CloudPapers/dean_longtail.pdf) - "A + simple way to curb latency variability is to issue the same request to multiple replicas and use + the results from whichever replica responds first." + +- [Viewstamped Replication Revisited](https://hdl.handle.net/1721.1/71763) + +- [Viewstamped Replication: A New Primary Copy Method to Support Highly-Available Distributed + Systems](http://pmg.csail.mit.edu/papers/vr.pdf) + +- [Flexible Paxos: Quorum intersection revisited](https://arxiv.org/pdf/1608.06696v1) + +- [Scalability! But at what COST?](https://www.usenix.org/system/files/conference/hotos15/hotos15-paper-mcsherry.pdf) + +- [ZFS: The Last Word in File Systems (Jeff Bonwick and Bill + Moore)](https://www.youtube.com/watch?v=NRoUC9P1PmA) - On disk failure and corruption, the need + for checksums... and checksums to check the checksums, and the power of copy-on-write for + crash-safety. + +- [An Analysis of Latent Sector Errors in Disk + Drives](https://research.cs.wisc.edu/wind/Publications/latent-sigmetrics07.pdf) + +- [An Analysis of Data Corruption in the Storage + Stack](https://www.usenix.org/legacy/events/fast08/tech/full_papers/bairavasundaram/bairavasundaram.pdf) + +- [A Study of SSD Reliability in Large Scale Enterprise Storage + Deployments](https://www.usenix.org/system/files/fast20-maneas.pdf) + +- [SDC 2018 - Protocol-Aware Recovery for Consensus-Based Storage](https://www.youtube.com/watch?v=fDY6Wi0GcPs) + ([pdf](https://www.usenix.org/conference/fast18/presentation/alagappan)) - Why replicated state + machines need to distinguish between a crash and corruption, and why it would be disastrous to + truncate the journal when encountering a checksum mismatch. + +- [Can Applications Recover from fsync + Failures?](https://www.usenix.org/system/files/atc20-rebello.pdf) - Why we use Direct I/O in + TigerBeetle and why the kernel page cache is a dangerous way to recover the journal, even when + restarting from an fsync() failure panic. + +- [Coil's Mojaloop Performance Work + 2020](https://docs.mojaloop.io/legacy/discussions/Mojaloop%20Performance%202020.pdf) - By Don + Changfoot and Joran Dirk Greef, a performance analysis of Mojaloop's central ledger that sparked + the idea for "an accounting database" as Adrian Hope-Bailie put it. And the rest, as they say, is + history! + +- [Swarm Testing](https://users.cs.utah.edu/~regehr/papers/swarm12.pdf) + +- [PCC: Re-architecting Congestion Control for Consistent High Performance](https://www.usenix.org/system/files/conference/nsdi15/nsdi15-paper-dong.pdf) diff --git a/ocam/docs/README.md b/ocam/docs/README.md new file mode 100644 index 00000000..d5ab25bc --- /dev/null +++ b/ocam/docs/README.md @@ -0,0 +1,16 @@ +# TigerBeetle + +This is the documentation for TigerBeetle: the financial transactions database designed for mission +critical safety and performance to power the next 30 years of [OLTP](./concepts/oltp.md). + +This is how the entire documentation is organized: + +- [Start](./start.md) gets you up and running with a cluster. +- [Concepts](./concepts/) explains why TigerBeetle exists. +- [Coding](./coding/) shows how to integrate TigerBeetle into your application. +- [Operating](./operating/) covers deployment and operating a TigerBeetle cluster. +- [Reference](./reference/) is a companion to [Coding](./coding/) which meticulously documents every + detail. + +Note that this documentation is aimed at the users of TigerBeetle. If you want to understand how it +works under the hood, check out the [internals docs](https://github.com/tigerbeetle/tigerbeetle/tree/main/docs/internals). diff --git a/ocam/docs/TIGER_STYLE.md b/ocam/docs/TIGER_STYLE.md new file mode 100644 index 00000000..d4cefaa6 --- /dev/null +++ b/ocam/docs/TIGER_STYLE.md @@ -0,0 +1,511 @@ +# TigerStyle + +## The Essence Of Style + +> “There are three things extremely hard: steel, a diamond, and to know one's self.” — Benjamin +> Franklin + +TigerBeetle's coding style is evolving. A collective give-and-take at the intersection of +engineering and art. Numbers and human intuition. Reason and experience. First principles and +knowledge. Precision and poetry. Just like music. A tight beat. A rare groove. Words that rhyme and +rhymes that break. Biodigital jazz. This is what we've learned along the way. The best is yet to +come. + +## Why Have Style? + +Another word for style is design. + +> “The design is not just what it looks like and feels like. The design is how it works.” — Steve +> Jobs + +Our design goals are safety, performance, and developer experience. In that order. All three are +important. Good style advances these goals. Does the code make for more or less safety, performance +or developer experience? That is why we need style. + +Put this way, style is more than readability, and readability is table stakes, a means to an end +rather than an end in itself. + +> “...in programming, style is not something to pursue directly. Style is necessary only where +> understanding is missing.” ─ [Let Over +> Lambda](https://letoverlambda.com/index.cl/guest/chap1.html) + +This document explores how we apply these design goals to coding style. First, a word on simplicity, +elegance and technical debt. + +## On Simplicity And Elegance + +Simplicity is not a free pass. It's not in conflict with our design goals. It need not be a +concession or a compromise. + +Rather, simplicity is how we bring our design goals together, how we identify the “super idea” that +solves the axes simultaneously, to achieve something elegant. + +> “Simplicity and elegance are unpopular because they require hard work and discipline to achieve” — +> Edsger Dijkstra + +Contrary to popular belief, simplicity is also not the first attempt but the hardest revision. It's +easy to say “let's do something simple”, but to do that in practice takes thought, multiple passes, +many sketches, and still we may have to [“throw one +away”](https://en.wikipedia.org/wiki/The_Mythical_Man-Month). + +The hardest part, then, is how much thought goes into everything. + +We spend this mental energy upfront, proactively rather than reactively, because we know that when +the thinking is done, what is spent on the design will be dwarfed by the implementation and testing, +and then again by the costs of operation and maintenance. + +An hour or day of design is worth weeks or months in production: + +> “the simple and elegant systems tend to be easier and faster to design and get right, more +> efficient in execution, and much more reliable” — Edsger Dijkstra + +## Technical Debt + +What could go wrong? What's wrong? Which question would we rather ask? The former, because code, +like steel, is less expensive to change while it's hot. A problem solved in production is many times +more expensive than a problem solved in implementation, or a problem solved in design. + +Since it's hard enough to discover showstoppers, when we do find them, we solve them. We don't allow +potential memcpy latency spikes, or exponential complexity algorithms to slip through. + +> “You shall not pass!” — Gandalf + +In other words, TigerBeetle has a “zero technical debt” policy. We do it right the first time. This +is important because the second time may not transpire, and because doing good work, that we can be +proud of, builds momentum. + +We know that what we ship is solid. We may lack crucial features, but what we have meets our design +goals. This is the only way to make steady incremental progress, knowing that the progress we have +made is indeed progress. + +## Safety + +> “The rules act like the seat-belt in your car: initially they are perhaps a little uncomfortable, +> but after a while their use becomes second-nature and not using them becomes unimaginable.” — +> Gerard J. Holzmann + +[NASA's Power of Ten — Rules for Developing Safety Critical +Code](https://spinroot.com/gerard/pdf/P10.pdf) will change the way you code forever. To expand: + +- Use **only very simple, explicit control flow** for clarity. **Do not use recursion** to ensure + that all executions that should be bounded are bounded. Use **only a minimum of excellent + abstractions** but only if they make the best sense of the domain. Abstractions are [never zero + cost](https://isaacfreund.com/blog/2022-05/). Every abstraction introduces the risk of a leaky + abstraction. + +- **Put a limit on everything** because, in reality, this is what we expect—everything has a limit. + For example, all loops and all queues must have a fixed upper bound to prevent infinite loops or + tail latency spikes. This follows the [“fail-fast”](https://en.wikipedia.org/wiki/Fail-fast) + principle so that violations are detected sooner rather than later. Where a loop cannot terminate + (e.g. an event loop), this must be asserted. + +- Use explicitly-sized types like `u32` for everything, avoid architecture-specific `usize`. + +- **Assertions detect programmer errors. Unlike operating errors, which are expected and which must + be handled, assertion failures are unexpected. The only correct way to handle corrupt code is to + crash. Assertions downgrade catastrophic correctness bugs into liveness bugs. Assertions are a + force multiplier for discovering bugs by fuzzing.** + + - **Assert all function arguments and return values, pre/postconditions and invariants.** A + function must not operate blindly on data it has not checked. The purpose of a function is to + increase the probability that a program is correct. Assertions within a function are part of how + functions serve this purpose. The assertion density of the code must average a minimum of two + assertions per function. + + - **[Pair assertions](https://tigerbeetle.com/blog/2023-12-27-it-takes-two-to-contract).** For + every property you want to enforce, try to find at least two different code paths where an + assertion can be added. For example, assert validity of data right before writing it to disk, + and also immediately after reading from disk. + + - On occasion, you may use a blatantly true assertion instead of a comment as stronger + documentation where the assertion condition is critical and surprising. + + - Split compound assertions: prefer `assert(a); assert(b);` over `assert(a and b);`. + The former is simpler to read, and provides more precise information if the condition fails. + + - Use single-line `if` to assert an implication: `if (a) assert(b)`. + + - **Assert the relationships of compile-time constants** as a sanity check, and also to document + and enforce [subtle + invariants](https://github.com/coilhq/tigerbeetle/blob/db789acfb93584e5cb9f331f9d6092ef90b53ea6/src/vsr/journal.zig#L45-L47) + or [type + sizes](https://github.com/coilhq/tigerbeetle/blob/578ac603326e1d3d33532701cb9285d5d2532fe7/src/ewah.zig#L41-L53). + Compile-time assertions are extremely powerful because they are able to check a program's design + integrity _before_ the program even executes. + + - **The golden rule of assertions is to assert the _positive space_ that you do expect AND to + assert the _negative space_ that you do not expect** because where data moves across the + valid/invalid boundary between these spaces is where interesting bugs are often found. This is + also why **tests must test exhaustively**, not only with valid data but also with invalid data, + and as valid data becomes invalid. + + - Assertions are a safety net, not a substitute for human understanding. With simulation testing, + there is the temptation to trust the fuzzer. But a fuzzer can prove only the presence of bugs, + not their absence. Therefore: + - Build a precise mental model of the code first, + - encode your understanding in the form of assertions, + - write the code and comments to explain and justify the mental model to your reviewer, + - and use VOPR as the final line of defense, to find bugs in your and reviewer's understanding + of code. + +- All memory must be statically allocated at startup. **No memory may be dynamically allocated (or + freed and reallocated) after initialization.** This avoids unpredictable behavior that can + significantly affect performance, and avoids use-after-free. As a second-order effect, it is our + experience that this also makes for more efficient, simpler designs that are more performant and + easier to maintain and reason about, compared to designs that do not consider all possible memory + usage patterns upfront as part of the design. + +- Declare variables at the **smallest possible scope**, and **minimize the number of variables in + scope**, to reduce the probability that variables are misused. + +- There's a sharp discontinuity between a function fitting on a screen, and having to scroll to + see how long it is. For this physical reason we enforce a **hard limit of 70 lines per function**. + Art is born of constraints. There are many ways to cut a wall of code into chunks of 70 lines, + but only a few splits will feel right. Some rules of thumb: + + * Good function shape is often the inverse of an hourglass: a few parameters, a simple return + type, and a lot of meaty logic between the braces. + * Centralize control flow. When splitting a large function, try to keep all switch/if + statements in the "parent" function, and move non-branchy logic fragments to helper + functions. Divide responsibility. All control flow should be handled by _one_ function, the rest shouldn't + care about control flow at all. In other words, + ["push `if`s up and `for`s down"](https://matklad.github.io/2023/11/15/push-ifs-up-and-fors-down.html). + * Similarly, centralize state manipulation. Let the parent function keep all relevant state in + local variables, and use helpers to compute what needs to change, rather than applying the + change directly. Keep leaf functions pure. + +- Appreciate, from day one, **all compiler warnings at the compiler's strictest setting**. + +- Whenever your program has to interact with external entities, **don't do things directly in + reaction to external events**. Instead, your program should run at its own pace. Not only does + this make your program safer by keeping the control flow of your program under your control, it + also improves performance for the same reason (you get to batch, instead of context switching on + every event). Additionally, this makes it easier to maintain bounds on work done per time period. + +Beyond these rules: + +- Compound conditions that evaluate multiple booleans make it difficult for the reader to verify + that all cases are handled. Split compound conditions into simple conditions using nested + `if/else` branches. Split complex `else if` chains into `else { if { } }` trees. This makes the + branches and cases clear. Again, consider whether a single `if` does not also need a matching + `else` branch, to ensure that the positive and negative spaces are handled or asserted. + +- Negations are not easy! State invariants positively. When working with lengths and indexes, this + form is easy to get right (and understand): + + ```zig + if (index < length) { + // The invariant holds. + } else { + // The invariant doesn't hold. + } + ``` + + This form is harder, and also goes against the grain of how `index` would typically be compared to + `length`, for example, in a loop condition: + + ```zig + if (index >= length) { + // It's not true that the invariant holds. + } + ``` + +- All errors must be handled. An [analysis of production failures in distributed data-intensive + systems](https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-yuan.pdf) found that + the majority of catastrophic failures could have been prevented by simple testing of error + handling code. + +> “Specifically, we found that almost all (92%) of the catastrophic system failures are the result +> of incorrect handling of non-fatal errors explicitly signaled in software.” + +- **Always motivate, always say why**. Never forget to say why. Because if you explain the rationale + for a decision, it not only increases the hearer's understanding, and makes them more likely to + adhere or comply, but it also shares criteria with them with which to evaluate the decision and + its importance. + +- **Explicitly pass options to library functions at the call site, instead of relying on the + defaults**. For example, write `@prefetch(a, .{ .cache = .data, .rw = .read, .locality = 3 });` + over `@prefetch(a, .{});`. This improves readability but most of all avoids latent, potentially + catastrophic bugs in case the library ever changes its defaults. + +## Performance + +> “The lack of back-of-the-envelope performance sketches is the root of all evil.” — Rivacindela +> Hudsoni + +- Think about performance from the outset, from the beginning. **The best time to solve performance, + to get the huge 1000x wins, is in the design phase, which is precisely when we can't measure or + profile.** It's also typically harder to fix a system after implementation and profiling, and the + gains are less. So you have to have mechanical sympathy. Like a carpenter, work with the grain. + +- **Perform back-of-the-envelope sketches with respect to the four resources (network, disk, memory, + CPU) and their two main characteristics (bandwidth, latency).** Sketches are cheap. Use sketches + to be “roughly right” and land within 90% of the global maximum. + +- Optimize for the slowest resources first (network, disk, memory, CPU) in that order, after + compensating for the frequency of usage, because faster resources may be used many times more. For + example, a memory cache miss may be as expensive as a disk fsync, if it happens many times more. + +- Distinguish between the control plane and data plane. A clear delineation between control plane + and data plane through the use of batching enables a high level of assertion safety without losing + performance. See our [July 2021 talk on Zig SHOWTIME](https://youtu.be/BH2jvJ74npM?t=1958) for + examples. + +- Amortize network, disk, memory and CPU costs by batching accesses. + +- Let the CPU be a sprinter doing the 100m. Be predictable. Don't force the CPU to zig zag and + change lanes. Give the CPU large enough chunks of work. This comes back to batching. + +- Be explicit. Minimize dependence on the compiler to do the right thing for you. + + In particular, extract hot loops into stand-alone functions with primitive arguments without + `self` (see [an example](https://github.com/tigerbeetle/tigerbeetle/blob/0.16.19/src/lsm/compaction.zig#L1932-L1937)). + That way, the compiler doesn't need to prove that it can cache struct's fields in registers, and a + human reader can spot redundant computations easier. + +## Developer Experience + +> “There are only two hard things in Computer Science: cache invalidation, naming things, and +> off-by-one errors.” — Phil Karlton + +### Naming Things + +- **Get the nouns and verbs just right.** Great names are the essence of great code, they capture + what a thing is or does, and provide a crisp, intuitive mental model. They show that you + understand the domain. Take time to find the perfect name, to find nouns and verbs that work + together, so that the whole is greater than the sum of its parts. + +- Use `snake_case` for function, variable, and file names. The underscore is the closest thing we + have as programmers to a space, and helps to separate words and encourage descriptive names. We + don't use Zig's `CamelCase.zig` style for "struct" files to keep the convention simple and + consistent. + +- Do not abbreviate variable names, unless the variable is a primitive integer type used as an + argument to a sort function or matrix calculation. Use long form arguments in scripts: `--force`, + not `-f`. Single letter flags are for interactive usage. + +- Use proper capitalization for acronyms (`VSRState`, not `VsrState`). + +- For the rest, follow the Zig style guide. + +- Add units or qualifiers to variable names, and put the units or qualifiers last, sorted by + descending significance, so that the variable starts with the most significant word, and ends with + the least significant word. For example, `latency_ms_max` rather than `max_latency_ms`. This will + then line up nicely when `latency_ms_min` is added, as well as group all variables that relate to + latency. + +- Infuse names with meaning. For example, `allocator: Allocator` is a good, if boring name, + but `gpa: Allocator` and `arena: Allocator` are excellent. They inform the reader whether + `deinit` should be called explicitly. + +- When choosing related names, try hard to find names with the same number of characters so that + related variables all line up in the source. For example, as arguments to a memcpy function, + `source` and `target` are better than `src` and `dest` because they have the second-order effect + that any related variables such as `source_offset` and `target_offset` will all line up in + calculations and slices. This makes the code symmetrical, with clean blocks that are easier for + the eye to parse and for the reader to check. + +- When a single function calls out to a helper function or callback, prefix the name of the helper + function with the name of the calling function to show the call history. For example, + `read_sector()` and `read_sector_callback()`. + +- Callbacks go last in the list of parameters. This mirrors control flow: callbacks are also + _invoked_ last. + +- _Order_ matters for readability (even if it doesn't affect semantics). On the first read, a file + is read top-down, so put important things near the top. The `main` function goes first. + + The same goes for `structs`, the order is fields then types then methods: + + ```zig + time: Time, + process_id: ProcessID, + + const ProcessID = struct { cluster: u128, replica: u8 }; + const Tracer = @This(); // This alias concludes the types section. + + pub fn init(gpa: std.mem.Allocator, time: Time) !Tracer { + ... + } + ``` + + If a nested type is complex, make it a top-level struct. + + At the same time, not everything has a single right order. When in doubt, consider sorting + alphabetically, taking advantage of big-endian naming. + +- Don't overload names with multiple meanings that are context-dependent. For example, TigerBeetle + has a feature called _pending transfers_ where a pending transfer can be subsequently _posted_ or + _voided_. At first, we called them _two-phase commit transfers_, but this overloaded the + _two-phase commit_ terminology that was used in our consensus protocol, causing confusion. + +- Think of how names will be used outside the code, in documentation or communication. For example, + a noun is often a better descriptor than an adjective or present participle, because a noun can be + directly used in correspondence without having to be rephrased. Compare `replica.pipeline` vs + `replica.preparing`. The former can be used directly as a section header in a document or + conversation, whereas the latter must be clarified. Noun names compose more clearly for derived + identifiers, e.g. `config.pipeline_max`. + +- Zig has named arguments through the `options: struct` pattern. Use it when arguments can be + mixed up. A function taking two `u64` must use an options struct. If an argument can be `null`, + it should be named so that the meaning of `null` literal at the call site is clear. + + Because dependencies like an allocator or a tracer are singletons with unique types, they should + be threaded through constructors positionally, from the most general to the most specific. + +- **Write descriptive commit messages** that inform and delight the reader, because your commit + messages are being read. Note that a pull request description is not stored in the git repository + and is invisible in `git blame`, and therefore is not a replacement for a commit message. + +- Don't forget to say why. Code alone is not documentation. Use comments to explain why you wrote + the code the way you did. Show your workings. + +- Don't forget to say how. For example, when writing a test, think of writing a description at the + top to explain the goal and methodology of the test, to help your reader get up to speed, or to + skip over sections, without forcing them to dive in. + +- Comments are sentences, with a space after the slash, with a capital letter and a full stop, or a + colon if they relate to something that follows. Comments are well-written prose describing the + code, not just scribblings in the margin. Comments after the end of a line _can_ be phrases, with + no punctuation. + +### Cache Invalidation + +- Don't duplicate variables or take aliases to them. This will reduce the probability that state + gets out of sync. + +- If you don't mean a function argument to be copied when passed by value, and if the argument type + is more than 16 bytes, then pass the argument as `*const`. This will catch bugs where the caller + makes an accidental copy on the stack before calling the function. + +- Construct larger structs _in-place_ by passing an _out pointer_ during initialization. + + In-place initializations can assume **pointer stability** and **immovable types** while + eliminating intermediate copy-move allocations, which can lead to undesirable stack growth. + + Keep in mind that in-place initializations are viral — if any field is initialized + in-place, the entire container struct should be initialized in-place as well. + + **Prefer:** + ```zig + fn init(target: *LargeStruct) !void { + target.* = .{ + // in-place initialization. + }; + } + + fn main() !void { + var target: LargeStruct = undefined; + try target.init(); + } + ``` + + **Over:** + ```zig + fn init() !LargeStruct { + return LargeStruct { + // moving the initialized object. + } + } + + fn main() !void { + var target = try LargeStruct.init(); + } + ``` + +- **Shrink the scope** to minimize the number of variables at play and reduce the probability that + the wrong variable is used. + +- Calculate or check variables close to where/when they are used. **Don't introduce variables before + they are needed.** Don't leave them around where they are not. This will reduce the probability of + a POCPOU (place-of-check to place-of-use), a distant cousin to the infamous + [TOCTOU](https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use). Most bugs come down to a + semantic gap, caused by a gap in time or space, because it's harder to check code that's not + contained along those dimensions. + +- Use simpler function signatures and return types to reduce dimensionality at the call site, the + number of branches that need to be handled at the call site, because this dimensionality can also + be viral, propagating through the call chain. For example, as a return type, `void` trumps `bool`, + `bool` trumps `u64`, `u64` trumps `?u64`, and `?u64` trumps `!u64`. + +- Ensure that functions run to completion without suspending, so that precondition assertions are + true throughout the lifetime of the function. These assertions are useful documentation without a + suspend, but may be misleading otherwise. + +- Be on your guard for **[buffer bleeds](https://en.wikipedia.org/wiki/Heartbleed)**. This is a + buffer underflow, the opposite of a buffer overflow, where a buffer is not fully utilized, with + padding not zeroed correctly. This may not only leak sensitive information, but may cause + deterministic guarantees as required by TigerBeetle to be violated. + +- Use newlines to **group resource allocation and deallocation**, i.e. before the resource + allocation and after the corresponding `defer` statement, to make leaks easier to spot. + +### Off-By-One Errors + +- **The usual suspects for off-by-one errors are casual interactions between an `index`, a `count` + or a `size`.** These are all primitive integer types, but should be seen as distinct types, with + clear rules to cast between them. To go from an `index` to a `count` you need to add one, since + indexes are _0-based_ but counts are _1-based_. To go from a `count` to a `size` you need to + multiply by the unit. Again, this is why including units and qualifiers in variable names is + important. + +- Show your intent with respect to division. For example, use `@divExact()`, `@divFloor()` or + `div_ceil()` to show the reader you've thought through all the interesting scenarios where + rounding may be involved. + +### Style By The Numbers + +- Run `zig fmt`. + +- Use 4 spaces of indentation, rather than 2 spaces, as that is more obvious to the eye at a + distance. + +- Hard limit all line lengths, without exception, to at most 100 columns for a good typographic + "measure". Use it up. Never go beyond. Nothing should be hidden by a horizontal scrollbar. Let + your editor help you by setting a column ruler. To wrap a function signature, call or data + structure, add a trailing comma, close your eyes and let `zig fmt` do the rest. + + Similar to function length, the motivation behind the number 100 is physical: just enough + to fit two copies of the code side-by-side on a screen. + +- Add braces to the `if` statement unless it fits on a single line for consistency and defense in + depth against "goto fail;" bugs. + +### Dependencies + +TigerBeetle has **a “zero dependencies” policy**, apart from the Zig toolchain. Dependencies, in +general, inevitably lead to supply chain attacks, safety and performance risk, and slow install +times. For foundational infrastructure in particular, the cost of any dependency is further +amplified throughout the rest of the stack. + +### Tooling + +Similarly, tools have costs. A small standardized toolbox is simpler to operate than an array of +specialized instruments each with a dedicated manual. Our primary tool is Zig. It may not be the +best for everything, but it's good enough for most things. We invest into our Zig tooling to ensure +that we can tackle new problems quickly, with a minimum of accidental complexity in our local +development environment. + +> “The right tool for the job is often the tool you are already using—adding new tools has a higher +> cost than many people appreciate” — John Carmack + +For example, the next time you write a script, instead of `scripts/*.sh`, write `scripts/*.zig`. + +This not only makes your script cross-platform and portable, but introduces type safety and +increases the probability that running your script will succeed for everyone on the team, instead of +hitting a Bash/Shell/OS-specific issue. + +Standardizing on Zig for tooling is important to ensure that we reduce dimensionality, as the team, +and therefore the range of personal tastes, grows. This may be slower for you in the short term, but +makes for more velocity for the team in the long term. + +## The Last Stage + +At the end of the day, keep trying things out, have fun, and remember—it's called TigerBeetle, not +only because it's fast, but because it's small! + +> You don’t really suppose, do you, that all your adventures and escapes were managed by mere luck, +> just for your sole benefit? You are a very fine person, Mr. Baggins, and I am very fond of you; +> but you are only quite a little fellow in a wide world after all!” +> +> “Thank goodness!” said Bilbo laughing, and handed him the tobacco-jar. diff --git a/ocam/docs/coding/README.md b/ocam/docs/coding/README.md new file mode 100644 index 00000000..ee169af1 --- /dev/null +++ b/ocam/docs/coding/README.md @@ -0,0 +1,25 @@ +# Coding + +This section is aimed at programmers building applications on top of TigerBeetle. It is organized +as a series of loosely connected guides which can be read in any order. + +- [System Architecture](./system-architecture.md) paints the big picture. +- [Data Modeling](./data-modeling.md) shows how to map business-level entities to the primitives + provided by TigerBeetle. +- [Financial Accounting](./financial-accounting.md), a deep dive into double-entry bookkeeping. +- [Requests](./requests.md) outlines the database interface. +- [Reliable Transaction Submission](./reliable-transaction-submission.md) explains the end-to-end + principle and how it helps to avoid double spending. +- [Two-Phase Transfers](./two-phase-transfers.md) introduces pending transfers, one of the most + powerful primitives built into TigerBeetle. +- [Linked Events](./linked-events.md) shows how several transfers can be chained together into a + larger transaction, which succeeds or fails atomically. +- [Time](./time.md) lists the guarantees provided by the TigerBeetle cluster clock. +- [Recipes](./recipes/) is a library of ready-made solutions for common business requirements such + as a currency exchange. +- [Clients](./clients/) shows how to use TigerBeetle from the comfort of .NET, Go, Java, Node.js, + or Python. +- [API Changes](./api-changes.md) describes changes introduced in the TigerBeetle Client libraries. +
+ Subscribe to the [tracking issue #2231](https://github.com/tigerbeetle/tigerbeetle/issues/2231) + to receive notifications about breaking changes! diff --git a/ocam/docs/coding/api-changes.md b/ocam/docs/coding/api-changes.md new file mode 100644 index 00000000..5b5b76ab --- /dev/null +++ b/ocam/docs/coding/api-changes.md @@ -0,0 +1,657 @@ +# API changes + +## [0.17.0](https://github.com/tigerbeetle/tigerbeetle/releases/tag/0.17.0) + +Applications using the TigerBeetle Client `0.16.x` **will require changes** when upgrading to +`0.17.x`. + +Future releases of the TigerBeetle cluster tagged as `0.17.x` will continue to support old clients +for an extended period, giving application developers time to plan a smooth upgrade to the new +client library while independently upgrading the cluster to newer releases. + +### New return type for `create_accounts` and `create_transfers`. + +The TigerBeetle release `0.17.0` introduced a new API for handling results when creating +[Accounts](../reference/requests/create_accounts.md) and +[Transfers](../reference/requests/create_transfers.md). + +The previous API for [`create_accounts`](../reference/requests/create_accounts.md) and +[`create_transfers`](../reference/requests/create_transfers.md) reported outcomes only for +failed events, returning a _sparse array_ of results. Each result included the `index` of the +failed element within the event batch and the corresponding error code. Successfully created +events were not returned by the TigerBeetle cluster, and the application could safely assume +them as `ok`. + +While this approach prioritized saving network bandwidth by omitting results for the common +_happy path_, it didn’t provide enough information about the outcome. + +The new protocol departs from the _sparse array_ style, returning the status of each event, +including the successfully created ones, along with the `timestamp` when they were processed +by the TigerBeetle cluster. + +Applications can now benefit from knowing the `timestamp` assigned to a successfully created +[`Account`](../reference/account.md#timestamp) or +[`Transfer`](../reference/transfer.md#timestamp), while also handling alternative paths more +efficiently by knowing _when_ validation occurred for those that couldn’t be created. + +The enums CreateAccountResult and CreateTransferResult were +renamed to CreateAccountStatus and CreateTransferStatus, +and the status code `ok` was renamed to `created`. + +This makes it explicit whether an event became part of the database state (`created` and `exists`) +or not. By removing the duality of _ok_ and _errors_, applications can handle results more clearly +according to their policies, since some outcomes might not be considered a failure by application +logic — for example, a `Transfer` not being created due to balance checks or pending timeouts +isn’t necessarily an _error_. + +Likewise, the `exists` status now reports the same `timestamp` returned by `created`, +allowing the application to treat both cases consistently. + +The result types CreateAccountsResult and +CreateTransfersResult were renamed to the singular form, +CreateAccountResult and CreateTransferResult. + +```zig +// Before: +pub const CreateAccountsResult = extern struct { + index: u32, + result: CreateAccountResult, +}; + +// After: +pub const CreateAccountResult = extern struct { + timestamp: u64, + status: CreateAccountStatus, + reserved: u32 = 0, +}; +``` + +```zig +// Before: +pub const CreateTransfersResult = extern struct { + index: u32, + result: CreateTransferResult, +}; + +// After: +pub const CreateTransferResult = extern struct { + timestamp: u64, + status: CreateTransferStatus, + reserved: u32 = 0, +}; +``` + +### Query limits. + +The previous API did not validate the maximum range of the fields +[`AccountFilter.limit`](../reference/account-filter.md#limit) and +[`QueryFilter.limit`](../reference/query-filter.md#limit). +Values up to `2^32 - 1` were accepted, and the TigerBeetle cluster was +responsible for capping the number of results to fit the message size. + +Now the TigerBeetle client enforces a valid `limit` and rejects requests with limits +greater than the [maximum batch size](../coding/requests.md#batching-events), +returning the `too_much_data` error code. + +This is the same behavior as when, for example, +[`create_accounts`](../reference/requests/create_accounts.md) or +[`create_transfers`](../reference/requests/create_transfers.md) +are called with more than _8189_ events. + +The operations [`query_accounts`](../reference/requests/query_accounts.md), +[`query_transfers`](../reference/requests/query_transfers.md), +[`get_account_transfers`](../reference/requests/get_account_transfers.md), and +[`get_account_balances`](../reference/requests/get_account_balances.md) are affected. + +### Client breaking changes + +Along with the new result types, some client libraries have changed the API to be more +idiomatic, for naming consistency, or even due to bug fixes in the previous API. + +See below is a list of API changes specific to each client library: + +
.NET + +### .NET Client breaking changes + +The TigerBeetle .NET Client `0.17.0` introduced the following breaking changes: + +- The enum types CreateAccountResult and + CreateTransferResult, with the status codes for the + [`create_accounts`](../reference/requests/create_accounts.md) and + [`create_transfers`](../reference/requests/create_transfers.md) operations respectively, + were renamed to CreateAccountStatus and + CreateTransferStatus. + + The enum value `Ok`, present in both enum types, was replaced by the new status code + `Created`, which indicates that the event was successfully created. + + |Type |Before | After | + |-----------|---------------------------------------|-----------------------------------------| + |Enum |`CreateAccountResult` |`CreateAccountStatus` | + |Enum Value |`CreateAccountResult.Ok` |`CreateAccountStatus.Created` | + |Enum |`CreateTransferResult` |`CreateTransferStatus` | + |Enum Value |`CreateTransferResult.Ok` |`CreateTransferStatus.Created` | + +- The result types CreateAccountsResult and + CreateTransfersResult were renamed to the singular form, + CreateAccountResult and CreateTransferResult. + + The property `Index` was removed, since each result value corresponds to an event in the batch + at the same index. + + Additional changes include the new `Timestamp` property and renaming the `Result` property to + `Status`, reflecting the change to the associated enums. + + |Type |Before | After | + |-----------|---------------------------------------|-----------------------------------------| + |Struct |`CreateAccountsResult` |`CreateAccountResult` | + |Property |`CreateAccountsResult.Index` |_removed_ | + |Property |_NA_ |`CreateAccountResult.Timestamp` | + |Property |`CreateAccountsResult.Result` |`CreateAccountResult.Status` | + |Struct |`CreateTransfersResult` |`CreateTransferResult` | + |Property |`CreateTransfersResult.Index` |_removed_ | + |Property |_NA_ |`CreateTransferResult.Timestamp` | + |Property |`CreateTransfersResult.Result` |`CreateTransferResult.Status` | + +- Non-batched methods of the `Client` class that received a single event such as + `CreateAccount`, `CreateTransfer`, `LookupAccount`, and `LookupTransfer` were removed. + + The batched versions that take an array of events such as + CreateAccounts, CreateTransfers, + LookupAccounts, and LookupTransfers remain unchanged. + +- New exceptions were introduced for conditions that can be handled by the application. + The `PacketStatus` enum is now internal and the `RequestException` was made `abstract`. + + The client can be explicitly closed by calling `Client.Close()`, whereas previously the only + way to close a client was through `Client.Dispose()`. + Interacting with a closed client now throws a `ClientClosedException` + instead of an `ObjectDisposedException`. + + |Type |Before | After | + |-----------|---------------------------------------|-----------------------------------------| + |Enum |`PacketStatus` |_removed_ | + |Exception |`RequestException` |_removed_ | + |Method | _added_ |`Client.Close()` | + |Exception | |`ClientClosedException` | + |Exception | |`ClientEvictedException` | + |Exception | |`ClientReleaseException` | + |Exception | |`TooMuchDataException` | + +### Example: + +Before: +```c# +CreateTransfersResult[] transferErrors = client.CreateTransfers(transfers); +if (transferErrors.Length > 0) { + // Error handling ... +} +``` + +After: +```c# +CreateTransferResult[] transferResults = client.CreateTransfers(transfers); +Assert.AreEqual(transferResults.Length, transfers.Length); +foreach(CreateTransferResult result in transferResults) +{ + switch(result.Status) + { + case CreateTransferStatus.Created: + case CreateTransferStatus.Exists: + // Successfully created. + break; + default: + // Could not be created. + break; + } +} +``` + +For more details, please refer to the +[.NET client reference page](https://docs.tigerbeetle.com/coding/clients/dotnet). + +

+ +
Go + +### Go Client breaking changes + +The TigerBeetle Go Client `0.17.0` introduced the following breaking changes: + +- The enum types CreateAccountResult and + CreateTransferResult, with the status codes for the + [`create_accounts`](../reference/requests/create_accounts.md) and + [`create_transfers`](../reference/requests/create_transfers.md) operations respectively, + were renamed to CreateAccountStatus and + CreateTransferStatus. + + The enum values `AccountOK` and `TransferOK`, were replaced by the new status codes + `AccountCreated` and `TransferCreated`, which indicates the event was successfully + created by the operation. + + |Type |Before | After | + |-----------|---------------------------------------|-----------------------------------------| + |Enum |`CreateAccountResult` |`CreateAccountStatus` | + |Enum Value |`AccountOK` |`AccountCreated` | + |Enum |`CreateTransferResult` |`CreateTransferStatus` | + |Enum Value |`TransferOK` |`TransferCreated` | + +- The result types `AccountEventResult` and `TransferEventResult` were renamed to + `CreateAccountResult` and `CreateTransferResult`. + + The field `Index` was removed, since each result value corresponds to an event in the batch + at the same index. + + Additional changes include the new `Timestamp` field and renaming the `Result` field to + `Status`, reflecting the change to the associated enums. + + |Type |Before | After | + |-----------|---------------------------------------|-----------------------------------------| + |Type |`AccountEventResult` |`CreateAccountResult` | + |Field |`AccountEventResult.Index` |_removed_ | + |Field |_NA_ |`CreateAccountResult.Timestamp` | + |Field |`AccountEventResult.Result` |`CreateAccountResult.Status` | + |Type |`TransferEventResult` |`CreateTransferResult` | + |Field |`TransferEventResult.Index` |_removed_ | + |Field |_NA_ |`CreateTransferResult.Timestamp` | + |Field |`TransferEventResult.Result` |`CreateTransferResult.Status` | + +- The `types` and `errors` packages were removed, consolidating all definitions under the root + `tigerbeetle_go` package. + + The `Err` types were removed in favor of simple value declarations for error codes, + allowing the use of idiomatic constructions such as `errors.Is(err, ErrTooMuchData)`. + +- Conversions between `UInt128` and `big.Int` now return and accept a pointer to a big integer + `*big.Int`, making the API more idiomatic. + + |Type |Before | After | + |----------|----------------------------------------|-----------------------------------------| + |Function |`BigInt() big.Int` |`BigInt() *big.Int` | + |Function |`BigIntToUint128(value big.Int) Uint128`|`BigIntToUint128(value *big.Int) Uint128`| + +### Example: + +Before: +```go +import ( + . "github.com/tigerbeetle/tigerbeetle-go" + . "github.com/tigerbeetle/tigerbeetle-go/pkg/types" +) + +var transferErrors []TransferEventResult +var err error + +transferErrors, err = client.CreateTransfers(transfers); +if err != nil { + // Request error ... +} +if len(transferErrors) > 0 { + // Error handling ... +} +``` + +After: +```go +import ( + . "github.com/tigerbeetle/tigerbeetle-go" +) + +var transferResults []CreateTransferResult +var err error + +transferResults, err = client.CreateTransfers(transfers); +if err != nil { + // Request error ... +} +for _, result := range transferResults { + switch result.Status { + case TransferCreated, TransferExists: + // Successfully created. + default: + // Could not be created. + } +} +``` + +For more details, please refer to the +[Go client reference page](https://docs.tigerbeetle.com/coding/clients/go). + +

+ +
Java + +### Java Client breaking changes + +The TigerBeetle Java Client `0.17.0` introduced the following breaking changes: + +- The enum types CreateAccountResult and + CreateTransferResult, with the status codes for the + [`create_accounts`](../reference/requests/create_accounts.md) and + [`create_transfers`](../reference/requests/create_transfers.md) operations respectively, + were renamed to CreateAccountStatus and + CreateTransferStatus. + + The enum value `Ok`, present in both enum types, was replaced by the new status code + `Created`, which indicates that the event was successfully created. + + |Type |Before | After | + |-----------|---------------------------------------|------------------------------------------| + |Enum |`CreateAccountResult` |`CreateAccountStatus` | + |Enum Value |`CreateAccountResult.Ok` |`CreateAccountStatus.Created` | + |Enum |`CreateTransferResult` |`CreateTransferStatus` | + |Enum Value |`CreateTransferResult.Ok` |`CreateTransferStatus.Created` | + + +- The result types `CreateAccountResultBatch` and `CreateTransferResultBatch`, + had the `getIndex()` property removed, since each result value corresponds to one event in the + batch at the same index. + + Additional changes include the new `getTimestamp()` property and renaming the + `getResult()` property to `getStatus()`, reflecting the change to the associated enums. + + |Type |Before | After | + |-----------|---------------------------------------|------------------------------------------| + |Method |`CreateAccountResultBatch.getIndex()` |_removed_ | + |Method |_NA_ |`CreateAccountResultBatch.getTimestamp()` | + |Method |`CreateAccountResultBatch.getResult()` |`CreateAccountResultBatch.getStatus()` | + |Method |`CreateTransferResultBatch.getIndex()` |_removed_ | + |Method |_NA_ |`CreateTransferResultBatch.getTimestamp()`| + |Method |`CreateTransferResultBatch.getResult()`|`CreateTransferResultBatch.getStatus()` | + +- All the _blocking_ methods of the + [`Client`](https://javadoc.io/doc/com.tigerbeetle/tigerbeetle-java/latest/com.tigerbeetle/com/tigerbeetle/Client.html) + class (such as `createAccounts`, `createTransfers`, and others) may throw the _checked_ exception + [`InterruptedException`](https://docs.oracle.com/javase/8/docs/api/java/lang/InterruptedException.html) + to signal that the waiting thread was interrupted by the Java environment. + The non-blocking versions of the same methods that return a + [`CompletableFuture`](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html) + remain unchanged. + +- New exceptions were introduced for conditions that can be handled by the application. + The `PacketStatus` enum is now internal and the `RequestException` was removed. + + All operations throw `ClientClosedException` instead of `IllegalStateException` + if the client is closed. + + |Type |Before | After | + |-----------|---------------------------------------|-----------------------------------------| + |Enum |`PacketStatus` |_removed_ | + |Exception |`RequestException` |_abstract class_ | + |Exception |`ClientClosedException` |_extends_ `RequestException` | + |Exception | |`ClientEvictedException` | + |Exception | |`ClientReleaseException` | + |Exception | |`TooMuchDataException` | + +### Example: + +Before: +```java +CreateTransferResultBatch transferErrors = client.createTransfers(transfers); +if (transferErrors.getLength() > 0) { + // Error handling ... +} +``` + +After: +```java +try { + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + assert transferResults.getLength() == transfers.getLength(); + while (transferResults.next()) { + switch (transferResults.getStatus()) { + case Created: + case Exists: + // Successfully created. + break; + default: + // Could not be created. + break; + } + } +} catch (InterruptedException exception) { + // The thread was interrupted. +} +``` + +For more details, please refer to the +[Java client reference page](./clients/java). + +

+ +
Node.js + +### Node.js Client breaking changes + +The TigerBeetle Node.js Client `0.17.0` introduced the following breaking changes: + +- The enum types `CreateAccountError` and `CreateTransferError`, with the status codes + for the [`create_accounts`](../reference/requests/create_accounts.md) and + [`create_transfers`](../reference/requests/create_transfers.md) operations respectively, + were renamed to `CreateAccountStatus` and `CreateTransferStatus`. + + The enum value `ok`, present in both enum types, was replaced by the new status code + `created`, which indicates that the event was successfully created. + + |Type |Before | After | + |-----------|---------------------------------------|-----------------------------------------| + |Enum |`CreateAccountError` |`CreateAccountStatus` | + |Enum Value |`CreateAccountError.ok` |`CreateAccountStatus.created` | + |Enum |`CreateTransferError` |`CreateTransferStatus` | + |Enum Value |`CreateTransferError.ok` |`CreateTransferStatus.created` | + +- The result types `CreateAccountsError` and `CreateTransfersError` were renamed to + `CreateAccountResult` and `CreateTransferResult`. + + The field `index` was removed, since each result value corresponds to an event in the batch + at the same index. + + Additional changes include the new `timestamp` field and renaming the + `result` field to `status`, reflecting the change to the associated enums. + + |Type |Before | After | + |-----------|---------------------------------------|-----------------------------------------| + |Type |`CreateAccountsError` |`CreateAccountResult` | + |Field |`CreateAccountsError.index` |_removed_ | + |Field |_NA_ |`CreateAccountResult.timestamp` | + |Field |`CreateAccountsError.result` |`CreateAccountResult.status` | + |Type |`CreateTransfersError` |`CreateTransferResult` | + |Field |`CreateTransfersError.index` |_removed_ | + |Field |_NA_ |`CreateTransferResult.timestamp` | + |Field |`CreateTransfersError.result` |`CreateTransferResult.status` | + +- New error type `RequestError` was introduced for conditions that can be handled by the + application. + Match the `RequestError.code` property against the constants defined in `ErrorCodes` to + determine the specific failure. + + |Type |Before | After | + |-----------|---------------------------------------|-----------------------------------------| + |Type | |`ErrorCodes` | + |Error | |`RequestError` | + + +### Example: + +Before: +```typescript +const transferErrors: CreateTransfersError[] = await client.createTransfers(transfers); +if (transferErrors.length > 0) { + // Error handling ... +} +``` + +After: +```typescript +const transferResults: CreateTransferResult[] = await client.createTransfers(transfers); +assert.strictEqual(transferResults.length, transfers.length); +for (const result of transferResults) { + switch (result.status) { + case CreateTransferStatus.created: + case CreateTransferStatus.exists: + // Successfully created. + break; + default: + // Could not be created. + break; + } +} +``` + +For more details, please refer to the +[Node.js client reference page](./clients/node). + +

+ +
Python
+ +### Python Client breaking changes + +The TigerBeetle Python Client `0.17.0` introduced the following breaking changes: + +- The enum types `CreateAccountResult` and `CreateTransferResult`, with the status codes + for the [`create_accounts`](../reference/requests/create_accounts.md) and + [`create_transfers`](../reference/requests/create_transfers.md) operations respectively, + were renamed to `CreateAccountStatus` and `CreateTransferStatus`. + + The enum value `OK`, present in both enum types, was replaced by the new status code + `CREATED`, which indicates that the event was successfully created. + + |Type |Before | After | + |-----------|---------------------------------------|-----------------------------------------| + |Enum |`CreateAccountResult` |`CreateAccountStatus` | + |Enum Value |`CreateAccountResult.OK` |`CreateAccountStatus.CREATED` | + |Enum |`CreateTransferResult` |`CreateTransferStatus` | + |Enum Value |`CreateTransferResult.OK` |`CreateTransferStatus.CREATED` | + +- The result types CreateAccountsResult and + CreateTransfersResult were renamed to the singular form, + CreateAccountResult and CreateTransferResult. + + The field `index` was removed, since each result value corresponds to an event in the batch + at the same index. + + Additional changes include the new `timestamp` field and renaming the `result` field to + `status`, reflecting the change to the associated enums. + + |Type |Before | After | + |-----------|---------------------------------------|-----------------------------------------| + |Type |`CreateAccountsResult` |`CreateAccountResult` | + |Field |`CreateAccountsResult.index` |_removed_ | + |Field |_NA_ |`CreateAccountResult.timestamp` | + |Field |`CreateAccountsResult.result` |`CreateAccountResult.status` | + |Type |`CreateTransfersResult` |`CreateTransferResult` | + |Field |`CreateTransfersResult.index` |_removed_ | + |Field |_NA_ |`CreateTransferResult.timestamp` | + |Field |`CreateTransfersResult.result` |`CreateTransferResult.status` | + +- The constant `amount_max` was renamed to `AMOUNT_MAX`. + +- New exceptions were introduced for conditions that can be handled by the application. + The `PacketStatus` enum is now internal and the `PacketError` exception was removed. + + |Type |Before | After | + |-----------|---------------------------------------|-----------------------------------------| + |Enum |`PacketStatus` |_removed_ | + |Exception |`PacketError` |_removed_ | + |Exception | |`ClientEvictedError` | + |Exception | |`ClientReleaseTooLowError` | + |Exception | |`ClientReleaseTooHighError` | + |Exception | |`TooMuchDataError` | + +- Removed all default initialization values from the types `AccountFilter` and `QueryFilter`. + +### Example: + +Before: +```python +transfer_errors = client.create_transfers(transfers) +if len(transfer_errors) > 0: + # Error handling ... +``` + +After: +```python +transfer_results = client.create_transfers(transfers) +assert len(transfer_results) == len(transfers) +for result in transfer_results: + if result.status == tb.CreateAccountResult.CREATED or \ + result.status == tb.CreateAccountResult.EXISTS: + # Successfully created. + else: + # Could not be created. +``` + +For more details, please refer to the +[Python client reference page](./clients/python). + +

+ +
Rust
+ +### Rust Client breaking changes + +The TigerBeetle Rust Client `0.17.0` also introduced the new API. +However, the Rust client is not yet publicly available on Crates.io at the time of this release, +so they are not considered breaking changes. + +For more details, please refer to the +[Rust client reference page](./clients/rust). + +
+ +## [0.16.33](https://github.com/tigerbeetle/tigerbeetle/releases/tag/0.16.33) + +### Oldest supported client version is `0.16.4` + +Please make sure that all of your clients are running on at `0.16.4` or newer before upgrading to this release! + +## [0.16.4](https://github.com/tigerbeetle/tigerbeetle/releases/tag/0.16.4) + +### Transient Failures + +Clients have the strong guarantee that a `Transfer.id` that has once failed due to a transient error code will never succeed again if retried. + +Transient failures occur when semantically valid transfers fail due to reasons that depend exclusively on the database status. For example, when an `account` does not have enough credits to fulfill the `transfer`. + +Please refer to the [documentation](../reference/requests/create_transfers.md) for a complete list of all transient failures. + +As of `0.16.4`: + +- New precedence order for the `create_transfers` and `create_accounts` error codes. + The idempotency checks (e.g., all `exists_*` cases) are now validated prior to the semantical checks. + + For example, when submitting a `transfer` with the `id` of an existing one, clients might receive `exists_with_different_` instead of `_must_not_be_zero`. + + _This is **not** a breaking API change and affects all supported client versions._ + +- `create_transfers` now returns `exists_with_different_ledger` + Reflecting the new precedence order change. + + _This is a breaking API change which is gated in the state machine by the client's release._ + _Clients of previous versions will receive `transfer_must_have_the_same_ledger_as_accounts` instead._ + +- Added a new result code + [`id_already_failed`](../reference/requests/create_transfers.md#id_already_failed). + It is returned by `create_transfers` when the `Transfer.id` was already used in a previous attempt that resulted in a transient failure. + + _This is a breaking API change which is gated in the state machine by the client's release._ + _Clients of previous versions will be able to successfully create transfers submitted with an `id` that previously failed, since it passes all validations._ + + +## [0.16.0](https://github.com/tigerbeetle/tigerbeetle/releases/tag/0.16.0) + +### Zero-amount transfers + +Zero-amount transfers are now permitted. + +This is a _breaking API change_ which is gated in the state machine by the _client's_ release. + +As of `0.16.0`: + +- Clients no longer return `amount_must_not_be_zero` to `create_transfers`. + +- Balancing transfers no longer use `amount=0` as a sentinel value to represent "transfer as much as possible". For that purpose, use `AMOUNT_MAX` (exact constant name depends on client implementation) instead. + +- Post-pending transfers no longer use `amount=0` as a sentinel value to represent "transfer full pending amount". For that purpose, use `AMOUNT_MAX` instead. In `0.16.0`, sending a post-pending transfer with `amount=0` will successfully post `amount=0`, voiding the remainder (i.e. voiding the whole pending amount). diff --git a/ocam/docs/coding/clients/README.md b/ocam/docs/coding/clients/README.md new file mode 100644 index 00000000..71dc7b83 --- /dev/null +++ b/ocam/docs/coding/clients/README.md @@ -0,0 +1,19 @@ +# Clients + +TigerBeetle has official client libraries for the following languages: + +- [.NET](/src/clients/dotnet/) ([nuget package](https://www.nuget.org/packages/tigerbeetle)). +- [Go](/src/clients/go/) ([package](https://github.com/tigerbeetle/tigerbeetle-go), [API docs](https://pkg.go.dev/github.com/tigerbeetle/tigerbeetle-go)). +- [Java](/src/clients/java/) ([maven central package](https://central.sonatype.com/artifact/com.tigerbeetle/tigerbeetle-java), [API docs](https://javadoc.io/doc/com.tigerbeetle/tigerbeetle-java/)). +- [Node.js](/src/clients/node/) ([npm package](https://www.npmjs.com/package/tigerbeetle-node)). +- [Python](/src/clients/python/) ([PyPi package](https://pypi.org/project/tigerbeetle/)). +- [Ruby](/src/clients/ruby/) ([RubyGems package](https://rubygems.org/gems/tigerbeetle)). +- [Rust](/src/clients/rust/) ([Cargo package](https://crates.io/crates/tigerbeetle)). + +See [API Changes](../api-changes.md) for the history of changes introduced in the TigerBeetle +Client libraries.
+Subscribe to the [tracking issue #2231](https://github.com/tigerbeetle/tigerbeetle/issues/2231) +to receive notifications about breaking changes. + +Please report any client bugs to the +[main issue tracker](https://github.com/tigerbeetle/tigerbeetle/issues). diff --git a/ocam/docs/coding/data-modeling.md b/ocam/docs/coding/data-modeling.md new file mode 100644 index 00000000..71777df7 --- /dev/null +++ b/ocam/docs/coding/data-modeling.md @@ -0,0 +1,242 @@ +# Data Modeling + +This section describes various aspects of the TigerBeetle data model and provides some suggestions +for how you can map your application's requirements onto the data model. + +## Accounts, Transfers, and Ledgers + +The TigerBeetle data model consists of [`Account`s](../reference/account.md), +[`Transfer`s](../reference/transfer.md), and ledgers. + +### Ledgers + +Ledgers partition accounts into groups that may represent a currency or asset type or any other +logical grouping. Only accounts on the same ledger can transact directly, but you can use atomically +linked transfers to implement [currency exchange](./recipes/currency-exchange.md). + +Ledgers are only stored in TigerBeetle as a numeric identifier on the +[account](../reference/account.md#ledger) and [transfer](../reference/transfer.md) data +structures. You may want to store additional metadata about each ledger in a control plane +[database](./system-architecture.md). + +You can also use different ledgers to further partition accounts, beyond asset type. For example, if +you have a multi-tenant setup where you are tracking balances for your customers' end-users, you +might have a ledger for each of your customers. If customers have end-user accounts in multiple +currencies, each of your customers would have multiple ledgers. + +## Debits vs Credits + +TigerBeetle tracks each account's cumulative posted debits and cumulative posted credits. In +double-entry accounting, an account balance is the difference between the two -- computed as either +`debits - credits` or `credits - debits`, depending on the type of account. It is up to the +application to compute the balance from the cumulative debits/credits. + +From the database's perspective the distinction is arbitrary, but accounting conventions recommend +using a certain balance type for certain types of accounts. + +If you are new to thinking in terms of debits and credits, read the +[deep dive on financial accounting](./financial-accounting.md) to get a better understanding of +double-entry bookkeeping and the different types of accounts. + +### Debit Balances + +`balance = debits - credits` + +By convention, debit balances are used to represent: + +- Operator's Assets +- Operator's Expenses + +To enforce a positive (non-negative) debit balance, use +[`flags.credits_must_not_exceed_debits`](../reference/account.md#flagscredits_must_not_exceed_debits). + +To keep an account's balance between an upper and lower bound, see the +[Balance Bounds recipe](./recipes/balance-bounds.md). + +### Credit Balances + +`balance = credits - debits` + +By convention, credit balances are used to represent: + +- Operator's Liabilities +- Equity in the Operator's Business +- Operator's Income + +To enforce a positive (non-negative) credit balance, use +[`flags.debits_must_not_exceed_credits`](../reference/account.md#flagsdebits_must_not_exceed_credits). +For example, a customer account that is represented as an Operator's Liability would use this flag +to ensure that the balance cannot go negative. + +To keep an account's balance between an upper and lower bound, see the +[Balance Bounds recipe](./recipes/balance-bounds.md). + +### Compound Transfers + +`Transfer`s in TigerBeetle debit a single account and credit a single account. You can read more +about implementing compound transfers in +[Multi-Debit, Multi-Credit Transfers](./recipes/multi-debit-credit-transfers.md). + +## Fractional Amounts and Asset Scale + +To maximize precision and efficiency, [`Account`](../reference/account.md) debits/credits and +[`Transfer`](../reference/transfer.md) amounts are unsigned 128-bit integers. However, +currencies are often denominated in fractional amounts. + +To represent a fractional amount in TigerBeetle, **map the smallest useful unit of the fractional +currency to 1**. Consider all amounts in TigerBeetle as a multiple of that unit. + +Applications may rescale the integer amounts as necessary when rendering or interfacing with other +systems. But when working with fractional amounts, calculations should be performed on the integers +to avoid loss of precision due to floating-point approximations. + +### Asset Scale + +When the multiplier is a power of 10 (e.g. `10 ^ n`), then the exponent `n` is referred to as an +_asset scale_. For example, representing USD in cents uses an asset scale of `2`. + +#### Examples + +- `1 USD` = `100` cents. Using an asset scale of `2`, + - The fractional amount `0.45 USD` is represented as the integer `45`. + - The fractional amount `123.00 USD` is represented as the integer `12300`. + - The fractional amount `123.45 USD` is represented as the integer `12345`. + +- `1 JPY` = `1` yen. Using an asset scale of `0`, + - The fractional amount `123 JPY` is represented as the integer `123`. + +- `1 KWD` = `1000` fils. Using an asset scale of `3`, + - The fractional amount `0.450 KWD` is represented as the integer `450`. + - The fractional amount `123.000 KWD` is represented as the integer `123000`. + - The fractional amount `123.450 KWD` is represented as the integer `123450`. + +The other direction works as well. If the smallest useful unit of an asset is `10, 000, 000` units, +then it can be scaled down to the integer `1` using an asset scale of `-7`. + +### ⚠️ Asset Scales Cannot Be Easily Changed + +When setting your asset scales, we recommend thinking about whether your application may _ever_ +require a larger asset scale. If so, we would recommend using that larger scale from the start. + +For example, it might seem natural to use an asset scale of 2 for many currencies. However, it may +be wise to use a higher scale in case you ever need to represent smaller fractions of that asset. + +Accounts and transfers are immutable once created. In order to change the asset scale of a ledger, +you would need to use a different `ledger` number and duplicate all the accounts on that ledger over +to the new one. + +## `user_data` + +`user_data_128`, `user_data_64` and `user_data_32` are the most flexible fields in the schema (for +both [accounts](../reference/account.md) and [transfers](../reference/transfer.md)). Each +`user_data` field's contents are arbitrary, interpreted only by the application. + +Each `user_data` field is indexed for efficient point and range queries. + +While the usage of each field is entirely up to you, one way of thinking about each of the fields +is: + +- `user_data_128` - this might store the "who" and/or "what" of a transfer. For example, it could be + a pointer to a business entity stored within the + [control plane](https://en.wikipedia.org/wiki/Control_plane) database. +- `user_data_64` - this might store a second timestamp for "when" the transaction originated in the + real world, rather than when the transfer was + [timestamped by TigerBeetle](./time.md#why-tigerbeetle-manages-timestamps). This can be used if + you need to model [bitemporality](https://tigerbeetle.com/blog/2026-01-14-bitemporality/). + Alternatively, if you do not need this to be used for a timestamp, you could use this field in + place of the `user_data_128` to store the "who"/"what". +- `user_data_32` - this might store the "where" of a transfer. For example, it could store the + jurisdiction where the transaction originated in the real world. In certain cases, such as for + cross-border remittances, it might not be enough to have the UTC timestamp and you may want to + know the transfer's locale. + +(Note that the [`code`](#code) can be used to encode the "why" of a transfer.) + +Any of the `user_data` fields can be used as a group identifier for objects that will be queried +together. For example, for multiple transfers used for +[currency exchange](./recipes/currency-exchange.md). + +## `id` + +The `id` field uniquely identifies each [`Account`](../reference/account.md#id) and +[`Transfer`](../reference/transfer.md#id) within the cluster. + +The primary purpose of an `id` is to serve as an "idempotency key" — to avoid executing an event +twice. For example, if a client creates a transfer but the server's reply is lost, the client (or +application) will retry — the database must not transfer the money twice. + +Note that `id`s are unique per cluster -- not per ledger. You should attach a separate identifier in +the [`user_data`](#user_data) field if you want to store a connection between multiple `Account`s or +multiple `Transfer`s that are related to one another. For example, different currency `Account`s +belonging to the same user or multiple `Transfer`s that are part of a +[currency exchange](./recipes/currency-exchange.md). + +[TigerBeetle Time-Based Identifiers](#tigerbeetle-time-based-identifiers-recommended) are +recommended for most applications. + +When selecting an `id` scheme: + +- Idempotency is particularly important (and difficult) in the context of + [application crash recovery](./reliable-transaction-submission.md). +- Be careful to [avoid `id` collisions](https://en.wikipedia.org/wiki/Birthday_problem). +- An account and a transfer may share the same `id` (they belong to different "namespaces"), but + this is not recommended because other systems (that you may later connect to TigerBeetle) may use + a single "namespace" for all objects. +- Avoid requiring a central oracle to generate each unique `id` (e.g. an auto-increment field in + SQL). A central oracle may become a performance bottleneck when creating accounts/transfers. +- Sequences of identifiers with long runs of strictly increasing (or strictly decreasing) values are + amenable to optimization, leading to higher database throughput. +- Random identifiers are not recommended – they can't take advantage of all of the LSM + optimizations. (Random identifiers have _significantly_ lower throughput than strictly-increasing ULIDs). + +### TigerBeetle Time-Based Identifiers (Recommended) + +TigerBeetle recommends using a specific ID scheme for most applications. It is time-based and +lexicographically sortable. The scheme is inspired by ULIDs and UUIDv7s but is better able to take +advantage of LSM optimizations, which leads to higher database throughput. + +TigerBeetle clients include an `id()` function to generate IDs using the recommended scheme. + +TigerBeetle ID is a 128-bit number where: + +- the high 48 bits are a millisecond timestamp +- the low 80 bits are random. + +``` +id = (timestamp << 80) | random +``` + +When creating multiple objects during the same millisecond, we increment the random bytes rather +than generating new random bytes. These details ensure that a sequence of objects have strictly +increasing IDs according to the server, which improves database optimization. + +Similar to ULIDs and UUIDv7s, these IDs have the following benefits: + +- they have an insignificant risk of collision. +- they do not require a central oracle to generate. + +### Reuse Foreign Identifier + +This technique is most appropriate when integrating TigerBeetle with an existing application where +TigerBeetle accounts or transfers map one-to-one with an entity in the foreign database. + +Set `id` to a "foreign key" -- that is, reuse an identifier of a corresponding object from another +database. For example, if every user (within the application's database) has a single account, then +the identifier within the foreign database can be used as the `Account.id` within TigerBeetle. + +To reuse the foreign identifier, it must conform to TigerBeetle's `id` +[constraints](../reference/account.md#id). + +## `code` + +The `code` identifier represents the "why" for an Account or Transfer. + +On an [`Account`](../reference/account.md#code), the `code` indicates the account type, such as +assets, liabilities, equity, income, or expenses, and subcategories within those classification. + +On a [`Transfer`](../reference/transfer.md#code), the `code` indicates why a given transfer is +happening, such as a purchase, refund, currency exchange, etc. + +When you start building out your application on top of TigerBeetle, you may find it helpful to list +out all of the known types of accounts and movements of funds and mapping each of these to `code` +numbers or ranges. diff --git a/ocam/docs/coding/financial-accounting.md b/ocam/docs/coding/financial-accounting.md new file mode 100644 index 00000000..1b24008a --- /dev/null +++ b/ocam/docs/coding/financial-accounting.md @@ -0,0 +1,191 @@ +# Financial Accounting + +For developers with non-financial backgrounds, TigerBeetle's use of accounting concepts like debits +and credits may be one of the trickier parts to understand. However, these concepts have been the +language of business for hundreds of years, and it will be worth it! + +This page goes a bit deeper into debits and credits, double-entry bookkeeping, and how to think +about your accounts as part of a type system. + +## Building Intuition with Two Simple Examples + +If you have an outstanding loan and owe a bank `100`, is your balance `100` or `-100`? Conversely, if +you have `200` in your bank account, is the balance `200` or `-200`? + +Thinking about these two examples, we can start to build an intuition that the **positive or +negative sign of the balance depends on whose perspective we're looking from**. That `100` you owe +the bank represents a "bad" thing for you, but a "good" thing for the bank. We might think about +that same debt differently if we're doing your accounting or the bank's. + +These examples also hint at the **different types of accounts**. We probably want to think about a +debt as having the opposite "sign" as the funds in your bank account. At the same time, the +types of these accounts look different depending on whether you are considering them from the +perspective of you or the bank. + +Now, back to our original questions: is the loan balance `100` or `-100` and is the bank account +balance `200` or `-200`? On some level, this feels a bit arbitrary, because it is. Fortunately, there are some +**commonly agreed-upon standards**! This is exactly what debits and credits and the financial +accounting type system provide. + +## Types of Accounts + +In financial accounting, there are 5 main types of accounts: + +- **Asset** - what you own, which could produce income or which you could sell. +- **Liability** - what you owe to other people. +- **Equity** - value of the business owned by the owners or shareholders, or "the residual interest + in the assets of the entity after deducting all its liabilities."[^1] +- **Income** - money or things of value you receive for selling products or services, or "increases + in assets, or decreases in liabilities, that result in increases in equity, other than those + relating to contributions from holders of equity claims."[^1] +- **Expense** - money you spend to pay for products or services, or "decreases in assets, or + increases in liabilities, that result in decreases in equity, other than those relating to + distributions to holders of equity claims."[^1] + +[^1]: + IFRS. _Conceptual Framework for Financial Reporting_. IFRS Foundation, 2018. + + +As mentioned above, the type of account depends on whose perspective you are doing the accounting +from. In those examples, the loan you have from the bank is liability for you, because you owe the +amount to the bank. However, that same loan is an asset from the bank's perspective. In contrast, +the money in your bank account is an asset for you but it is a liability for the bank. + +Each of these major categories are further subdivided into more specific types of accounts. For +example, in your personal accounting you would separately track the cash in your physical wallet +from the funds in your checking account, even though both of those are assets. The bank would split +out mortgages from car loans, even though both of those are also assets for the bank. + +## Double-Entry Bookkeeping + +Categorizing accounts into different types is useful for organizational purposes, but it also +provides a key error-correcting mechanism. + +Every record in our accounting is not only recorded in one place, but in two. This is double-entry +bookkeeping. Why would we do that? + +Let's think about the bank loan in our example above. When you took out the loan, two things +actually happened at the same time. On the one hand, you now owe the bank `100`. At the same time, +the bank gave you `100`. These are the two entries that comprise the loan transaction. + +From your perspective, your liability to the bank increased by `100` while your assets also increased +by `100`. From the bank's perspective, their assets (the loan to you) increased by `100` while their +liabilities (the money in your bank account) also increased by `100`. + +Double-entry bookkeeping ensures that funds are always accounted for. Money never just appears. +**Funds always go from somewhere to somewhere.** + +## Keeping Accounts in Balance + +Now we understand that there are different types of accounts and every transaction will be recorded +in two (or more) accounts -- but which accounts? + +The [Fundamental Accounting Equation](https://en.wikipedia.org/wiki/Accounting_equation) stipulates +that: + +**Assets - Liabilities = Equity** + +Using our loan example, it's no accident that the loan increases assets and liabilities at the same +time. Assets and liabilities are on the opposite sides of the equation, and both sides must be +exactly equal. Loans increase assets and liabilities equally. + +Here are some other types of transactions that would affect assets, liabilities, and equity, while +maintaining this balance: + +- If you withdraw `100` in cash from your bank account, your total assets stay the same. Your bank + account balance (an asset) would decrease while your physical cash (another asset) would increase. +- From the perspective of the bank, you withdrawing `100` in cash decreases their assets in the form + of the cash they give you, while also decreasing their liabilities because your bank balance + decreases as well. +- If a shareholder invests `1000` in the bank, that increases both the bank's assets and equity. + +Assets, liabilities, and equity represent a point in time. The other two main categories, income and +expenses, represent flows of money in and out. + +Income and expenses impact the position of the business over time. The expanded accounting equation +can be written as: + +**Assets - Liabilities = Equity + Income − Expenses** + +You don't need to memorize these equations (unless you're training as an accountant!). However, it +is useful to understand that those main account types lie on different sides of this equation. + +## Debits and Credits vs Signed Integers + +Instead of using a positive or negative integer to track a balance, TigerBeetle and double-entry +bookkeeping systems use **debits and credits**. + +The two entries that give "double-entry bookkeeping" its name are the debit and the credit: every +transaction has at least one debit and at least one credit. (Note that for efficiency's sake, +TigerBeetle `Transfer`s consist of exactly one debit and one credit. These can be composed into more +complex [multi-debit, multi-credit transfers](./recipes/multi-debit-credit-transfers.md).) Which +entry is the debit and which is the credit? The answer is easy once you understand that **accounting +is a type system**. An account increases with a debit or credit according to its type. + +When our example loan increases the assets and liabilities, we need to assign each of these entries +to either be a debit or a credit. At some level, this is completely arbitrary. For clarity, +accountants have used the same standards for hundreds of years: + +### How Debits and Credits Increase or Decrease Account Balances + +- **Assets and expenses are increased with debits, decreased with credits** +- **Liabilities, equity, and income are increased with credits, decreased with debits** + +Or, in a table form: + +| | Debit | Credit | +| --------- | ----- | ------ | +| Asset | + | - | +| Liability | - | + | +| Equity | - | + | +| Income | - | + | +| Expense | + | - | + +From the perspective of our example bank: + +- You taking out a loan debits (increases) their loan assets and credits (increases) their bank + account balance liabilities. +- You paying off the loan debits (decreases) their bank account balance liabilities and credits + (decreases) their loan assets. +- You depositing cash debits (increases) their cash assets and credits (increases) their bank + account balance liabilities. +- You withdrawing cash debits (decreases) their bank account balance liabilities and credits + (decreases) their cash assets. + +Note that accounting conventions also always write the debits first, to represent that something +is received (debit) before it is given up (credit). +This is also consistent with the visual representation of +[T-Accounts](https://en.wikipedia.org/wiki/Debits_and_credits#T-accounts), with a "debit" column +on the left and a "credit" column on the right. + +If this seems arbitrary and confusing, we understand! It's a convention, just like how most +programmers need to learn zero-based array indexing and then at some point it becomes second nature. + +### Account Types and the "Normal Balance" + +Some other accounting systems have the concept of a "normal balance", which would indicate whether a +given account's balance is increased by debits or credits. + +When designing for TigerBeetle, we recommend thinking about account types instead of "normal +balances". This is because the type of balance follows from the type of account, but the type of +balance doesn't tell you the type of account. For example, an account might have a normal balance on +the debit side but that doesn't tell you whether it is an asset or expense. + +## Takeaways + +- Accounts are categorized into types. The 5 main types are asset, liability, equity, income, and + expense. +- Depending on the type of account, an increase is recorded as either a debit or a credit. +- All transfers consist of two entries, a debit and a credit. Double-entry bookkeeping ensures that + all funds come from somewhere and go somewhere. + +When you get started using TigerBeetle, we would recommend writing a list of all the types of +accounts in your system that you can think of. Then, think about whether, from the perspective of +your business, each account represents an asset, liability, equity, income, or expense. That +determines whether the given type of account is increased with a debit or a credit. + +## Want More Help Understanding Debits and Credits? + +The TigerBeetle team can support you to design your chart of accounts, and leverage +the power of fully managed TigerBeetle in your architecture. Contact us at + to set up a call. diff --git a/ocam/docs/coding/linked-events.md b/ocam/docs/coding/linked-events.md new file mode 100644 index 00000000..38b3453e --- /dev/null +++ b/ocam/docs/coding/linked-events.md @@ -0,0 +1,46 @@ +# Linked Events + +Events within a request [succeed or fail](../reference/requests/create_transfers.md#result) +independently unless they are explicitly linked using `flags.linked` +([`Account.flags.linked`](../reference/account.md#flagslinked) or +[`Transfer.flags.linked`](../reference/transfer.md#flagslinked)). + +When the `linked` flag is specified, it links the outcome of a Transfer or Account creation with the +outcome of the next one in the request. These chains of events will all succeed or fail together. + +**The last event in a chain is denoted by the first Transfer or Account without this flag.** + +The last Transfer or Account in a request may never have the `flags.linked` set, as it would leave a +chain open-ended. Attempting to do so will result in the +[`linked_event_chain_open`](../reference/requests/create_transfers.md#linked_event_chain_open) error. + +Multiple chains of events may coexist within a request to succeed or fail independently. + +Events within a chain are executed in order, or are rolled back on error, so that the effect of each +event in the chain is visible to the next. Each chain is either visible or invisible as a unit to +subsequent transfers after the chain. The event that was the first to fail within a chain will have +a unique error result. Other events in the chain will have their error result set to +[`linked_event_failed`](../reference/requests/create_transfers.md#linked_event_failed). + +### Linked Transfers Example + +Consider this set of Transfers as part of a request: + +| Transfer | Index in Request | flags.linked | +| -------- | ---------------- | ------------ | +| `A` | `0` | `false` | +| `B` | `1` | `true` | +| `C` | `2` | `true` | +| `D` | `3` | `false` | +| `E` | `4` | `false` | + +If any of transfers `B`, `C`, or `D` fail (for example, due to +[`exceeds_credits`](../reference/requests/create_transfers.md#exceeds_credits)), then `B`, `C`, +and `D` will all fail. They are linked. + +Transfers `A` and `E` fail or succeed independently of `B`, `C`, `D`, and each other. + +After the chain of linked events has executed, the fact that they were linked will not be saved. To +save the association between Transfers or Accounts, it must be +[encoded into the data model](./data-modeling.md), for example by adding an ID to one of +the [user data](./data-modeling.md#user_data) fields. diff --git a/ocam/docs/coding/recipes/README.md b/ocam/docs/coding/recipes/README.md new file mode 100644 index 00000000..0efeda6b --- /dev/null +++ b/ocam/docs/coding/recipes/README.md @@ -0,0 +1,13 @@ +# Recipes + +A collection of solutions for common use-cases. Want to exchange some currency? Or made a wrong +transfer and want to undo that? We have a recipe for that! + +- [Currency Exchange](./currency-exchange.md) +- [Multi-Debit, Multi-Credit Transfers](./multi-debit-credit-transfers.md) +- [Closing Accounts](./close-account.md) +- [Balance-Conditional Transfers](./balance-conditional-transfers.md) +- [Balance-Invariant Transfers](./balance-invariant-transfers.md) +- [Balance Bounds](./balance-bounds.md) +- [Correcting Transfers](./correcting-transfers.md) +- [Rate Limiting](./rate-limiting.md) diff --git a/ocam/docs/coding/recipes/balance-bounds.md b/ocam/docs/coding/recipes/balance-bounds.md new file mode 100644 index 00000000..2128835d --- /dev/null +++ b/ocam/docs/coding/recipes/balance-bounds.md @@ -0,0 +1,102 @@ +# Balance Bounds + +It is easy to limit an account's balance using either +[`flags.debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits) +or +[`flags.credits_must_not_exceed_debits`](../../reference/account.md#flagscredits_must_not_exceed_debits). + +What if you want an account's balance to stay between an upper and a lower bound? + +This is possible to check atomically using a set of linked transfers. (Note: with the +`must_not_exceed` flag invariants, an account is guaranteed to never violate those invariants. This +maximum balance approach must be enforced per-transfer -- it is possible to exceed the limit simply +by not enforcing it for a particular transfer.) + +## Preconditions + +1. Target Account Should Have a Limited Balance + +The account whose balance you want to bound should have one of these flags set: + +- [`flags.debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits) + for accounts with [credit balances](../data-modeling.md#credit-balances) +- [`flags.credits_must_not_exceed_debits`](../../reference/account.md#flagscredits_must_not_exceed_debits) + for accounts with [debit balances](../data-modeling.md#debit-balances) + +2. Create a Control Account with the Opposite Limit + +There must also be a designated control account. + +As you can see below, this account will never actually take control of the target account's funds, +but we will set up simultaneous transfers in and out of the control account to apply the limit. + +This account must have the opposite limit applied as the target account: + +- [`flags.credits_must_not_exceed_debits`](../../reference/account.md#flagscredits_must_not_exceed_debits) + if the target account has a [credit balance](../data-modeling.md#credit-balances) +- [`flags.debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits) + if the target account has a [debit balance](../data-modeling.md#debit-balances) + +3. Create an Operator Account + +The operator account will be used to fund the Control Account. + +## Executing a Transfer with a Balance Bounds Check + +This consists of 5 [linked transfers](../linked-events.md). + +We will refer to two amounts: + +- The **limit amount** is upper bound we want to maintain on the target account's balance. +- The **transfer amount** is the amount we want to transfer if and only if the target account's + balance after a successful transfer would be within the bounds. + +### If the Target Account Has a Credit Balance + +In this case, we are keeping the Destination Account's balance between the bounds. + +| Transfer | Debit Account | Credit Account | Amount | Pending ID | Flags (Note: `\|` sets multiple flags) | +| -------- | ------------- | -------------- | ------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Source | Destination | Transfer | - | [`flags.linked`](../../reference/transfer.md#flagslinked) | +| 2 | Control | Operator | Limit | - | [`flags.linked`](../../reference/transfer.md#flagslinked) | +| 3 | Destination | Control | `AMOUNT_MAX` | - | [`flags.linked`](../../reference/transfer.md#flagslinked) \| [`flags.balancing_debit`](../../reference/transfer.md#flagsbalancing_debit) \| [`flags.pending`](../../reference/transfer.md#flagspending) | +| 4 | - | - | - | `3`\* | [`flags.linked`](../../reference/transfer.md#flagslinked) \| [`flags.void_pending_transfer`](../../reference/transfer.md#flagsvoid_pending_transfer) | +| 5 | Operator | Control | Limit | - | - | + +\*This must be set to the transfer ID of the pending transfer (in this example, it is transfer 3). + +### If the Target Account Has a Debit Balance + +In this case, we are keeping the Destination Account's balance between the bounds. + +| Transfer | Debit Account | Credit Account | Amount | Pending ID | Flags (Note `\|` sets multiple flags) | +| -------- | ------------- | -------------- | ------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Destination | Source | Transfer | - | [`flags.linked`](../../reference/transfer.md#flagslinked) | +| 2 | Operator | Control | Limit | - | [`flags.linked`](../../reference/transfer.md#flagslinked) | +| 3 | Control | Destination | `AMOUNT_MAX` | - | [`flags.balancing_credit`](../../reference/transfer.md#flagsbalancing_credit) \| [`flags.pending`](../../reference/transfer.md#flagspending) \| [`flags.linked`](../../reference/transfer.md#flagslinked) | +| 4 | - | - | - | `3`\* | [`flags.void_pending_transfer`](../../reference/transfer.md#flagsvoid_pending_transfer) \| [`flags.linked`](../../reference/transfer.md#flagslinked) | +| 5 | Control | Operator | Limit | - | - | + +\*This must be set to the transfer ID of the pending transfer (in this example, it is transfer 3). + +### Understanding the Mechanism + +Each of the 5 transfers is [linked](../linked-events.md) so that all of +them will succeed or all of them will fail. + +The first transfer is the one we actually want to send. + +The second transfer sets the Control Account's balance to the upper bound we want to impose. + +The third transfer uses a [`balancing_debit`](../../reference/transfer.md#flagsbalancing_debit) or +[`balancing_credit`](../../reference/transfer.md#flagsbalancing_credit) to transfer the Destination +Account's net credit balance or net debit balance, respectively, to the Control Account. This +transfer will fail if the first transfer would put the Destination Account's balance above the upper +bound. + +The third transfer is also a pending transfer, so it won't actually transfer the Destination +Account's funds, even if it succeeds. + +If everything to this point succeeds, the fourth and fifth transfers simply undo the effects of the +second and third transfers. The fourth transfer voids the pending transfer. And the fifth transfer +resets the Control Account's net balance to zero. diff --git a/ocam/docs/coding/recipes/balance-conditional-transfers.md b/ocam/docs/coding/recipes/balance-conditional-transfers.md new file mode 100644 index 00000000..86d8a213 --- /dev/null +++ b/ocam/docs/coding/recipes/balance-conditional-transfers.md @@ -0,0 +1,74 @@ +# Balance-Conditional Transfers + +In some use cases, you may want to execute a transfer if and only if an account has at least a +certain balance. + +It would be unsafe to check an account's balance using the +[`lookup_accounts`](../../reference/requests/lookup_accounts.md) and then perform the transfer, +because these requests are not be atomic and the account's balance may change between the lookup and +the transfer. + +You can atomically run a check against an account's balance before executing a transfer by using a +control or temporary account and linked transfers. + +## Preconditions + +### 1. Target Account Must Have a Limited Balance + +The account for whom you want to do the balance check must have one of these flags set: + +- [`flags.debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits) + for accounts with [credit balances](../data-modeling.md#credit-balances) +- [`flags.credits_must_not_exceed_debits`](../../reference/account.md#flagscredits_must_not_exceed_debits) + for accounts with [debit balances](../data-modeling.md#debit-balances) + +### 2. Create a Control Account + +There must also be a designated control account. As you can see below, this account will never +actually take control of the target account's funds, but we will set up simultaneous transfers in +and out of the control account. + +## Executing a Balance-Conditional Transfer + +The balance-conditional transfer consists of 3 +[linked transfers](../linked-events.md). + +We will refer to two amounts: + +- The **threshold amount** is the minimum amount the target account should have in order to execute + the transfer. +- The **transfer amount** is the amount we want to transfer if and only if the target account's + balance meets the threshold. + +### If the Source Account Has a Credit Balance + +| Transfer | Debit Account | Credit Account | Amount | Pending Id | Flags | +| -------- | ------------- | -------------- | --------- | ---------- | -------------------------------------------------------------- | +| 1 | Source | Control | Threshold | - | [`flags.linked`](../../reference/transfer.md#flagslinked), [`pending`](../../reference/transfer.md#flagspending) | +| 2 | - | - | - | 1 | [`flags.linked`](../../reference/transfer.md#flagslinked), [`void_pending_transfer`](../../reference/transfer.md#flagsvoid_pending_transfer) | +| 3 | Source | Destination | Transfer | - | N/A | + +### If the Source Account Has a Debit Balance + +| Transfer | Debit Account | Credit Account | Amount | Pending Id | Flags | +| -------- | ------------- | -------------- | --------- | ---------- | -------------------------------------------------------------- | +| 1 | Control | Source | Threshold | - | [`flags.linked`](../../reference/transfer.md#flagslinked), [`pending`](../../reference/transfer.md#flagspending) | +| 2 | - | - | - | 1 | [`flags.linked`](../../reference/transfer.md#flagslinked), [`void_pending_transfer`](../../reference/transfer.md#flagsvoid_pending_transfer) | +| 3 | Destination | Source | Transfer | - | N/A | + +### Understanding the Mechanism + +Each of the 3 transfers is linked, meaning they will all succeed or fail together. + +The first transfer attempts to transfer the threshold amount to the control account. If this +transfer would cause the source account's net balance to go below zero, the account's balance limit +flag would ensure that the first transfer fails. If the first transfer fails, the other two linked +transfers would also fail. + +If the first transfer succeeds, it means that the source account did have the threshold balance. In +this case, the second transfer cancels the first transfer (returning the threshold amount to the +source account). Then, the third transfer would execute the desired transfer to the ultimate +destination account. + +Note that in the tables above, we do the balance check on the source account. The balance check +could also be applied to the destination account instead. diff --git a/ocam/docs/coding/recipes/balance-invariant-transfers.md b/ocam/docs/coding/recipes/balance-invariant-transfers.md new file mode 100644 index 00000000..4bcfd606 --- /dev/null +++ b/ocam/docs/coding/recipes/balance-invariant-transfers.md @@ -0,0 +1,56 @@ +# Balance-invariant Transfers + +For some accounts, it may be useful to enforce +[`flags.debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits) +or +[`flags.credits_must_not_exceed_debits`](../../reference/account.md#flagscredits_must_not_exceed_debits) +balance invariants for only a subset of all transfers, rather than all transfers. + +This can be achieved by having a **control** account used to test the balance invariants at the +desired points in time. The control account will have a 0 balance and the balance invariant that we +want to test on the **destination** account. At the point where we want to test the destination +account balance invariant, we can initiate a pending balancing transfer for the **opposite** side to +the control account. If the invariant is violated on the destination account, the balancing transfer +has non-zero amount, violates the control account invariant, and fails the entire chain. The +following example will make this clearer. + +## Per-transfer `credits_must_not_exceed_debits` + +Let's test a `credits_must_not_exceed_debits` balance invariant on a destination account after a particular transfer. + +This recipe requires three accounts: +1. The **source** account, to debit. +2. The **destination** account, to credit. (With _neither_ + [`flags.credits_must_not_exceed_debits`](../../reference/account.md#flagscredits_must_not_exceed_debits) nor + [`flags.debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits) set, + since in this recipe we are only enforcing the invariant on a per-transfer basis.) +3. The **control** account, to test the balance invariant. The control account should have + [`flags.credits_must_not_exceed_debits`](../../reference/account.md#flagscredits_must_not_exceed_debits) + set. + +| Id | Debit Account | Credit Account | Amount | Pending Id | Flags | +| -: | ------------: | -------------: | -----: | ---------: | --------------------------------------------------: | +| 1 | Source | Destination | 123 | - | [`linked`](../../reference/transfer.md#flagslinked) | +| 2 | Destination | Control | 1 | - | [`linked`](../../reference/transfer.md#flagslinked), [`pending`](../../reference/transfer.md#flagspending), [`balancing_debit`](../../reference/transfer.md#flagsbalancing_debit) | +| 3 | - | - | 0 | 2 | [`void_pending_transfer`](../../reference/transfer.md#flagsvoid_pending_transfer) | + +When the destination account's credits after transfer `1` do not exceed its debits, the chain will succeed. +When the destination account's credits after transfer `1` exceed its debits, transfer `2` will fail with `exceeds_debits`. + +## Per-transfer `debits_must_not_exceed_credits` + +This case is symmetric: + +1. The **source** is account to credit. +2. The **destination** is account to debit. Neither + [`flags.credits_must_not_exceed_debits`](../../reference/account.md#flagscredits_must_not_exceed_debits) nor + [`flags.debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits) are set. +3. The **control** account has + [`flags.debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits) + set. + +| Id | Debit Account | Credit Account | Amount | Pending Id | Flags | +| -: | ------------: | -------------: | -----: | ---------: | --------------------------------------------------: | +| 1 | Destination | Source | 123 | - | [`linked`](../../reference/transfer.md#flagslinked) | +| 2 | Control | Destination | 1 | - | [`linked`](../../reference/transfer.md#flagslinked), [`pending`](../../reference/transfer.md#flagspending), [`balancing_credit`](../../reference/transfer.md#flagsbalancing_credit) | +| 3 | - | - | 0 | 2 | [`void_pending_transfer`](../../reference/transfer.md#flagsvoid_pending_transfer) | diff --git a/ocam/docs/coding/recipes/close-account.md b/ocam/docs/coding/recipes/close-account.md new file mode 100644 index 00000000..d7b204e0 --- /dev/null +++ b/ocam/docs/coding/recipes/close-account.md @@ -0,0 +1,72 @@ +# Close Account + +In accounting, a _closing entry_ calculates the net debit or credit balance for an account and then +credits or debits this balance respectively, to zero the account's balance and move the balance to +another account. + +Additionally, it may be desirable to forbid further transfers on this account (i.e. at the end of +an accounting period, upon account termination, or even temporarily freezing the account for audit +purposes). +This doesn't affect existing [pending transfers](../two-phase-transfers.md), which can still time +out but can’t be posted or voided. + +### Example + +Given a set of accounts: + +| Account | Debits Pending | Debits Posted | Credits Pending | Credits Posted | Flags | +| ------: | -------------: | ------------: | --------------: | -------------: | ----------------- | +| `A` | 0 | 10 | 0 | 20 | [`debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits)| +| `B` | 0 | 30 | 0 | 5 | [`credits_must_not_exceed_debits`](../../reference/account.md#flagscredits_must_not_exceed_debits)| +| `C` | 0 | 0 | 0 | 0 | | + +The "closing entries" for accounts `A` and `B` are expressed as _linked chains_, so they either +succeed or fail atomically. + +- Account `A`: the linked transfers are `T1` and `T2`. + +- Account `B`: the linked transfers are `T3` and `T4`. + +- Account `C`: is the _control account_ and will not be closed. + +| Transfer | Debit Account | Credit Account | Amount | Amount (recorded) | Flags | +| -------: | --------------: | -------------: | -----------: | ----------------: | -------------- | +| `T1` | `A` | `C` | `AMOUNT_MAX` | 10 | [`balancing_debit`](../../reference/transfer.md#flagsbalancing_debit),[`linked`](../../reference/transfer.md#flagslinked) | +| `T2` | `A` | `C` | 0 | 0 | [`closing_debit`](../../reference/transfer.md#flagsclosing_debit), [`pending`](../../reference/transfer.md#flagspending) | +| `T3` | `C` | `B` | `AMOUNT_MAX` | 25 | [`balancing_credit`](../../reference/transfer.md#flagsbalancing_credit),[`linked`](../../reference/transfer.md#flagslinked)| +| `T4` | `C` | `B` | 0 | 0 | [`closing_credit`](../../reference/transfer.md#flagsclosing_credit), [`pending`](../../reference/transfer.md#flagspending) | + + +- `T1` and `T3` are _balancing transfers_ with `AMOUNT_MAX` as the `Transfer.amount` so that the + application does not need to know (or query) the balance prior to closing the account. + + The stored transfer's `amount` will be set to the actual amount transferred. + +- `T2` and `T4` are _closing transfers_ that will cause the respective account to be closed. + + The closing transfer must be also a _pending transfer_ so the action can be reversible. + +After committing these transfers, `A` and `B` are closed with net balance zero, and will reject any +further transfers. + +| Account | Debits Pending | Debits Posted | Credits Pending | Credits Posted | Flags | +| ------: | -------------: | ------------: | --------------: | -------------: | ----------------- | +| `A` | 0 | 20 | 0 | 20 | [`debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits), [`closed`](../../reference/account.md#flagsclosed)| +| `B` | 0 | 30 | 0 | 30 | [`credits_must_not_exceed_debits`](../../reference/account.md#flagscredits_must_not_exceed_debits), [`closed`](../../reference/account.md#flagsclosed)| +| `C` | 0 | 25 | 0 | 10 | | + +To re-open the closed account, the _pending closing transfer_ can be _voided_, reverting the +closing action (but not reverting the net balance): + +| Transfer | Debit Account | Credit Account | Amount | Pending Transfer | Flags | +| -------: | --------------: | -------------: | -----------: | ----------------: | -------------- | +| `T5` | `A` | `C` | 0 | `T2` | [`void_pending_transfer`](../../reference/transfer.md#flagsvoid_pending_transfer)| +| `T6` | `C` | `B` | 0 | `T4` | [`void_pending_transfer`](../../reference/transfer.md#flagsvoid_pending_transfer)| + +After committing these transfers, `A` and `B` are re-opened and can accept transfers again: + +| Account | Debits Pending | Debits Posted | Credits Pending | Credits Posted | Flags | +| ------: | -------------: | ------------: | --------------: | -------------: | ---------------- | +| `A` | 0 | 20 | 0 | 20 | [`debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits)| +| `B` | 0 | 30 | 0 | 30 | [`credits_must_not_exceed_debits`](../../reference/account.md#flagscredits_must_not_exceed_debits)| +| `C` | 0 | 25 | 0 | 10 | | diff --git a/ocam/docs/coding/recipes/correcting-transfers.md b/ocam/docs/coding/recipes/correcting-transfers.md new file mode 100644 index 00000000..acebe393 --- /dev/null +++ b/ocam/docs/coding/recipes/correcting-transfers.md @@ -0,0 +1,55 @@ +# Correcting Transfers + +[`Transfer`s](../../reference/transfer.md) in TigerBeetle are immutable, so once they are created +they cannot be modified or deleted. + +Immutability is useful for creating an auditable log of all of the business events, but it does +raise the question of what to do when a transfer was made in error or some detail such as the amount +was incorrect. + +## Always Add More Transfers + +Correcting transfers or entries in TigerBeetle are handled with more transfers to reverse or adjust +the effects of the previous transfer(s). + +This is important because adding transfers as opposed to deleting or modifying incorrect ones adds +more information to the history. The log of events includes the original error, when it took place, +as well as any attempts to correct the record and when they took place. A correcting entry might +even be wrong, in which case it itself can be corrected with yet another transfer. All of these +events form a timeline of the particular business event, which is stored permanently. + +Another way to put this is that TigerBeetle is the lowest layer of the accounting stack and +represents the finest-resolution data that is stored. At a higher-level reporting layer, you can +"downsample" the data to show only the corrected transfer event. However, it would not be possible +to go back if the original record were modified or deleted. + +Two specific recommendations for correcting transfers are: + +1. You may want to have a [`Transfer.code`](../../reference/transfer.md#code) that indicates a given + transfer is a correction, or you may want multiple codes where each one represents a different + reason why the correction has taken place. +2. If you use the [`Transfer.user_data_128`](../../reference/transfer.md#user_data_128) to store an + ID that links multiple transfers within TigerBeetle or points to a + [record in an external database](../system-architecture.md), you may want to use the same + `user_data_128` field on the correction transfer(s), even if they happen at a later point. + +### Example + +Let's say you had a couple of transfers, from account `A` to accounts `X` and `Y`: + +| Ledger | Debit Account | Credit Account | Amount | `code` | `user_data_128` | `flags.linked` | +| -----: | ------------: | -------------: | -----: | -----: | --------------: | -------------: | +| USD | `A` | `X` | 10000 | 600 | 123456 | true | +| USD | `A` | `Y` | 50 | 9000 | 123456 | false | + +Now, let's say we realized the amount was wrong and we need to adjust both of the amounts by 10%. We +would submit two **additional** transfers going in the opposite direction: + +| Ledger | Debit Account | Credit Account | Amount | `code` | `user_data_128` | `flags.linked` | +| -----: | ------------: | -------------: | -----: | -----: | --------------: | -------------: | +| USD | `X` | `A` | 1000 | 10000 | 123456 | true | +| USD | `Y` | `A` | 5 | 10000 | 123456 | false | + +Note that the codes used here don't have any actual meaning, but you would want to +[enumerate your business events](../data-modeling.md#code) and map each to a numeric code value, +including the initial reasons for transfers and the reasons they might be corrected. diff --git a/ocam/docs/coding/recipes/currency-exchange.md b/ocam/docs/coding/recipes/currency-exchange.md new file mode 100644 index 00000000..c001c963 --- /dev/null +++ b/ocam/docs/coding/recipes/currency-exchange.md @@ -0,0 +1,76 @@ +# Currency Exchange + +Some applications require multiple currencies. For example, a bank may hold balances in many +different currencies. If a single logical entity holds multiple currencies, each currency must be +held in a separate TigerBeetle `Account`. (Normalizing to a single currency at the application level +should be avoided because exchange rates fluctuate). + +Currency exchange is a trade of one type of currency (denoted by the `ledger`) for another, +facilitated by an entity called the _liquidity provider_. + +## Data Modeling + +Distinct [`ledger`](../../reference/account.md#ledger) values denote different currencies (or +other asset types). Transfers between pairs of accounts with different `ledger`s are +[not permitted](../../reference/requests/create_transfers.md#accounts_must_have_the_same_ledger). + +Instead, currency exchange is implemented by creating two +[atomically linked](../../reference/transfer.md#flagslinked) different-ledger transfers between +two pairs of same-ledger accounts. + +A simple currency exchange involves four accounts: + +- A _source account_ `A₁`, on ledger `1`. +- A _destination account_ `A₂`, on ledger `2`. +- A _source liquidity account_ `L₁`, on ledger `1`. +- A _destination liquidity account_ `L₂`, on ledger `2`. + +and two linked transfers: + +- A transfer `T₁` from the _source account_ to the _source liquidity account_. +- A transfer `T₂` from the _destination liquidity account_ to the _destination account_. + +The transfer amounts vary according to the exchange rate. + +- Both liquidity accounts belong to the liquidity provider (e.g. a bank or exchange). +- The source and destination accounts may belong to the same entity as one another, or different + entities, depending on the use case. + +### Example + +Consider sending `100.00 USD` from account `A₁` (denominated in USD) to account `A₂` (denominated +in INR). Assuming an exchange rate of `1.00 USD = 82.42135 INR`, `100.00 USD = 8242.14 INR`: + +| Ledger | Debit Account | Credit Account | Amount | `flags.linked` | +| -----: | ------------: | -------------: | -----: | -------------: | +| USD | `A₁` | `L₁` | 10000 | true | +| INR | `L₂` | `A₂` | 824214 | false | + +- Amounts are [represented as integers](../data-modeling.md#fractional-amounts-and-asset-scale). +- Because both liquidity accounts belong to the same entity, the entity does not lose money on the + transaction. + - If the exchange rate is precise, the entity breaks even. + - If the exchange rate is not precise, the application should round in favor of the liquidity + account to deter arbitrage. +- Because the two transfers are linked together, they will either both succeed or both fail. + +## Spread + +In the prior example, the liquidity provider breaks even. A fee (i.e. spread) can be included in the +`linked` chain as a separate transfer from the source account to the source liquidity account (`A₁` +to `L₁`). + +This is preferable to simply modifying the exchange rate in the liquidity provider's favor because +it implicitly records the exchange rate and spread at the time of the exchange — information that +cannot be derived if the two are combined. + +### Example + +This depicts the same scenario as the prior example, except the liquidity provider charges a `0.10 USD` +fee for the transaction. + +| Ledger | Debit Account | Credit Account | Amount | `flags.linked` | +| -----: | ------------: | -------------: | -----: | -------------: | +| USD | `A₁` | `L₁` | 10000 | true | +| USD | `A₁` | `L₁` | 10 | true | +| INR | `L₂` | `A₂` | 824214 | false | diff --git a/ocam/docs/coding/recipes/multi-debit-credit-transfers.md b/ocam/docs/coding/recipes/multi-debit-credit-transfers.md new file mode 100644 index 00000000..0a5d3b6e --- /dev/null +++ b/ocam/docs/coding/recipes/multi-debit-credit-transfers.md @@ -0,0 +1,107 @@ +# Multi-Debit, Multi-Credit Transfers + +TigerBeetle is designed for maximum performance. In order to keep it lean, the database only +supports simple transfers with a single debit and a single credit. + +However, you'll probably run into cases where you want transactions with multiple debits and/or +credits. For example, you might have a transfer where you want to extract fees and/or taxes. + +Read on to see how to implement one-to-many and many-to-many transfers! + +> Note that all of these examples use the +> [Linked Transfers flag (`flags.linked`)](../../reference/transfer.md#flagslinked) to ensure +> that all of the transfers succeed or fail together. + +## One-to-Many Transfers + +Transactions that involve multiple debits and a single credit OR a single debit and multiple credits +are relatively straightforward. + +You can use multiple linked transfers as depicted below. + +### Single Debit, Multiple Credits + +This example debits a single account and credits multiple accounts. It uses the following accounts: + +- A _source account_ `A`, on the `USD` ledger. +- Three _destination accounts_ `X`, `Y`, and `Z`, on the `USD` ledger. + +| Ledger | Debit Account | Credit Account | Amount | `flags.linked` | +| -----: | ------------: | -------------: | -----: | -------------: | +| USD | `A` | `X` | 10000 | true | +| USD | `A` | `Y` | 50 | true | +| USD | `A` | `Z` | 10 | false | + +### Multiple Debits, Single Credit + +This example debits multiple accounts and credits a single account. It uses the following accounts: + +- Three _source accounts_ `A`, `B`, and `C` on the `USD` ledger. +- A _destination account_ `X` on the `USD` ledger. + +| Ledger | Debit Account | Credit Account | Amount | `flags.linked` | +| -----: | ------------: | -------------: | -----: | -------------: | +| USD | `A` | `X` | 10000 | true | +| USD | `B` | `X` | 50 | true | +| USD | `C` | `X` | 10 | false | + +### Multiple Debits, Single Credit, Balancing debits + +This example debits multiple accounts and credits a single account. +The total amount to transfer to the credit account is known (in this case, `100`), but the balances +of the individual debit accounts are not known. That is, each debit account should contribute as +much as possible (in order of precedence) up to the target, cumulative transfer amount. + +It uses the following accounts: + +- Three _source accounts_ `A`, `B`, and `C`, with [`flags.debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits). +- A _destination account_ `X`. +- A control account `LIMIT`, with [`flags.debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits). +- A control account `SETUP`, for setting up the `LIMIT` account. + +| Id | Ledger | Debit Account | Credit Account | Amount | Flags | +| -: | -----: | ------------: | -------------: | -----------: | :------------- | +| 1 | USD | `SETUP` | `LIMIT` | 100 | [`linked`](../../reference/transfer.md#flagslinked) | +| 2 | USD | `A` | `SETUP` | 100 | [`linked`](../../reference/transfer.md#flagslinked), [`balancing_debit`](../../reference/transfer.md#flagsbalancing_debit), [`balancing_credit`](../../reference/transfer.md#flagsbalancing_credit) | +| 3 | USD | `B` | `SETUP` | 100 | [`linked`](../../reference/transfer.md#flagslinked), [`balancing_debit`](../../reference/transfer.md#flagsbalancing_debit), [`balancing_credit`](../../reference/transfer.md#flagsbalancing_credit) | +| 4 | USD | `C` | `SETUP` | 100 | [`linked`](../../reference/transfer.md#flagslinked), [`balancing_debit`](../../reference/transfer.md#flagsbalancing_debit), [`balancing_credit`](../../reference/transfer.md#flagsbalancing_credit) | +| 5 | USD | `SETUP` | `X` | 100 | [`linked`](../../reference/transfer.md#flagslinked) | +| 6 | USD | `LIMIT` | `SETUP` | `AMOUNT_MAX` | [`balancing_credit`](../../reference/transfer.md#flagsbalancing_credit) | + +If the cumulative [credit balance](../data-modeling.md#credit-balances) of `A + B + C` is less than +`100`, the chain will fail (transfer `6` will return `exceeds_credits`). + +## Many-to-Many Transfers + +Transactions with multiple debits and multiple credits are a bit more involved (but you got this!). + +This is where the accounting concept of a Control Account comes in handy. We can use this as an +intermediary account, as illustrated below. + +In this example, we'll use the following accounts: + +- Two _source accounts_ `A` and `B` on the `USD` ledger. +- Three _destination accounts_ `X`, `Y`, and `Z`, on the `USD` ledger. +- A _compound entry control account_ `Control` on the `USD` ledger. + +| Ledger | Debit Account | Credit Account | Amount | `flags.linked` | +| -----: | ------------: | -------------: | -----: | -------------: | +| USD | `A` | `Control` | 10000 | true | +| USD | `B` | `Control` | 50 | true | +| USD | `Control` | `X` | 9000 | true | +| USD | `Control` | `Y` | 1000 | true | +| USD | `Control` | `Z` | 50 | false | + +Here, we use two transfers to debit accounts `A` and `B` and credit the `Control` account, and +another three transfers to credit accounts `X`, `Y`, and `Z`. + +If you looked closely at this example, you may have noticed that we could have debited `B` and +credited `Z` directly because the amounts happened to line up. That is true! + +For a little more extreme performance, you _might_ consider implementing logic to circumvent the +control account where possible, to reduce the number of transfers to implement a compound journal +entry. + +However, if you're just getting started, you can avoid premature optimizations (we've all been +there!). You may find it easier to program these compound journal entries _always_ using a control +account -- and you can then come back to squeeze this performance out later! diff --git a/ocam/docs/coding/recipes/rate-limiting.md b/ocam/docs/coding/recipes/rate-limiting.md new file mode 100644 index 00000000..10251beb --- /dev/null +++ b/ocam/docs/coding/recipes/rate-limiting.md @@ -0,0 +1,116 @@ +# Rate Limiting + +TigerBeetle can be used to account for non-financial resources. + +In this recipe, we will show you how to use it to implement rate limiting using the +[leaky bucket algorithm](https://en.wikipedia.org/wiki/Leaky_bucket) based on the user request rate, +bandwidth, and money. + +## Mechanism + +For each type of resource we want to limit, we will have a ledger specifically for that resource. On +that ledger, we have an operator account and an account for each user. Each user's account will have +a balance limit applied. + +To set up the rate limiting system, we will first credit the resource limit amount to each of the +users. For each user request, we will then create a +[pending transfer](../two-phase-transfers.md#reserve-funds-pending-transfer) with a +[timeout](../two-phase-transfers.md#expire-pending-transfer). We will never post or void these +transfers, but will instead let them expire. + +Since each account's credit "balance" is limited, requesting a pending transfer that would exceed the +rate limit will fail. However, when each pending transfer expires, the pending amounts are automatically restored to +the available balance. + +## Request Rate Limiting + +Let's say we want to limit each user to 10 requests per minute. + +We need our user account to have a limited balance. + +| Ledger | Account | Flags | +| ------------ | -------- | -------------------------------------------------------------------------------------------------- | +| Request Rate | Operator | `0` | +| Request Rate | User | [`debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits) | + +We'll first transfer 10 units from the operator to the user. + +| Transfer | Ledger | Debit Account | Credit Account | Amount | +| -------- | -----------: | ------------: | -------------: | -----: | +| 1 | Request Rate | Operator | User | 10 | + +Then, for each incoming request, we will create a pending transfer for 1 unit back to the operator +from the user: + +| Transfer | Ledger | Debit Account | Credit Account | Amount | Timeout | Flags | +| -------- | -----------: | ------------: | -------------: | -----: | ------- | ----------------------------------------------------: | +| 2...N | Request Rate | User | Operator | 1 | 60 | [`pending`](../../reference/transfer.md#flagspending) | + +Note that we use a timeout of 60 (seconds), because we wanted to limit each user to 10 requests _per +minute_. + +That's it! Each of these transfers will "reserve" some of the user's balance and then replenish the +balance after they expire. + +## Bandwidth Limiting + +To limit user requests based on bandwidth as opposed to request rate, we can apply the same +technique but use amounts that represent the request size. + +Let's say we wanted to limit each user to 10 MB (10,000,000 bytes) per minute. + +Our account setup is the same as before: + +| Ledger | Account | Flags | +| --------- | -------- | -------------------------------------------------------------------------------------------------- | +| Bandwidth | Operator | 0 | +| Bandwidth | User | [`debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits) | + +Now, we'll transfer 10,000,000 units (bytes in this case) from the operator to the user: + +| Transfer | Ledger | Debit Account | Credit Account | Amount | +| -------- | --------: | ------------: | -------------: | -------: | +| 1 | Bandwidth | Operator | User | 10000000 | + +For each incoming request, we'll create a pending transfer where the amount is equal to the request +size: + +| Transfer | Ledger | Debit Account | Credit Account | Amount | Timeout | Flags | +| -------- | --------: | ------------: | -------------: | -----------: | ------- | ----------------------------------------------------: | +| 2...N | Bandwidth | User | Operator | Request Size | 60 | [`pending`](../../reference/transfer.md#flagspending) | + +We're again using a timeout of 60 seconds, but you could adjust this to be whatever time window you +want to use to limit requests. + +## Transfer Amount Limiting + +Now, let's say you wanted to limit each account to transferring no more than a certain amount of +money per time window. We can do that using 2 ledgers and linked transfers. + +| Ledger | Account | Flags | +| ------------- | -------- | -------------------------------------------------------------------------------------------------- | +| Rate Limiting | Operator | 0 | +| Rate Limiting | User | [`debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits) | +| USD | Operator | 0 | +| USD | User | [`debits_must_not_exceed_credits`](../../reference/account.md#flagsdebits_must_not_exceed_credits) | + +Let's say we wanted to limit each account to sending no more than 1000 USD per day. + +To set up, we transfer 1000 from the Operator to the User on the Rate Limiting ledger: + +| Transfer | Ledger | Debit Account | Credit Account | Amount | +| -------- | ------------: | ------------: | -------------: | -----: | +| 1 | Rate Limiting | Operator | User | 1000 | + +For each transfer the user wants to do, we will create 2 transfers that are +[linked](../linked-events.md): + +| Transfer | Ledger | Debit Account | Credit Account | Amount | Timeout | Flags (Note `\|` sets multiple flags) | +| -------- | ------------: | ------------: | -------------: | --------------: | ------- | -----------------------------------------------------------------------------------------------------------: | +| 2N | Rate Limiting | User | Operator | Transfer Amount | 86400 | [`pending`](../../reference/transfer.md#flagspending) \| [`linked`](../../reference/transfer.md#flagslinked) | +| 2N + 1 | USD | User | Destination | Transfer Amount | 0 | 0 | + +Note that we are using a timeout of 86400 seconds, because this is the number of seconds in a day. + +These are linked such that if the first transfer fails, because the user has already transferred too +much money in the past day, the second transfer will also fail. diff --git a/ocam/docs/coding/reliable-transaction-submission.md b/ocam/docs/coding/reliable-transaction-submission.md new file mode 100644 index 00000000..044f878c --- /dev/null +++ b/ocam/docs/coding/reliable-transaction-submission.md @@ -0,0 +1,54 @@ +# Reliable Transaction Submission + +When making payments or recording transfers, it is important to ensure that they are recorded once +and only once -- even if some parts of the system fail during the transaction. + +There are some subtle gotchas to avoid, so this page describes how to submit events -- and +especially transfers -- reliably. + +## The App or Browser Should Generate the ID + +[`Transfer`s](../reference/transfer.md#id) and [`Account`s](../reference/account.md#id) +carry an `id` field that is used as an idempotency key to ensure the same object is not created +twice. + +**The client software, such as your app or web page, that the user interacts with should generate +the `id` (not your API). This `id` should be persisted locally before submission, and the same `id` +should be used for subsequent retries.** + +1. User initiates a transfer. +2. Client software (app, web page, etc) [generates the transfer `id`](./data-modeling.md#id). +3. Client software **persists the `id` in the app or browser local storage.** +4. Client software submits the transfer to your [API service](./system-architecture.md). +5. API service includes the transfer in a [request](../reference/requests/README.md). +6. TigerBeetle creates the transfer with the given `id` once and only once. +7. TigerBeetle responds to the API service. +8. The API service responds to the client software. + +### Handling Network Failures + +The method described above handles various potential network failures. The request may be lost +before it reaches the API service or before it reaches TigerBeetle. Or, the response may be lost on +the way back from TigerBeetle. + +Generating the `id` on the client side ensures that transfers can be safely retried. The app must +use the same `id` each time the transfer is resent. + +If the transfer was already created before and then retried, TigerBeetle will return the +[`exists`](../reference/requests/create_transfers.md#exists) response code. If the transfer had +not already been created, it will be created and return the +[`created`](../reference/requests/create_transfers.md#created). + +### Handling Client Software Restarts + +The method described above also handles potential restarts of the app or browser while the request +is in flight. + +It is important to **persist the `id` to local storage on the client's device before submitting the +transfer**. When the app or web page reloads, it should resubmit the transfer using the same `id`. + +This ensures that the operation can be safely retried even if the client app or browser restarts +before receiving the response to the operation. Similar to the case of a network failure, +TigerBeetle will respond with the [`created`](../reference/requests/create_transfers.md#created) if a +transfer is newly created and [`exists`](../reference/requests/create_transfers.md#exists) if an +object with the same `id` was already created. diff --git a/ocam/docs/coding/requests.md b/ocam/docs/coding/requests.md new file mode 100644 index 00000000..e77b6693 --- /dev/null +++ b/ocam/docs/coding/requests.md @@ -0,0 +1,112 @@ +# Requests + +A _request_ queries or updates the database state. + +A request consists of one or more _events_ of the same type sent to the cluster in a single message. +For example, a single request can create multiple transfers but it cannot create both accounts and +transfers. + +The cluster commits an entire request at once. Events are applied in series, such that successive +events observe the effects of previous ones and event timestamps are +[totally ordered](./time.md#timestamps-are-totally-ordered). + +Each request receives one _reply_ message from the cluster. The reply contains one _result_ for each +event in the request. + +## Request Types + +- [`create_accounts`](../reference/requests/create_accounts.md): create [`Account`s](../reference/account.md) +- [`create_transfers`](../reference/requests/create_transfers.md): create [`Transfer`s](../reference/transfer.md) +- [`lookup_accounts`](../reference/requests/lookup_accounts.md): fetch `Account`s by `id` +- [`lookup_transfers`](../reference/requests/lookup_transfers.md): fetch `Transfer`s by `id` +- [`get_account_transfers`](../reference/requests/get_account_transfers.md): fetch `Transfer`s by `debit_account_id` or + `credit_account_id` +- [`get_account_balances`](../reference/requests/get_account_balances.md): fetch the historical account balance by the + `Account`'s `id`. +- [`query_accounts`](../reference/requests/query_accounts.md): query `Account`s +- [`query_transfers`](../reference/requests/query_transfers.md): query `Transfer`s + +## Events and Results + +Each request has a corresponding _event_ and _result_ type: + +| Request Type | Event | Result | +| ----------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `create_accounts` | [`Account`](../reference/requests/create_accounts.md#event) | [`CreateAccountResult`](../reference/requests/create_accounts.md#result) | +| `create_transfers` | [`Transfer`](../reference/requests/create_transfers.md#event) | [`CreateTransferResult`](../reference/requests/create_transfers.md#result) | +| `lookup_accounts` | [`Account.id`](../reference/requests/lookup_accounts.md#event) | [`Account`](../reference/requests/lookup_accounts.md#result) or nothing | +| `lookup_transfers` | [`Transfer.id`](../reference/requests/lookup_transfers.md#event) | [`Transfer`](../reference/requests/lookup_transfers.md#result) or nothing | +| `get_account_transfers` | [`AccountFilter`](../reference/account-filter.md) | [`Transfer`](../reference/requests/get_account_transfers.md#result) or nothing | +| `get_account_balances` | [`AccountFilter`](../reference/account-filter.md) | [`AccountBalance`](../reference/requests/get_account_balances.md#result) or nothing | +| `query_accounts` | [`QueryFilter`](../reference/query-filter.md) | [`Account`](../reference/requests/lookup_accounts.md#result) or nothing | +| `query_transfers` | [`QueryFilter`](../reference/query-filter.md) | [`Transfer`](../reference/requests/lookup_transfers.md#result) or nothing | + +### Idempotency + +Events that create objects are idempotent. The first event to create an object with a given `id` +will receive the `ok` result. Subsequent events that attempt to create the same object will receive +the `exists` result. + +## Batching Events + +To achieve high throughput, TigerBeetle amortizes the overhead of consensus and I/O by +[batching](../concepts/performance.md#batching-batching-batching) +many events in each request. + +In the default configuration, the maximum batch sizes for each request type are: + +| Request Type | Request Batch Size (Events) | Reply Batch Size (Results) | +| ----------------------- | --------------------------: | -------------------------: | +| `lookup_accounts` | 8189 | 8189 | +| `lookup_transfers` | 8189 | 8189 | +| `create_accounts` | 8189 | 8189 | +| `create_transfers` | 8189 | 8189 | +| `get_account_transfers` | 1† | 8189 | +| `get_account_balances` | 1† | 8189 | +| `query_accounts` | 1† | 8189 | +| `query_transfers` | 1† | 8189 | + +- [Node.js](/src/clients/node/README.md#batching) +- [Go](/src/clients/go/README.md#batching) +- [Java](/src/clients/java/README.md#batching) +- [.NET](/src/clients/dotnet/README.md#batching) +- [Python](/src/clients/python/README.md#batching) + +### Automatic Batching + +TigerBeetle clients automatically batch operations. There may be instances where your application logic +makes it hard to fill up the batches that you send to TigerBeetle, for example a multi-threaded web +server where each HTTP request is handled on a different thread. + +The TigerBeetle client should be shared across threads (or tasks, depending on your paradigm), since +it automatically groups together batches of small sizes into one request. Since TigerBeetle clients +can have [**at most one in-flight request**](../reference/sessions.md), the client +accumulates smaller batches together while waiting for a reply to the last request. + +†: For queries (e.g. `get_account_transfers`, etc) TigerBeetle clients use the query `limit` to +automatically batch queries of the same type together into requests when it knows for sure that all +of their results will fit in a single reply. + +## Guarantees + +- A request executes within the cluster at most once. +- Requests do not [time out](../reference/sessions.md#retries). Clients will continuously retry requests until + they receive a reply from the cluster. This is because in the case of a network partition, a lack + of response from the cluster could either indicate that the request was dropped before it was + processed or that the reply was dropped after the request was processed. Note that individual + [pending transfers](./two-phase-transfers.md) within a request may have + [timeouts](../reference/transfer.md#timeout). +- Requests retried by their original client session receive identical replies. +- Requests retried by a different client (same request body, different session) may receive + different replies. +- Events within a request are executed in sequence. The effects of a given event are observable when + the next event within that request is applied. +- Events within a request do not interleave with events from other requests. +- All events within a request batch are committed, or none are. Note that this does not mean that + all of the events in a batch will succeed, or that all will fail. Events succeed or fail + independently unless they are explicitly [linked](./linked-events.md). +- Once committed, an event will always be committed -- the cluster's state never backtracks. +- Within a cluster, object + [timestamps are unique and strictly increasing](./time.md#timestamps-are-totally-ordered). + No two objects within the same cluster will have the same timestamp. Furthermore, the order of the + timestamps indicates the order in which the objects were committed. diff --git a/ocam/docs/coding/system-architecture.md b/ocam/docs/coding/system-architecture.md new file mode 100644 index 00000000..cd90cb16 --- /dev/null +++ b/ocam/docs/coding/system-architecture.md @@ -0,0 +1,72 @@ +# TigerBeetle in Your System Architecture + +TigerBeetle is an Online Transaction Processing (OLTP) database built for safety and performance. It +is not a general purpose database like PostgreSQL or MySQL. Instead, TigerBeetle works alongside +your general purpose database, which we refer to as an Online General Purpose (OLGP) database. + +TigerBeetle should be used in the data plane, or hot path of transaction processing, while your +general purpose database is used in the control plane and may be used for storing information or +metadata that is updated less frequently. + +![TigerBeetle in Your System Architecture](https://github.com/user-attachments/assets/679ec8be-640d-4c7e-b082-076557baeac7) + +## Division of Responsibilities + +**App or Website** + +- Initiate transactions +- [Generate Transfer and Account IDs](./reliable-transaction-submission.md#the-app-or-browser-should-generate-the-id) + +**Stateless API Service** + +- Handle authentication and authorization +- Create account records in both the general purpose database and TigerBeetle when users sign up +- [Cache ledger metadata](#ledger-account-and-transfer-types) +- [Batch transfers](./requests.md#batching-events) +- Apply exchange rates for [currency exchange](./recipes/currency-exchange.md) transactions + +**General Purpose (OLGP) Database** + +- Store metadata about ledgers and accounts (such as string names or descriptions) +- Store mappings between [integer type identifiers](#ledger-account-and-transfer-types) used in + TigerBeetle and string representations used by the app and API + +**TigerBeetle (OLTP) Database** + +- Record transfers between accounts +- Track balances for accounts +- Enforce balance limits +- Enforce financial consistency through double-entry bookkeeping +- Enforce strict serializability of events +- Optionally store pointers to records or entities in the general purpose database in the + [`user_data`](./data-modeling.md#user_data) fields + +## Ledger, Account, and Transfer Types + +For performance reasons, TigerBeetle stores the ledger, account, and transfer types as simple +integers. Most likely, you will want these integers to map to enums of type names or strings, along +with other associated metadata. + +The mapping from the string representation of these types to the integers used within TigerBeetle +may be hard-coded into your application logic or stored in a general purpose (OLGP) database and +cached by your application. (These mappings should be immutable and append-only, so there is no +concern about cache invalidation.) + +⚠️ Importantly, **initiating a transfer should not require fetching metadata from the general +purpose database**. If it does, that database will become the bottleneck and will negate the +performance gains from using TigerBeetle. + +Specifically, the types of information that fit into this category include: + +| Hard-coded in app or cached | In TigerBeetle | +| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Currency or asset code's string representation (for example, "USD") | [`ledger`](./data-modeling.md#asset-scale) and [asset scale](./data-modeling.md#asset-scale) | +| Account type's string representation (for example, "cash") | [`code`](./data-modeling.md#code) | +| Transfer type's string representation (for example, "refund") | [`code`](./data-modeling.md#code) | + +## Authentication + +TigerBeetle does not support authentication. You should never allow untrusted users or services to +interact with it directly. + +Also, untrusted processes must not be able to access or modify TigerBeetle's on-disk data file. diff --git a/ocam/docs/coding/time.md b/ocam/docs/coding/time.md new file mode 100644 index 00000000..95c3cb08 --- /dev/null +++ b/ocam/docs/coding/time.md @@ -0,0 +1,71 @@ +# Time + +Time is a critical component of all distributed systems and databases. Within TigerBeetle, we keep +track of two types of time: logical time and physical time. Logical time is about ordering events +relative to each other, and physical time is the everyday time, a numeric timestamp. + +## Logical Time + +TigerBeetle uses a consensus protocol ([Viewstamped +Replication](https://hdl.handle.net/1721.1/71763)) to guarantee [strict +serializability](http://www.bailis.org/blog/linearizability-versus-serializability/) for all +operations. + +In other words, to an external observer, TigerBeetle cluster behaves as if it is just a single +machine which processes the incoming requests in order. If an application submits a batch of +transfers with transfer `T1`, receives a reply, and then submits a batch with another transfer `T2`, +it is guaranteed that `T2` will observe the effects of `T1`. Note, however, that there could be +concurrent requests from multiple applications, so, unless `T1` and `T2` are in the same batch of +transfers, some other transfer could happen in between them. See the +[reference](../reference/sessions.md) for precise guarantees. + +## Physical Time + +TigerBeetle uses physical time in addition to the logical time provided by the consensus algorithm. +Financial transactions require physical time for multiple reasons, including: + +- **Liquidity** - TigerBeetle supports [Two-Phase Transfers](./two-phase-transfers.md) that reserve + funds and hold them in a pending state until they are posted, voided, or the transfer times out. A + timeout is useful to ensure that the reserved funds are not held in limbo indefinitely. +- **Compliance and Auditing** - For regulatory and security purposes, it is useful to have a + specific idea of when (in terms of wall clock time) transfers took place. + +TigerBeetle uses two-layered approach to physical time. On the basic layer, each replica asks the +underling operating system about the current time. Then, timing information from several replicas is +aggregated to make sure that the replicas roughly agree on the time, to prevent a replica with a bad +clock from issuing incorrect timestamps. Additionally, this "cluster time" is made strictly +monotonic, for end user's convenience. + +## Why TigerBeetle Manages Timestamps + +An important invariant is that the TigerBeetle cluster assigns all timestamps. In particular, +timestamps on [`Transfer`s](../reference/transfer.md#timestamp) and +[`Account`s](../reference/account.md#timestamp) are set by the cluster when the corresponding event +arrives at the primary. This is why the `timestamp` field must be set to `0` when operations are +submitted by the client. + +Similarly, the [`Transfer.timeout`](../reference/transfer.md#timeout) is given as an interval +in seconds, rather than as an absolute timestamp, because it is also managed by the primary. The +`timeout` is calculated relative to the `timestamp` when the operation arrives at the primary. + +This restriction is needed to make sure that any two timestamps always refer to the same underlying +clock (cluster's physical time) and are directly comparable. This in turn provides a set of powerful +guarantees. + +### Timestamps are Totally Ordered + +All `timestamp`s within TigerBeetle are unique, immutable and +[totally ordered](https://book.mixu.net/distsys/time.html). A transfer that is created before another +transfer is guaranteed to have an earlier `timestamp` (even if they were created in the same +request). + +In other systems this is also called a "physical" timestamp, "ingestion" timestamp, "record" +timestamp, or "system" timestamp. + +## Further Reading + +If you are curious how exactly it is that TigerBeetle achieves strictly monotonic physical time, we +have a talk and a blog post with details: + +* [Detecting Clock Sync Failure in Highly Available Systems (YouTube)](https://youtu.be/7R-Iz6sJG6Q?si=9sD2TpfD29AxUjOY) +* [Three Clocks are Better than One (TigerBeetle Blog)](https://tigerbeetle.com/blog/three-clocks-are-better-than-one/) diff --git a/ocam/docs/coding/two-phase-transfers.md b/ocam/docs/coding/two-phase-transfers.md new file mode 100644 index 00000000..c704c57f --- /dev/null +++ b/ocam/docs/coding/two-phase-transfers.md @@ -0,0 +1,174 @@ +# Two-Phase Transfers + +A two-phase transfer moves funds in stages: + +1. Reserve funds ([pending](#reserve-funds-pending-transfer)) +2. Resolve funds ([post](#post-pending-transfer), [void](#void-pending-transfer), or + [expire](#expire-pending-transfer)) + +The name "two-phase transfer" is a reference to the +[two-phase commit protocol for distributed transactions](https://en.wikipedia.org/wiki/Two-phase_commit_protocol). + +## Reserve Funds (Pending Transfer) + +A pending transfer, denoted by [`flags.pending`](../reference/transfer.md#flagspending), +reserves its `amount` in the debit/credit accounts' +[`debits_pending`](../reference/account.md#debits_pending)/[`credits_pending`](../reference/account.md#credits_pending) +fields, respectively. Pending transfers leave the `debits_posted`/`credits_posted` unmodified. + +## Resolve Funds + +Pending transfers can be posted, voided, or they may time out. + +### Post-Pending Transfer + +A post-pending transfer, denoted by +[`flags.post_pending_transfer`](../reference/transfer.md#flagspost_pending_transfer), causes a +pending transfer to "post", transferring some or all of the pending transfer's reserved amount to +its destination. + +- If the posted [`amount`](../reference/transfer.md#amount) is less than the pending transfer's + amount, then only this amount is posted, and the remainder is restored to its original accounts. +- If the posted [`amount`](../reference/transfer.md#amount) is equal to the pending transfer's + amount or equal to `AMOUNT_MAX` (`2^128 - 1`), the full pending transfer's amount is posted. +- If the posted [`amount`](../reference/transfer.md#amount) is greater than the pending transfer's + amount (but less than `AMOUNT_MAX`), + [`exceeds_pending_transfer_amount`](../reference/requests/create_transfers.md#exceeds_pending_transfer_amount) + is returned. + +
+Client < 0.16.0 + +- If the posted [`amount`](../reference/transfer.md#amount) is 0, the full pending transfer's + amount is posted. +- If the posted [`amount`](../reference/transfer.md#amount) is nonzero, then only this amount + is posted, and the remainder is restored to its original accounts. It must be less than or equal + to the pending transfer's amount. + +
+ +Additionally, when `flags.post_pending_transfer` is set: + +- [`pending_id`](../reference/transfer.md#pending_id) must reference a + [pending transfer](#reserve-funds-pending-transfer) +- [`flags.void_pending_transfer`](../reference/transfer.md#flagsvoid_pending_transfer) must not + be set. + +The following fields may either be zero or they must match the value of the pending transfer's +field: + +- [`debit_account_id`](../reference/transfer.md#debit_account_id) +- [`credit_account_id`](../reference/transfer.md#credit_account_id) +- [`ledger`](../reference/transfer.md#ledger) +- [`code`](../reference/transfer.md#code) + +### Void-Pending Transfer + +A void-pending transfer, denoted by +[`flags.void_pending_transfer`](../reference/transfer.md#flagsvoid_pending_transfer), restores +the pending amount its original accounts. Additionally, when this field is set: + +- [`pending_id`](../reference/transfer.md#pending_id) must reference a + [pending transfer](#reserve-funds-pending-transfer) +- [`flags.post_pending_transfer`](../reference/transfer.md#flagspost_pending_transfer) must not + be set. + +The following fields may either be zero or they must match the value of the pending transfer's +field: + +- [`debit_account_id`](../reference/transfer.md#debit_account_id) +- [`credit_account_id`](../reference/transfer.md#credit_account_id) +- [`ledger`](../reference/transfer.md#ledger) +- [`code`](../reference/transfer.md#code) + +### Expire Pending Transfer + +A pending transfer may optionally be created with a +[timeout](../reference/transfer.md#timeout). If the timeout interval passes before the transfer +is either posted or voided, the transfer expires and the full amount is returned to the original +account. + +Note that `timeout`s are given as intervals, specified in seconds, rather than as absolute +timestamps. For more details on why, read the page about [Time in TigerBeetle](./time.md). + +### Errors + +A pending transfer can only be posted or voided once. It cannot be posted twice or voided then +posted, etc. + +Attempting to resolve a pending transfer more than once will return the applicable error result: + +- [`pending_transfer_already_posted`](../reference/requests/create_transfers.md#pending_transfer_already_posted) +- [`pending_transfer_already_voided`](../reference/requests/create_transfers.md#pending_transfer_already_voided) +- [`pending_transfer_expired`](../reference/requests/create_transfers.md#pending_transfer_expired) + +## Interaction with Account Invariants + +The pending transfer's amount is reserved in a way that the second step in a two-phase transfer will +never cause the accounts' configured balance invariants +([`credits_must_not_exceed_debits`](../reference/account.md#flagscredits_must_not_exceed_debits) +or +[`debits_must_not_exceed_credits`](../reference/account.md#flagsdebits_must_not_exceed_credits)) +to be broken, whether the second step is a post or void. + +### Pessimistic Pending Transfers + +If an account with +[`debits_must_not_exceed_credits`](../reference/account.md#flagsdebits_must_not_exceed_credits) +has `credits_posted = 100` and `debits_posted = 70` and a pending transfer is started causing the +account to have `debits_pending = 50`, the _pending_ transfer will fail. It will not wait to get to +_posted_ status to fail. + +## All Transfers Are Immutable + +To reiterate, completing a two-phase transfer (by either marking it void or posted) does not involve +modifying the pending transfer. Instead you create a new transfer. + +The first transfer that is marked pending will always have its pending flag set. + +The second transfer will have a +[`post_pending_transfer`](../reference/transfer.md#flagspost_pending_transfer) or +[`void_pending_transfer`](../reference/transfer.md#flagsvoid_pending_transfer) flag set and a +[`pending_id`](../reference/transfer.md#pending_id) field set to the +[`id`](../reference/transfer.md#id) of the first transfer. The +[`id`](../reference/transfer.md#id) of the second transfer will be unique, not the same +[`id`](../reference/transfer.md#id) as the initial pending transfer. + +## Examples + +The following examples show the state of two accounts in three steps: + +1. Initially, before any transfers +2. After a pending transfer +3. And after the pending transfer is posted or voided + +### Post Full Pending Amount + +| Account `A` | | Account `B` | | Transfers | | | | +| :---------- | :--------- | :---------- | :--------- | :------------------- | :-------------------- | ---------: | :---------------------- | +| **debits** | | **credits** | | | | | | +| **pending** | **posted** | **pending** | **posted** | **debit_account_id** | **credit_account_id** | **amount** | **flags** | +| `w` | `x` | `y` | `z` | - | - | - | - | +| `w` + 123 | `x` | `y` + 123 | `z` | `A` | `B` | 123 | `pending` | +| `w` | `x`+ 123 | `y` | `z` + 123 | `A` | `B` | 123 | `post_pending_transfer` | + +### Post Partial Pending Amount + +| Account `A` | | Account `B` | | Transfers | | | | +| :---------- | :--------- | :---------- | :--------- | :------------------- | :-------------------- | ---------: | :---------------------- | +| **debits** | | **credits** | | | | | | +| **pending** | **posted** | **pending** | **posted** | **debit_account_id** | **credit_account_id** | **amount** | **flags** | +| `w` | `x` | `y` | `z` | - | - | - | - | +| `w` + 123 | `x` | `y` + 123 | `z` | `A` | `B` | 123 | `pending` | +| `w` | `x` + 100 | `y` | `z` + 100 | `A` | `B` | 100 | `post_pending_transfer` | + +### Void Pending Transfer + +| Account `A` | | Account `B` | | Transfers | | | | +| :---------- | :--------- | :---------- | :--------- | :------------------- | :-------------------- | ---------: | :---------------------- | +| **debits** | | **credits** | | | | | | +| **pending** | **posted** | **pending** | **posted** | **debit_account_id** | **credit_account_id** | **amount** | **flags** | +| `w` | `x` | `y` | `z` | - | - | - | - | +| `w` + 123 | `x` | `y` + 123 | `z` | `A` | `B` | 123 | `pending` | +| `w` | `x` | `y` | `z` | `A` | `B` | 123 | `void_pending_transfer` | + diff --git a/ocam/docs/concepts/README.md b/ocam/docs/concepts/README.md new file mode 100644 index 00000000..276d4cca --- /dev/null +++ b/ocam/docs/concepts/README.md @@ -0,0 +1,12 @@ +# Concepts + +This section is for anyone evaluating TigerBeetle, eager to learn about it, or curious. It focuses +on the big picture and problems that TigerBeetle solves. As well as why it looks nothing like a +typical SQL database from the outside _and_ from the inside. + +- [OLTP](./oltp.md) defines the domain of TigerBeetle --- system of record for business + transactions. +- [Debit-Credit](./debit-credit.md) argues that double-entry bookkeeping is the right schema for + this domain. +- [Performance](./performance.md) explains how TigerBeetle achieves state-of-the-art performance. +- [Safety](./safety.md) shows that safety and performance are not at odds with each other. diff --git a/ocam/docs/concepts/debit-credit.md b/ocam/docs/concepts/debit-credit.md new file mode 100644 index 00000000..38fd0a9a --- /dev/null +++ b/ocam/docs/concepts/debit-credit.md @@ -0,0 +1,155 @@ +# Debit/Credit: The Schema for OLTP + +As discussed in the previous section, OLTP is all about processing business transactions. We saw +that the nuances of OLTP workloads make them tricky to handle at scale. + +Now, we'll turn to the data model and see how the specifics of business transactions actually lend +themselves to an incredibly simple schema that's been in use for centuries. + +## The "Who, What, When, Where, Why, and How Much" of OLTP + +OLTP and business transactions tend to record the same types of information: + +- **Who**: which accounts are transacting? +- **What**: what type of asset or value is moving? +- **When**: when was the transaction initiated or when was it finalized? +- **Where**: where in the world did the transaction take place? +- **Why**: what type of transaction is this or why is it happening? +- **How Much**: what quantity of the asset or items was moved? + +## The Language of Business for Centuries + +Debit/Credit, or double-entry bookkeeping, has been the lingua franca of business and accounting +[since at least the 13th century](https://en.wikipedia.org/wiki/History_of_accounting). + +The key insight underpinning Debit/Credit systems is that every transfer records a movement of +value from one or more accounts to one or more accounts. Money never appears from nowhere or +disappears. This simple principle helps ensure that all of a business's money is accounted for. + +Debit/Credit perfectly captures the who, what, when, where, why, and how much of OLTP while +ensuring financial consistency. It is minimal and complete: two entities (accounts, transfers) +and one invariant (every debit has an equal and opposite credit) model any exchange of value, +in any domain. + +(For a deeper dive on debits and credits, see our primer on +[Financial Accounting](../coding/financial-accounting.md).) + +## SQL vs Debit/Credit + +While SQL is a great query language for getting data out of a database, OLTP is primarily about +getting data into the database and this is where SQL falls short. + +**Often, a single business transaction requires multiple SQL queries (on the order of 10 SQL queries +per transaction)** and potentially even multiple round-trips from the application to the database. + +By designing a database specifically for the schema and needs of OLTP, we can ensure our accounting +logic is enforced correctly while massively increasing performance. + +## TigerBeetle Enforces Debit/Credit in the Database + +The schema of OLTP is built into TigerBeetle's data model, and is ready for you to use: + +- **Who**: the [`debit_account_id`](../reference/transfer.md#debit_account_id) and + [`credit_account_id`](../reference/transfer.md#credit_account_id) indicate which accounts are + transacting. +- **What**: each asset or type of value in TigerBeetle is tracked on a separate + [ledger](../coding/data-modeling.md#ledgers). The [`ledger`](../reference/transfer.md#ledger) + field indicates what is being transferred. +- **When**: each transfer has a unique [`timestamp`](../reference/transfer.md#timestamp) for when it + is processed by the cluster, but you can add another timestamp representing when the transaction + happened in the real world in the [`user_data_64`](../reference/transfer.md#user_data_64) field. +- **Where**: the [`user_data_32`](../reference/transfer.md#user_data_32) can be used to store the + locale where the transfer occurred. +- **Why**: the [`code`](../reference/transfer.md#code) field stores the reason a transfer occurred + and should map to an enum or table of all the possible business events. +- **How Much**: the [`amount`](../reference/transfer.md#amount) indicates how much of the asset or + item is being transferred. + +TigerBeetle also supports [two-phase transfers](../coding/two-phase-transfers.md) out of the box, +and can express complex atomic chains of transfers using +[linked events](../coding/linked-events.md). These powerful built-in primitives allow for a large +vocabulary of [patterns and recipes](../coding/recipes/) for +[data modeling](../coding/data-modeling.md). + +Crucially, accounting invariants such as balance limits are enforced within the database, avoiding +round-trips between your database and application logic. + +## Immutability is Essential + +Another critical element of Debit/Credit systems is immutability: once transfers are recorded, +they cannot be erased. Reversals are implemented with separate transfers to provide a full and +auditable log of business events. + +Even the strongest durability doesn't prevent logical data loss. Where SQL allows destructive UPDATE +and DELETE, TigerBeetle enforces append-only immutability — ensuring effortless reconciliation and +audit success. Transfers in TigerBeetle are always immutable, out of the box. There is no possibility +of a malformed query unintentionally deleting data. + +Accidentally dropping rows or tables is bad in any database, but it is unacceptable when it comes to +accounting. Legal compliance and good business practices require that all funds be fully accounted +for, and all history be maintained. + +### Preserving User Privacy + +Many applications must comply with GDPR and other privacy-preserving requirements, such as the right +to be forgotten. User privacy can be preserved by being intentional about what data is stored in +the `user_data_*` fields. + +For example, if `user_data_128` is used to map from your application's unique `user_id` and +TigerBeetle, a user can be 'forgotten' by deleting that mapping. Without it, the accounts and +transfers in TigerBeetle cannot be linked back to a real user, rendering them meaningless and +preserving the user's privacy. + +## Don't Roll Your Own Ledger + +Many companies start out building their own system for recording business transactions. Then, once +their business scales, they [realize they need a proper ledger](https://tigerbeetle.com/stories/super) +and end up coming back to debits and credits. + +A number of prime examples of this are: + +- **Uber**: In 2018, Uber started a 2-year, 40-engineer effort to migrate their collection and + disbursement payment platform to one based on the principles of double-entry accounting and debits + and credits.[^1] +- **Airbnb**: From 2012 to 2016, Airbnb used a MySQL-based data pipeline to record all of its + transactions in an immutable store suitable for reporting. The pipeline became too complex, hard + to scale, and slow. They ended up building a new financial reporting system based on double-entry + accounting.[^2] +- **Stripe**: While we don't know when this system initially went into service, Stripe relies on an + internal system based on double-entry accounting and an immutable log of events to record all of + the payments they process.[^3] + +[^1]: + Singla, A., & Wu, S. (2020, October 2). _Revolutionizing Money Movements at Scale with Strong + Data Consistency_. Uber Blog. + [https://www.uber.com/blog/money-scale-strong-data](https://www.uber.com/blog/money-scale-strong-data). + +[^2]: + Liang, A. (2017, March 16). _Tracking the Money — Scaling Financial Reporting at Airbnb_. The + Airbnb Tech Blog. + [https://medium.com/airbnb-engineering/tracking-the-money-scaling-financial-reporting-at-airbnb-6d742b80f040](https://medium.com/airbnb-engineering/tracking-the-money-scaling-financial-reporting-at-airbnb-6d742b80f040). + +[^3]: + Ganelin, I. (2024, February 16). _Ledger: Stripe’s system for tracking and validating money + movement_. Stripe Blog. + [https://stripe.com/blog/ledger-stripe-system-for-tracking-and-validating-money-movement](https://stripe.com/blog/ledger-stripe-system-for-tracking-and-validating-money-movement). + +## Standardized, Simple, and Scalable + +From one perspective, Debit/Credit may seem like a limited data model. However, it is incredibly +flexible and scalable. Any business event can be recorded as debits and credits -- indeed, +accountants have been doing precisely this for centuries! + +Instead of modeling business transactions as a set of ad-hoc tables and relationships, debits and +credits provide a simple and standardized schema that can be used across all product lines, now and +in the future. This avoids the need to add columns, tables, and complex relations between them as +new features are added -- and avoids complex schema migrations. + +Debit/Credit is a universal schema, the foundation of business for hundreds of years, and you can +leverage TigerBeetle's high-performance implementation of it, built for OLTP in the 21st century. + +## Next: Performance + +So far, we've seen why we need a new database designed for OLTP and how Debit/Credit provides the +perfect data model for it. Next, we look at the [performance](./performance.md) of a database +designed for OLTP. diff --git a/ocam/docs/concepts/oltp.md b/ocam/docs/concepts/oltp.md new file mode 100644 index 00000000..ffc6a88e --- /dev/null +++ b/ocam/docs/concepts/oltp.md @@ -0,0 +1,81 @@ +# Online Transaction Processing (OLTP) + +Online Transaction Processing (OLTP) is about **recording business transactions in real-time**. This +could be payments, sales, car sharing rides, game scores, or API usage. + +## The World is Becoming More Transactional + +Historically, general purpose databases like PostgreSQL, MySQL, and SQLite handled OLTP. We refer +to these as Online General Purpose (OLGP) databases. + +OLTP workloads have increased by 3-4 orders of magnitude in the last 10 years alone. For example: + +- The [UPI](https://en.wikipedia.org/wiki/Unified_Payments_Interface) + real-time payments switch in India processed 10 billion payments in the year 2019. + In January 2025 alone, it processed [16.9 billion payments.](https://www.npci.org.in/what-we-do/upi/product-statistics) +- Cleaner energy and smart metering means energy is being traded by the kilowatt-hour. + Customer billing is every 15 or 30 minutes rather than at the end of the month. +- Serverless APIs charge for usage by the second or per-request, rather than per month. (Today, + serverless billing at scale is often implemented using [MapReduce](https://en.wikipedia.org/wiki/MapReduce). + This makes it difficult or impossible to offer customers real-time spending caps.) + +OLGP databases already struggle to keep up. + +**But TigerBeetle is built to handle the scale of OLTP workloads today and for the decades to come.** +It works well alongside OLGP databases, which hold infrequently updated data. +TigerBeetle can race ahead, giving your system unparalleled latency and throughput. + +## Write-Heavy Workloads + +A distinguishing characteristic of OLTP is its focus on _recording_ business transactions. In +contrast, OLGP databases are often designed for read-heavy or balanced workloads. + +TigerBeetle is optimized from the ground up for write-heavy workloads. This means it can handle the +increasing scale of OLTP, unlike an OLGP database. + +## High Contention on Hot Accounts + +Business transactions always involve more than one account. One account gets paid but then there are +fees, taxes, revenue splits, and other costs to account for. + +OLTP systems often have accounts involved in a high percentage of all transactions. This is +especially true for accounts that represent the business income or expenses. Locks can be used to +ensure that updates to these 'hot accounts' are consistent. But the resulting contention can bring +the system's performance to a crawl. + +TigerBeetle provides strong consistency guarantees without row locks. This sidesteps the issue of +contention on hot accounts. Due to TigerBeetle's use of the system cache, transactions processing +speed even _increases_. + +## Business Transactions Don't Shard Well + +One of the most common ways to scale systems is to horizontally scale or shard them. This means +different servers process different sets of transactions. Unfortunately, business transactions don't +shard well. Horizontal scaling is a poor fit for OLTP: + +- Most accounts cannot be neatly partitioned between shards. +- Transactions between accounts on different shards become more complex and slow. +- Row locks on hot accounts worsen when the transactions must execute across shards. + +Another approach to scaling OLTP systems is to use MapReduce for billing. But this makes it hard to +provide real-time balance reporting or spending limits. It also creates a poor user experience +that's hard to fix post system design. + +TigerBeetle uses a [single-core design](./performance.md#single-threaded-by-design ) +and unique performance optimizations to deliver high throughput. And this without the downsides of +horizontal scaling. + +## Bottleneck for Your System + +You can only do as much business as your database supports. You need a core OLTP database capable of +handling your transactions on your busiest days. And for decades to come. + +TigerBeetle is designed to handle **1 million transactions per second**, to remove the risk of your +business outgrowing your database. + +## Next Up: Debit / Credit is the Schema for OLTP + +The world is becoming more transactional. OLTP workloads are increasing and we need a database +designed from the ground up to handle them. +What is the perfect schema and language for this database? +[Debit / Credit](./debit-credit.md). diff --git a/ocam/docs/concepts/performance.md b/ocam/docs/concepts/performance.md new file mode 100644 index 00000000..e16efb4a --- /dev/null +++ b/ocam/docs/concepts/performance.md @@ -0,0 +1,90 @@ +# Performance + +How, exactly, is TigerBeetle so fast? + +## It's All About The Interface + +TigerBeetle is designed specifically for [OLTP](./oltp.md) workloads. + +The prevailing paradigm for OLGP is interactive transactions, where business-logic lives in +the application, and the job of the database is to send the data to the application, holding the +locks while the data is being processed. This works for mixed read-write workload with low +contention, but fails for highly-contended OLTP workloads --- locks over the network are very +expensive! + +With TigerBeetle, **all the logic lives inside the database**, obviating the need for locking. Not +only is this very fast, it is also more convenient --- the application can speak +[Debit/Credit](./debit-credit.md) directly, it doesn't need to translate the language of business to +SQL. This is the power of an interface for performance! + +## Batching, Batching, Batching + +On a busy day in a busy city, taking the subway is faster than using a car. On empty streets, a personal +sports car gives you the best latency, but when the load and contention increase, due to +[Little's law](https://en.wikipedia.org/wiki/Little%27s_law), both latency and throughput become abysmal. + +TigerBeetle works like a high-speed train --- its interface always deals with _batches_ of +transfers, up to 8,190 transfers per query. Although TigerBeetle is a replicated database using a +consensus algorithm, the cost of replication is paid only once per batch, which means that TigerBeetle runs +almost as fast as an in-memory hash map, all the while providing extreme durability and availability. + +What's more, under light load, the batches automatically become smaller, trading unnecessary +throughput for better latency. + +## Extreme Engineering + +Debit/Credit fixes inefficiency in the interface, pervasive batching amortizes costs, but, to really +hit performance targets, solid engineering is required at every level of the stack: + +- TigerBeetle is built fully from scratch, without using any dependencies, to make sure that all the +layers are co-designed for OLTP. +- TigerBeetle is written in [Zig](https://ziglang.org/), a systems programming language which doesn't +use garbage collection and is designed for writing fast code. +- Every data structure is hand-crafted with the CPU in mind: a transfer object is 128 bytes in size, +cache-line aligned. Executing a batch of transfers is just one tight CPU loop! +- TigerBeetle allocates all the memory statically: it never runs out of memory, it never stalls due to +a GC pause or mutex contention, and it never fragments the memory. +- TigerBeetle is designed for [io_uring](https://en.wikipedia.org/wiki/Io_uring), a Linux kernel interface +for zero syscall networking and storage I/O. + +These and other performance rules are captured in +[TigerStyle](https://github.com/tigerbeetle/tigerbeetle/blob/main/docs/TIGER_STYLE.md) --- the +secret recipe that keeps TigerBeetle fast and safe. + +## Single Threaded By Design + +TigerBeetle uses a single core by design and uses a single leader node to process events. Adding +more nodes can therefore increase reliability, but not throughput. + +For a high-performance database, this may seem like an unusual choice. However, sharding in +financial databases is notoriously difficult, and contention issues often negate the would-be +benefits. Specifically, a small number of hot accounts are often involved in a large proportion of +the transactions, so the shards responsible for those accounts become bottlenecks. + +For more details on when single-threaded implementations of algorithms outperform multi-threaded +implementations, see ["Scalability! But at what +COST?](https://www.usenix.org/system/files/conference/hotos15/hotos15-paper-mcsherry.pdf). + +## Performance = Flexibility + +Is it _really_ necessary to go to such great lengths in the name of performance? + +It depends on the use-case (worth keeping in mind is that higher performance can _unlock_ +new use-cases). An OLGP database might be enough for nightly settlement; for **real-time +settlement**, OLTP is a no-brainer. + +If a transaction system just hits its throughput target, every unexpected delay or ops accident +will lead to missed transactions. If a system operates at one tenth of capacity, there is headroom +for the unexpected. + +Last but not least, it is prudent to think about the future. The future is hard to predict (even the +_present_ is hard to wrap one's head around!); the option to handle significantly more load on +short notice greatly expands optionality and sleep quality. + +## Next: Safety + +Performance can get you very far very fast, but it is useless if the result is wrong. Business +transaction processing also requires **strong safety guarantees**, to ensure that data cannot be +lost, and **high availability** to ensure that money is not lost due to database downtime. + +Next, how TigerBeetle ensures [safety](./safety.md). diff --git a/ocam/docs/concepts/safety.md b/ocam/docs/concepts/safety.md new file mode 100644 index 00000000..1ba3b013 --- /dev/null +++ b/ocam/docs/concepts/safety.md @@ -0,0 +1,203 @@ +# Safety + +The purpose of a database is to store data: if the database accepts new data, it should be able to +retrieve it later. Surprisingly, many databases don't provide guaranteed durability -- usually the +data is there, but, under certain edge case conditions, it can get lost! + +As the purpose of TigerBeetle is to be the system of record for business transaction, associated +with real-world value transfers, it is paramount that the data stored in TigerBeetle is safe. + +TigerBeetle is therefore designed, engineered, and tested to deliver unbreakable durability -- +even under the most extreme failure scenarios. + +## Strict Serializability + +The easiest way to lose data is by incorrectly using the database, by misconfiguring (or just +misunderstanding) its isolation level. For this reason, TigerBeetle intentionally supports only the +strictest possible isolation level -- **strict serializability**. All transfers are executed +one-by-one, on a single core. + +Furthermore, TigerBeetle's state machine is designed according to the +[end-to-end idempotency principle](../coding/reliable-transaction-submission.md) -- +each transfer has a unique client-generated `u128` id, and each transfer is processed at most once, +even in the presence of intermediate retry loops. + +## High Availability + +Some databases rely on a single central server, which puts the data at risk as any single server +might fail catastrophically (e.g. due to a fire in the data center). Primary/backup systems with +ad-hoc failover can lose data due to +[split-brain](https://en.wikipedia.org/wiki/Split-brain_(computing)). + +To avoid these pitfalls, TigerBeetle implements pioneering +[Viewstamped Replication](https://hdl.handle.net/1721.1/71763) and consensus algorithm, +that guarantees correct, automatic failover. It's worth emphasizing that consensus proper needs only +be engaged during actual failover. During the normal operation, the cost of consensus is just the +cost of replication, which is further minimized because of +[batching](./performance.md#batching-batching-batching), tail latency tolerance, and pipelining. + +TigerBeetle does not depend on synchronized system clocks, does not use leader leases, and +**performs leader-based timestamping** so that your application can deal only with safe relative +quantities of time with respect to transfer timeouts. To ensure that the leader's clock is within +safe bounds of "true time", TigerBeetle combines all the clocks in the cluster to create a +fault-tolerant clock that we call +["cluster time"](https://tigerbeetle.com/blog/three-clocks-are-better-than-one/). + +For the highest availability, TigerBeetle should be deployed as a cluster of six replicas across three +different cloud providers (two replicas per provider). Because TigerBeetle uses +[Heidi Howard's flexible quorums](https://arxiv.org/pdf/1608.06696v1), this deployment is guaranteed +to tolerate a complete outage of any cloud provider and will likely survive even if one extra +replica fails. Multi-cloud eliminates lock-in, meets regulatory requirements, and protects +availability -- even through provider slowdowns and disruptions. + +TigerBeetle detects and overcomes +[Gray Failure](https://www.microsoft.com/en-us/research/wp-content/uploads/2017/06/paper-1.pdf) +automatically. If a replica's disk becomes slow or the network interface starts dropping packets, +TigerBeetle automatically adjusts replication topology to ensure that the slow replica doesn't +affect user-visible latencies, while still guaranteeing cluster-wide durability. + +## Storage Fault Tolerance + +Traditionally, databases assume that disks do not fail, or at least fail politely with a clear error +code. This is usually a reasonable assumption, but edge cases matter. + +HDD and SSD hardware can fail. Disks can silently return corrupt data ( +[0.031% of SSD disks per year](https://www.usenix.org/system/files/fast20-maneas.pdf), +[1.4% of Enterprise HDD disks per year](https://www.usenix.org/legacy/events/fast08/tech/full_papers/bairavasundaram/bairavasundaram.pdf)), +misdirect IO ( +[0.023% of SSD disks per year](https://www.usenix.org/system/files/fast20-maneas.pdf), +[0.466% of Nearline HDD disks per year](https://www.usenix.org/legacy/events/fast08/tech/full_papers/bairavasundaram/bairavasundaram.pdf)), +or just suddenly become extremely slow, without returning an error code (the so called +[gray failure](https://www.microsoft.com/en-us/research/wp-content/uploads/2017/06/paper-1.pdf)). + +On top of hardware, software might be buggy or just tricky to use correctly. Handling fsync failures +correctly is [particularly hard](https://www.usenix.org/system/files/atc20-rebello.pdf). + +**TigerBeetle assumes that its disk _will_ fail** and takes advantage of replication to proactively +repair replica's local disks: + +- All data in TigerBeetle is immutable, checksummed, and [hash-chained](https://csrc.nist.gov/glossary/term/hash_chain), providing a strong guarantee + that no corruption or tampering happened. In case of a latent sector error, the error is detected + and repaired without any operator involvement. +- Most consensus implementations lose data or become unavailable if the write-ahead log gets + corrupted. TigerBeetle uses [Protocol Aware Recovery](https://www.youtube.com/watch?v=fDY6Wi0GcPs) + to remain available unless the data gets corrupted on every single replica. +- To minimize the impact of software bugs, TigerBeetle puts as little software as possible between + itself and the disk -- TigerBeetle manages its own page cache, writes data to disk with O_DIRECT + and can work with a block device directly, no file system is necessary. +- TigerBeetle also tolerates Gray Failure -- if a disk on a replica becomes very slow, the cluster + falls back on other replicas for durability. + +## Software Reliability + +Even the advanced algorithm with a formally proved correctness theorem is useless if the +implementation is buggy. TigerBeetle uses the oldest and the newest software engineering practices +to ensure correctness. + +TigerBeetle is written in [Zig](https://ziglang.org) -- a modern systems programming language that +removes many instances of undefined behavior, provides spatial memory safety and encourages simple, +straightforward code. + +TigerBeetle adheres to a strict code style, +[TigerStyle](https://github.com/tigerbeetle/tigerbeetle/blob/main/docs/TIGER_STYLE.md), inspired by +[NASA's power of ten](https://spinroot.com/gerard/pdf/P10.pdf). For example, TigerBeetle uses static +memory allocation, which designs away memory fragmentation, out-of-memory errors and +use-after-frees. + +TigerBeetle is tested in the [VOPR](https://tigerbeetle.com/blog/2023-07-06-simulation-testing-for-liveness/) +-- a simulated environment where an entire cluster, running real +code, is subjected to all kinds of network, storage and process faults, at 1000x speed. This +simulation can find both logical errors in the algorithms and coding bugs in the source. This +simulator is running 24/7 on 1024 cores, fuzzing the latest version of the database. You can also +[play it as a game](https://sim.tigerbeetle.com). + +## Human Fallibility + +While, with a lot of care, software can be perfected to become virtually bug-free, humans will +always make mistakes. TigerBeetle takes this into account and tries to protect from operator errors: + +- The surface area is intentionally minimized, with little configurability. +- In particular, there's only one isolation level -- strict serializability. +- Upgrades are automatic and atomic, guaranteeing that each transfer is applied by only a single + version of code. +- TigerBeetle always runs with online verification on, to detect any discrepancies in the data. + +## Is TigerBeetle ACID-compliant? + +Yes. Let's discuss each part: + +### Atomicity + +As part of replication, each operation is durably stored in at least a quorum of replicas' +Write-Ahead Logs (WAL) before the primary will acknowledge the operation as committed. WAL entries +are executed through the state machine business logic and the resulting state changes are stored in +TigerBeetle's LSM-Forest local storage engine. + +The WAL is what allows TigerBeetle to achieve atomicity and durability since the WAL is the source +of truth. If TigerBeetle crashes, the WAL is replayed at startup from the last checkpoint on disk. + +However, financial atomicity goes further than this: events and transfers can be +[linked](../coding/linked-events.md) when created so they all succeed or fail together. + +### Consistency + +TigerBeetle guarantees strict serializability. And at the cluster level, stale reads are not +possible since all operations (not only writes, but also reads) go through the global consensus +protocol. + +However, financial consistency requires more than this. TigerBeetle exposes a double-entry +accounting API to guarantee that money cannot be created or destroyed, but only transferred from one +account to another. And transfer history is immutable. + +### Isolation + +All client requests (and all events within a client request batch) are executed with the highest +level of isolation, serially through the state machine, one after another, before the next operation +begins. Counterintuitively, the use of batching and serial execution means that TigerBeetle can also +provide this level of isolation optimally, without the cost of locks for all the individual events +within a batch. + +### Durability + +Without Durability, the guarantees of Atomicity, Consistency, and Isolation collapse -- the only +letter in ACID whose loss undoes the others. + +Up until 2018, traditional DBMS durability has focused on the Crash Consistency Model, however, +Fsyncgate and +[Protocol Aware Recovery](https://www.usenix.org/conference/fast18/presentation/alagappan) have +shown that this model can lead to real data loss for users in the wild. TigerBeetle therefore adopts +an explicit storage fault model, which we then verify and test with incredible levels of corruption, +something which few distributed systems historically were designed to handle. Our emphasis on +protecting Durability sets TigerBeetle apart. + +While absolute durability is impossible -- all hardware can ultimately fail; data we write +today might not be available tomorrow -- TigerBeetle embraces limited disk reliability and maximizes +data durability in spite of imperfect disks. We actively work against such entropy by taking +advantage of cluster-wide storage. A record would need to get corrupted on all replicas in a cluster +to get lost, and even in that case **the system would safely halt**. + +## Security + +As a financial system of record, TigerBeetle is a trusted component and must be running in a +trusted environment. While TigerBeetle is extensively fuzzed, deals only with fixed-sized integer +data structures, has no (de)serialization and doesn't take user-generated strings, TigerBeetle +doesn't provide any permission system. The application must implement its own access controls. + +Note on `io_uring`: it is a relatively recent +([Linux 5.1, 2019](https://www.kernel.org/pub/linux/kernel/v5.x/ChangeLog-5.1)) +part of the Linux kernel. It had some kernel exploits, which made it problematic for sandboxed +applications, and lead to `io_uring` being +[disabled](https://security.googleblog.com/2023/06/learnings-from-kctf-vrps-42-linux.html) +for systems which deal with untrusted data. Because TigerBeetle, by design, only deals with trusted +integer data, its usage of `io_uring` is secure, and is the safest and most performant way to handle +asynchronous disk I/O. + +## Next: Coding + +This concludes the discussion of the concepts behind TigerBeetle --- an [OLTP](./oltp.md) database +for recording business transactions in real time, using a +[double-entry bookkeeping](./debit-credit.md) schema, which +[is orders of magnitude faster](./performance.md) and +[keeps the data safe](./safety.md) even when the underlying hardware inevitably fails. + +We will now learn [how to build applications on top of TigerBeetle](../coding/). diff --git a/ocam/docs/internals/ARCHITECTURE.md b/ocam/docs/internals/ARCHITECTURE.md new file mode 100644 index 00000000..f2744528 --- /dev/null +++ b/ocam/docs/internals/ARCHITECTURE.md @@ -0,0 +1 @@ +Moved to [../ARCHITECTURE.md](../ARCHITECTURE.md). diff --git a/ocam/docs/internals/HACKING.md b/ocam/docs/internals/HACKING.md new file mode 100644 index 00000000..4c4c79a5 --- /dev/null +++ b/ocam/docs/internals/HACKING.md @@ -0,0 +1,161 @@ +# Hacking on TigerBeetle + +**Prerequisites:** TigerBeetle makes use of certain fairly new technologies, such as +[io_uring](https://kernel.dk/io_uring.pdf) or advanced CPU instructions for cryptography. As such, +it requires a fairly modern kernel (≥ 5.6) and CPU. While at the moment only Linux is supported for +production deployments, TigerBeetle also works on Windows and MacOS. + +## Building + +```console +git clone https://github.com/tigerbeetle/tigerbeetle.git +cd tigerbeetle +./zig/download.ps1 # Yes, .ps1 even on Linux. +./zig/zig build -Drelease +./tigerbeetle version +``` + +See the [Quick Start](/docs/start.md) for how to use a freshly-built TigerBeetle and +[docs.tigerbeetle.com](https://docs.tigerbeetle.com) for the rest of user-facing documentation. + +## Testing + +All database tests: + +```console +./zig/zig build test +``` + +A specific test: + +```console +./zig/zig build test -- parse_addresses +``` + +Fuzzing ([/src/fuzz_tests.zig](/src/fuzz_tests.zig)): + +```console +./zig/zig build fuzz -- smoke +./zig/zig build fuzz -- lsm_tree +``` + +Continuous Integration entry point (see [ci.yml](/.github/workflows/ci.yml)): + +```console +./zig/zig build ci +``` + +## Simulation + +The bulk of testing happens via our deterministic simulator: + +```console +./zig/zig build vopr +``` + +To run the VOPR using a specific seed (this produces a fully deterministic, reproducible outcome): + +```console +./zig/zig build vopr -- 123 +``` + +See [./testing.md](./testing.md) for the explanation of the output format. + +## CFO + +In addition to the standard GitHub CI infrastructure that is used for tests and merge queue, we +employ a cluster of machines for continuous fuzzing, via the Continuous Fuzzing Orchestrator +([/src/scripts/cfo.zig](/src/scripts/cfo.zig)). You can see the results on devhub: + + + +To direct CFO's eye of Sauron towards your PR, apply one of `fuzz` labels, e.g., +[`fuzz vopr`](https://github.com/tigerbeetle/tigerbeetle/labels/fuzz%20vopr). + +## Clients + +Each client is built using language-specific tooling (`npm`, `maven`, `dotnet`, and `go`), and links +to a native library built with Zig. The general pattern is + +```console +./zig/zig build clients:lang +cd src/clients/lang +lang_package_manager test +``` + +See `src/clients/$LANG/ci.zig` scripts for exact commands we use on CI to build clients. + +### Testing Client Libraries + +Each language client is tested by a mixture of unit-tests written using language-specific test +frameworks, and integration tests which run sample projects against a real `tigerbeetle` process. +Everything is orchestrated by [ci.zig](/src/scripts/ci.zig) script: + +```console +./zig/zig build scripts -- ci --language=go +``` + +## Other Useful Commands + +Build & immediately run TigerBeetle: + +```console +./zig/zig build run -- format ... +``` + +Quickly check if the code compiles without spending time to generate the binary: + +```console +./zig/zig build check +``` + +Reformat the code according to style: + +``` +./zig/zig fmt . +``` + +Run lint checks: + +``` +./zig/zig build test -- tidy +``` + +Run macro benchmark: + +``` +./zig/zig build -Drelease run -- benchmark +``` + +See comments at the top of +[/src/tigerbeetle/benchmark_load.zig](/src/tigerbeetle/benchmark_load.zig) +for details of benchmarking. + +## Pull Requests + +When submitting pull request, _assign_ a single person to be its reviewer. Unpacking: + +* GitHub supports both "assign" and "request review". The difference between them is that "request" + is "edge triggered" (it is cleared after a round of review), while "assign" is "level triggered" + (it won't go away until the PR is merged or closed). We use "assign", because the reviewer is + co-responsible for making sure that the PR doesn't stall, and is eventually completed. + +* Only a single person is assigned to any particular pull request, to avoid diffusion of + responsibility and the bystander effect. + +* Pull request author chooses the reviewer. The author has the most context about who is the best + person to request review from. When picking a reviewer, think about sharing knowledge, balancing + review load, and maximizing correctness of the code. + +After pull request is approved, the author makes the final call to merge by clicking "merge when +ready" button on GitHub. To reduce the number of round-trips, "merge when ready" can be engaged +before the review is completed: a PR will then be merged automatically once an approving review is +submitted. + +Important exception: if a change potentially has non-trivial version compatibility implications (you +need to think at all about versions `v` and `v+1`), it should have one additional reviewer, whose +job is to find upgrade bugs. + +To synchronize the state of the pull request, rebase the pull request on top of main branch. You +don't need to proactively synchronize pull requests with main: merge queue runs the tests on the +merge commit from the PR branch into main. diff --git a/ocam/docs/internals/README.md b/ocam/docs/internals/README.md new file mode 100644 index 00000000..7f3a5c14 --- /dev/null +++ b/ocam/docs/internals/README.md @@ -0,0 +1,24 @@ +# TigerBeetle Internals + +Welcome, wanderer! You are looking at the TigerBeetle's internal documentation. If you want to _use_ +TigerBeetle, you don't need to read this and could head straight to our user-level docs at + + + +If you want to learn how TigerBeetle works inside, here's what we got: + +- [TIGER_STYLE](../TIGER_STYLE.md) is _the_ style guide, and more. This is the philosophy underlining + all the code here! +- [ARCHITECTURE](../ARCHITECTURE.md) is a one-page technical intro into TigerBeetle. If you are + learning the codebase, start here. +- [HACKING](./HACKING.md) gets you up to speed with building the codebase and running the tests. +- [Data File](./data_file.md) is a good second read. Following Fred Brooks' advice, it explains what + data is stored where and why. +- [VSR](./vsr.md) explains the upper consensus half of TigerBeetle. + - [Sync](./sync.md) covers state synchronization for lagging replicas, + - [Upgrades](./upgrades.md) are just so cool, you must read this! +- [LSM](./lsm.md) covers the lower storage half. +- [Releases](./releases.md) is our release process. +- [Talks](./talks.md) is the list of talks about TigerBeetle so far! +- [VOPR](./vopr.md) and [testing](./testing.md) cover the simulator. +- [docs](./docs.md) is our technical writing style guide. diff --git a/ocam/docs/internals/data_file.md b/ocam/docs/internals/data_file.md new file mode 100644 index 00000000..3ebc2d97 --- /dev/null +++ b/ocam/docs/internals/data_file.md @@ -0,0 +1,203 @@ +# Data File + +> “Just show me the tables already!” +> — probably not Fred Brooks + +Each TigerBeetle replica stores all data inside a single file, called the data file (conventional +extension is `.tigerbeetle`). This document describes the high level layout of the data file. The +presentation is simplified a bit, to provide intuition without drowning the reader in details. +Consult the source code for byte-level details! + +The data file is divided into several zones, with the main ones being: + +- write-ahead log +- superblock +- grid + +The grid forms the bulk of the data file (up to several terabytes). It is an elastic array of +512KiB blocks: + +```zig +pub const Block = [constants.block_size]u8; +pub const BlockPtr = *align(constants.sector_size) Block; +``` + +The grid serves as a raw storage layer. Higher level data structures (notably, the LSM tree) are +mapped to physical grid blocks. Because TigerBeetle is deterministic, the used portion of the grid +is identical across up-to-date replicas. This storage determinism is exploited to implement state +sync and repair on the level of grid blocks, see [the repair protocol](./vsr.md#protocol-repair-grid). + +A grid block is identified by a pair of a `u64` index and `u128` checksum: + +```zig +pub const BlockReference = struct { + index: u64, + checksum: u128, +}; +``` + +The block checksum is stored outside of the block itself, to protect from misdirected writes. So, to +read a block, you need to know the block's index and checksum from "elsewhere", where "elsewhere" is +either a different block, or the superblock. Overall, the grid is used to implement a purely +functional, persistent (in both senses), garbage collected data structure which is updated +atomically by swapping the pointer to the root node. This is the classic copy-on-write technique +commonly used in filesystems. In fact, you can think of TigerBeetle's data file as a filesystem. + +The superblock is what holds this logical "root pointer". Physically, the "root pointer" is comprised +of a couple of block references. These blocks, taken together, specify the manifests of all LSM trees. + +Superblock is located at a fixed position in the data file, so, when a replica starts up, it can +read the superblock, read root block indexes and hashes from the superblock, and through those get +access to the rest of the data in the grid. Besides the manifest, superblock also references a +compressed bitset, which is itself stored in the grid, of all grid blocks which are not currently +allocated. + +```zig +pub const SuperBlock = struct { + manifest_oldest: BlockReference, + manifest_newest: BlockReference, + free_set: BlockReference, +}; +``` + +Superblock durable updates must be atomic and need to write a fair amount of data (several +megabytes). To amortize this cost, superblock is flushed to disk relatively infrequently. The normal +mode of operation is that a replica starts up, reads the current superblock and free set to memory, +then proceeds allocating and writing new grid blocks, picking up free entries from the bit set. That +is, although the replica does write freshly allocated grid blocks to disk immediately, it does not +update the superblock on disk (so the logical state reachable from the superblock stays the same). +Only after a relatively large amount of new grid blocks are written, the replica atomically writes +the new superblock, with a new free set and a new logical "root pointer" (the superblock manifest). +If the replica crashes and restarts, it starts from the previous superblock, but, due to +determinism, replaying the operations after the crash results in exactly the same on-disk and +in-memory state. + +To implement atomic update of the superblock, the superblock is physically stored as 4 +distinct copies on disk. After startup, replica picks the latest superblock which has at least 2 +copies written. Picking just the latest copy would be wrong --- unlike the grid blocks, the +superblock stores its own checksum, and is vulnerable to misdirected reads (i.e., a misdirected read +can hide the sole latest copy). + +Because the superblock (and hence, logical grid state) is updated infrequently and in bursts, it +can't represent the entirety of persistent state. The rest of the state is stored in the write-ahead +log (WAL). The WAL is a ring buffer with prepares, and represents the logical diff which should be +applied to the state represented by superblock/grid to get the actual current state of the system. +WAL inner workings are described in the [VSR documentation](./vsr.md#protocol-normal), but, on a +high-level, when a replica processes a prepare, the replica: + +* writes the prepare to the WAL on disk +* applies changes from the prepare to the in-memory data structure representing the current state +* applies changes from the prepare to the pending on-disk state by allocating and writing fresh grid + blocks + +When enough prepares are received, the superblock is updated to point to the accumulated-so-far new +disk state. + +This covers how the three major areas of the data file -- the write-ahead log, the superblock and +the grid -- work together to represent abstract persistent logical state. + +Concretely, the state of TigerBeetle is a collection (forest) of LSM trees. LSM structure is +described [in a separate document](./lsm.md), here only high level on-disk layout is discussed. + +Each LSM tree stores a set of values. Values are: + +* uniform in size, +* small (hundreds of bytes), +* sorted by key, +* which is embedded in the value itself (e.g, an `Account` value uses `timestamp` as a unique key). + +To start from the middle, values are arranged in tables on disk. Each table represents a sorted +array of values and is physically stored in multiple blocks. Specifically: + +* A table's value blocks each store a sorted array of values. +* A table's index block stores pointers to the value blocks, as well as boundary keys. + +```zig +const TableValueBlock = struct { + values_sorted: [value_count_max]Value, +}; + +const TableIndexBlock = struct { + value_block_checksums: [value_block_count_max]u128, + value_block_indexes: [value_block_count_max]u64, + value_block_key_max: [value_block_count_max]Key, +}; + +const TableInfo = struct { + tree_id: u16, + index_block_index: u64, + index_block_checksum: u128, + key_min: Key, + key_max: Key, +}; +``` + +To lookup a value in a table, binary search the index block to locate the value block which should +hold the value, then binary search inside the value block. + +Table size is physically limited by a single index block which can hold only so many references to +value blocks. However, tables are further artificially limited to hold only a certain (compile-time +constant) number of entries. Tables are arranged in levels. Each subsequent level contains +exponentially more tables. + +Tables in a single level are pairwise disjoint. Tables in different layers overlap, but the key LSM: +invariant is observed: values in shallow layers override values in deeper layers. This means that +all modification happen to the first (purely in-memory) level. + +An asynchronous compaction process rebalances layers. Compaction removes one table from level A, finds +all tables from level A+1 that intersect that table, removes all those tables from level A+1 and +inserts the result of the intersection. + +Schematically, the effect of compaction can be represented as a sequence of events: + +```zig +const CompactionEvent = struct { + label: Label + table: TableInfo, // points to table's index block +}; + +const Label = struct { + level: u6, + event: enum(u2) { insert, update, remove }, +}; +``` + +What's more, the current state of a tree can be represented implicitly as a sequence of such +insertion and removal events, which starts from the empty set of tables. And that's exactly how it +is represented physically in a data file! + +Specifically, each LSM tree is a collection of layers which is stored implicitly as log of events. +The log consists of a sequence of `ManifestBlock`s: + +```zig +const ManifestBlock = struct { + previous_manifest_block: BlockReference, + labels: [entry_count_max]Label, + tables: [entry_count_max]TableInfo, +}; +``` + +The manifest is an on-disk (in-grid) linked list, where each manifest block holds a reference to the +previous block. + +The superblock then stores the oldest and newest manifest log blocks for all trees: + +```zig +const Superblock = { + manifest_block_oldest_address: u64, + manifest_block_oldest_checksum: u128, + manifest_block_newest_address: u64, + manifest_block_newest_checksum: u128, + free_set_last_address: u64, + free_set_last_checksum: u128, +}; +``` + +Tying everything together: + +State is represented as a collection of LSM trees. Superblock is the root of all state. For each LSM +tree, superblock contains the pointers to the blocks constituting each tree's manifest log -- a sequence +of individual tables additions and deletions. By replaying this manifest log, it is possible to +reconstruct the manifest in memory. `Manifest` describes levels and tables of a single LSM tree. A +table is a pointer to its index block. The index block is a sorted array of pointers to value +blocks. Value blocks are sorted arrays of values. diff --git a/ocam/docs/internals/docs.md b/ocam/docs/internals/docs.md new file mode 100644 index 00000000..552318b7 --- /dev/null +++ b/ocam/docs/internals/docs.md @@ -0,0 +1,60 @@ +# Docs Style Guide + +## Macro + +Docs are separated into user docs and developer docs. Right now, you are reading a developer +document describing how to write user docs! All docs are in the `./docs` folder in the repository. +Developer docs are in the `./docs/internals` subfolder. + +Both user and developer docs are considered to be integral parts of TigerBeetle database. +TIGER_STYLE applies to documentation as well! + +Documentation exists independent of presentation. In particular, the following three different +presentations are considered first-class: + +- Docs as rendered on by our own static site generator. +- Docs as viewable on by using GitHub built-in Markdown + renderer. +- Docs as raw markdown viewable in a local text editor. + +User docs roughly follow the Django-style organization. Specifically, they come in three flavors: + +- Tutorial ([start.md](/docs/start.md)) is an end-to-end quick tour which focuses on getting the + user to do specific things to achieve a specific goal, without necessary explaining what exactly + is going on. Tutorial are aimed at beginners and non-users. +- Guides gives an in-depth explanation of a particular area. Guides are used by new users, who know + the basics, and what to get a specific thing done. Unlike tutorials, guides should _always_ + explain the why. Every page under [coding](/docs/coding/) and [operating](/docs/operating) is a + guide. +- Reference ([reference](/docs/reference>)) is the API-docs level documentation. It specifies + behaviors with maximum level of precision. A reference is not good for reading start to finish, it + is a random-access document. + +Finally, we add our own TigerBeetle twist to this Django structure: + +- Concepts and principles explain why TigerBeetle is the way it is. From principles, the rest + follows. Tutorials, Guides, and the Reference are documents about TigerBeetle as implemented, + while the concepts speak to the Platonic ideal of the beetle. + +Unlike user docs, internal docs do not follow any specific structure. Internal docs are "ingest +optimized" --- it's more important to have something documented, then for the documentation to be in +a consistent style. If you are unsure where something needs to be documented, just add a new file +into the `./internals` folder: it will get properly reorganized&compacted with time! + +## Micro + +- As Dijkstra said, we should regard every line of documentation not as a line produced, but as a + line spend. The value of the docs is not in the number of words, but in the number of concepts + covered. Number of words is the cost. A good process for documentation writing is: + + - list all the facts that you want to communicate, + - find the shortest set of words that explain _all_ listed ideas, clearly and concisely. +- Cool URIs don't change! Think hard about file and section names, as they form parts of URLs. +- Because docs are viewable on GitHub, GitHub Flavored Markdown is used for all the content. +- Hard wrap long lines to keep them readable as source. +- Hard wrap at 100 because that's what TIGER_STYLE says. +- Use Oxford comma (A, B, and C), for consistency. +- Use Standard American English for consistency. +- Use `_underscores_` for weak emphasis (_italics_) and `**double starts**` for strong emphasis ( + **bold**) for consistency. +- Use `-`, not `*` for lists for consistency. diff --git a/ocam/docs/internals/lsm.md b/ocam/docs/internals/lsm.md new file mode 100644 index 00000000..5fcc5f48 --- /dev/null +++ b/ocam/docs/internals/lsm.md @@ -0,0 +1,310 @@ +# LSM + +Documentation for (roughly) code in the `src/lsm` directory. + +## Glossary + +- _bar_: `lsm_compaction_ops` beats; unit of incremental compaction. +- _beat_: `op % lsm_compaction_ops`; Single step of an incremental compaction. +- _groove_: A collection of LSM trees, storing objects and their indices. +- _immutable table_: In-memory table; one per tree. Used to periodically flush the mutable table to + disk. +- _level_: A collection of on-disk tables, numbering between `0` and `config.lsm_levels - 1` (usually `config.lsm_levels = 7`). +- _forest_: A collection of grooves. +- _manifest_: Index of table and level metadata; one per tree. +- _mutable table_: In-memory table; one per tree. All tree updates (e.g. `Tree.put`) directly modify just this table. +- _snapshot_: Sequence number which selects the queryable partition of on-disk tables. + +## Tree +### Tables + +A tree is a hierarchy of in-memory and on-disk tables. There are three categories of tables: + +- The [mutable table](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/lsm/table_memory.zig) is an in-memory table. + - Each tree has a single mutable table. + - All tree updates, inserts, and removes are applied to the mutable table. + - The mutable table's size is allocated to accommodate a full bar of updates. +- The [immutable table](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/lsm/table_memory.zig) is an in-memory table. + - Each tree has a single immutable table. + - The mutable table's contents are periodically moved to the immutable table, + where they are stored while being flushed to level `0`. +- Level `0` … level `config.lsm_levels - 1` each contain an exponentially increasing number of + immutable on-disk tables. + - Each tree has as many as `config.lsm_growth_factor ^ (level + 1)` tables per level. + (`config.lsm_growth_factor` is typically 8). + - Within a given level and snapshot, the tables' key ranges are [disjoint](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/lsm/manifest_level.zig). + +### Compaction + +Tree compaction runs to the sound of music! + +Compacting LSM trees involves merging and moving tables into the next levels as needed. +To avoid write amplification stalls and bound latency, compaction is done incrementally. + +A full compaction phase is denoted as a bar, using terms from music notation. +Each bar consists of `lsm_compaction_ops` beats or "compaction ticks" of work. +A compaction tick executes asynchronously immediately after every commit, with +`beat = commit.op % lsm_compaction_ops`. + +A bar is split in half according to the "first" beat and "middle" beat. +The first half of the bar compacts even levels while the latter compacts odd levels. +Mutable table changes are sorted and compacted into the immutable table. +The immutable table is compacted into level 0 during the odd level half of the bar. + +At any given point, there are at most `⌈levels/2⌉` compactions running concurrently. +The source level is denoted as `level_a` and the target level as `level_b`. +The last level in the LSM tree has no target level so it is never a source level. +Each compaction compacts a [single table](#compaction-selection-policy) from `level_a` into all tables in +`level_b` which intersect the `level_a` table's key range. + +Invariants: +* At the end of every beat, there is space in mutable table for the next beat. +* The manifest log is compacted during every half-bar. +* The compactions' output tables are not [visible](#snapshots-and-compaction) until the compaction has finished. + +1. First half-bar, first beat ("first beat"): + * Assert no compactions are currently running. + * Allow the per-level table limits to overflow if needed (for example, if we may compact a table + from level `A` to level `B`, where level `B` is already full). + * Start compactions from even levels that have reached their table limit. + * Acquire reservations from the Free Set for all blocks (upper-bound) that will be written + during this half-bar. + +2. First half-bar, last beat: + * Finish ticking any incomplete even-level compactions. + * Assert on callback completion that all compactions are complete. + * Release reservations from the Free Set. + +3. Second half-bar, first beat ("middle beat"): + * Assert no compactions are currently running. + * Start compactions from odd levels that have reached their table limit. + * Compact the immutable table if it contains any sorted values (it might be empty). + * Acquire reservations from the Free Set for all blocks (upper-bound) that will be written + during this half-bar. + +4. Second half-bar, last beat: + * Finish ticking any incomplete odd-level and immutable table compactions. + * Assert on callback completion that all compactions are complete. + * Assert on callback completion that no level's table count overflows. + * Flush, clear, and sort mutable table values into immutable table for next bar. + * Remove input tables that are invisible to all current and persisted snapshots. + * Release reservations from the Free Set. + +#### Compaction Selection Policy + +Compaction selects the table from level `A` which overlaps the fewest visible tables of level `B`. + +For example, in the following table (with `lsm_growth_factor=2`), each table is depicted as the range of keys it includes. The tables with uppercase letters would be chosen for compaction next. + +``` +Level 0 A─────────────H l───────────────────────────z +Level 1 a───────e L─M o───────s u───────y +Level 2 b───d e─────h i───k l───n o─p q───s u─v w─────z +(Keys) a b c d e f g h i j k l m n o p q r s t u v w x y z +``` + +Links: +- [`Manifest.compaction_table`](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/lsm/manifest.zig) +- [Constructing and Analyzing the LSM Compaction Design Space](https://vldb.org/pvldb/vol14/p2216-sarkar.pdf) describes the tradeoffs of various data movement policies. TigerBeetle implements the "least overlapping with parent" policy. +- [Option of Compaction Priority](https://rocksdb.org/blog/2016/01/29/compaction_pri.html) + +##### Compaction Move Table + +When the [selected input table](#compaction-selection-policy) from level `A` does not overlap _any_ +input tables in level `B`, the input table can be "moved" to level `B`. +That is, instead of sort-merging `A` and `B`, just update the input table's metadata in the manifest. + +This is referred to as the _move table_ optimization. + +Where a tree performs inserts mostly in sort order, with a minimum of updates, this _move table_ +optimization should enable the tree's performance to approach that of an append-only log. + +##### Compaction Table Overlap + +Applying [this](#compaction-selection-policy) selection policy while compacting a table +from level A to level B, what is the maximum number of level-B tables that may overlap with the +selected level-A table (i.e. the "worst case")? + +Perhaps surprisingly, this is `lsm_growth_factor`: + +- Tables within a level are disjoint. +- Level `B` has at most `lsm_growth_factor` times as many tables as level `A`. +- To trigger compaction, level `A`'s visible-table count exceeds + `table_count_max_for_level(lsm_growth_factor, level_a)`. +- The [selection policy](#compaction-selection-policy) chooses the table from level `A` + which overlaps the fewest visible tables in level `B`. +- If any table in level `A` overlaps _more than_ `lsm_growth_factor` tables in level `B`, + that implies the existence of a table in level `A` with _less than_ `lsm_growth_factor` overlap. + The latter table would be selected over the former. + +### Snapshots + +Each table has a minimum and maximum integer snapshot (`snapshot_min` and `snapshot_max`). + +Each query targets a particular snapshot. A table `T` is _visible_ to a snapshot `S` when + +``` +T.snapshot_min ≤ S ≤ T.snapshot_max +``` + +and is _invisible_ to the snapshot otherwise. + +Compaction does not modify tables in place — it copies data. Snapshots control and distinguish +which copies are useful, and which can be deleted. Snapshots can also be persisted, enabling +queries against past states of the tree (unimplemented; future work). + +#### Snapshots and Compaction + +Consider the half-bar compaction beginning at op=`X` (`12`), with `lsm_compaction_ops=M` (`8`). +Each half-bar contains `N=M/2` (`4`) beats. The next half-bar begins at `Y=X+N` (`16`). + +During the half-bar compaction `X`: +- `snapshot_max` of each input table is truncated to `Y-1` (`15`). +- `snapshot_min` of each output table is initialized to `Y` (`16`). +- `snapshot_max` of each output table is initialized to `∞`. + +``` +0 4 8 12 16 20 24 (op, snapshot) +┼───┬───┼───┬───┼───┬───┼ + #### +····────────X────────···· (input tables, before compaction) +····──────────── (input tables, after compaction) + Y────···· (output tables, after compaction) +``` + +Beginning from the next op after the compaction (`Y`; `16`): +- The output tables of the above compaction `X` are visible. +- The input tables of the above compaction `X` are invisible. +- Therefore, it will lookup from the output tables, but ignore the input tables. +- Callers must not query from the output tables of `X` before the compaction half-bar has finished + (i.e. before the end of beat `Y-1` (`15`)), since those tables are incomplete. + +At this point the input tables can be removed if they are invisible to all persistent snapshots. + +#### Snapshot Queries + +Each query targets a particular snapshot, either: +- the current snapshot (`snapshot_latest`), or +- a [persisted snapshot](#persistent-snapshots). + +##### Persistent Snapshots + +TODO(Persistent Snapshots): Expand this section. + +#### Snapshot Values + +- The on-disk tables visible to a snapshot `B` do not contain the updates from the commit with op `B`. +- Rather, snapshot `B` is first visible to a prefetch from the commit with op `B`. + +Consider the following diagram (`lsm_compaction_ops=8`): + +``` +0 4 8 12 16 20 24 28 (op, snapshot) +┼───┬───┼───┬───┼───┬───┼───┬ + ,,,,,,,,........ + ↑A ↑B ↑C +``` + +Compaction is driven by the commits of ops `B→C` (`16…23`). While these ops are being committed: +- Updates from ops `0→A` (`0…7`) are on-disk. +- Updates from ops `A→B` (`8…15`) are in the immutable table. + - These updates were moved to the immutable table from the immutable table at the end of op `B-1` + (`15`). + - These updates will exist in the immutable table until it is reset at the end of op `C-1` (`23`). +- Updates from ops `B→C` (`16…23`) are added to the mutable table (by the respective commit). +- `tree.lookup_snapshot_max` is `B` when committing op `B`. +- `tree.lookup_snapshot_max` is `x` when committing op `x` (for `x ∈ {16,17,…,23}`). + +At the end of the last beat of the compaction bar (`23`): +- Updates from ops `0→B` (`0…15`) are on disk. +- Updates from ops `B→C` (`16…23`) are moved from the mutable table to the immutable table. +- `tree.lookup_snapshot_max` is `x` when committing op `x` (for `x ∈ {24,25,…}`). + + +### Manifest + +The manifest is a tree's index of table locations and metadata. + +Each manifest has two components: +- a single [`ManifestLog`](#manifest-log) shared by all trees and levels, and +- one [`ManifestLevel`](#manifest-level) for each on-disk level. + +#### Manifest Log + +The manifest log is an on-disk log of all updates to the trees' table indexes. + +The manifest log tracks: + + - tables created as compaction output + - tables updated as compaction input (modifying their `snapshot_max`) + - tables moved between levels by compaction + - tables deleted after compaction + +Updates are accumulated in-memory before being flushed: + + - incrementally during compaction, or + - in their entirety during checkpoint. + +The manifest log is periodically compacted to remove older entries that have been superseded by +newer entries. For example, if a table is created and later deleted, manifest log compaction +will eventually remove any reference to the table from the log blocks. + +Each manifest block has a reference to the (chronologically) previous manifest block. +The superblock stores the head and tail address/checksum of this linked list. +The reference on the header of the head manifest block "dangles" – the block it references has already been compacted. + +#### Manifest Level + +A `ManifestLevel` is an in-memory collection of the table metadata for a single level of a tree. + +For a given level and snapshot, there may be gaps in the key ranges of the visible tables, +but the key ranges are disjoint. + +Manifest levels are queried for tables at a target snapshot and within a key range. + +##### Example + +Given the `ManifestLevel` tables (with values chosen for visualization, not realism): + + label A B C D E F G H I J K L M + key_min 0 4 12 16 4 8 12 26 4 25 4 16 24 + key_max 3 11 15 19 7 11 15 27 7 27 11 19 27 + snapshot_min 1 1 1 1 3 3 3 3 5 5 7 7 7 + snapshot_max 9 3 3 7 5 7 9 5 7 7 9 9 9 + +A level's tables can be visualized in 2D as a partitioned rectangle: + + 0 1 2 + 0 4 8 2 6 0 4 8 + 9┌───┬───────┬───┬───┬───┬───┐ + │ │ K │ │ L │###│ M │ + 7│ ├───┬───┤ ├───┤###└┬──┤ + │ │ I │ │ G │ │####│ J│ + 5│ A ├───┤ F │ │ │####└┬─┤ + │ │ E │ │ │ D │#####│H│ + 3│ ├───┴───┼───┤ │#####└─┤ + │ │ B │ C │ │#######│ + 1└───┴───────┴───┴───┴───────┘ + +Example iterations: + + visibility snapshots direction key_min key_max tables + visible 2 ascending 0 28 A, B, C, D + visible 4 ascending 0 28 A, E, F, G, D, H + visible 6 descending 12 28 J, D, G + visible 8 ascending 0 28 A, K, G, L, M + invisible 2, 4, 6 ascending 0 28 K, L, M + +Legend: + + - `#` represents a gap — no tables cover these keys during the snapshot. + - The horizontal axis represents the key range. + - The vertical axis represents the snapshot range. + - Each rectangle is a table within the manifest level. + - The sides of each rectangle depict: + - left: `table.key_min` (the diagram is inclusive, and the `table.key_min` is inclusive) + - right: `table.key_max` (the diagram is EXCLUSIVE, but the `table.key_max` is INCLUSIVE) + - bottom: `table.snapshot_min` (inclusive) + - top: `table.snapshot_max` (inclusive) + - (Not depicted: tables may have `table.key_min == table.key_max`.) + - (Not depicted: the newest set of tables would have `table.snapshot_max == maxInt(u64)`.) diff --git a/ocam/docs/internals/releases.md b/ocam/docs/internals/releases.md new file mode 100644 index 00000000..8acb45ca --- /dev/null +++ b/ocam/docs/internals/releases.md @@ -0,0 +1,222 @@ +# Releases + +How a new TigerBeetle release is made. Note that the process is being +established, so this document might not perfectly reflect reality just yet. + +This document _starts_ with a succinct release manager algorithm for convenience of release managers. +The motivation for specific steps follows after. + +## Release Manager Algorithm + +### Friday + +1. Open [devhub](https://devhub.tigerbeetle.com/) to check that: + - you are the release manager for the week + - the VOPR results look reasonable (no failures and a bunch of successful runs for recent + commits) + - the graphs look reasonable (for example, no drastic changes in the RSS, data file size, or + executable size during the past week) + +2. ```console + $ ./zig/zig build scripts -- changelog + ``` + This will update local repository to match remote, create a branch for changelog PR, and add a + scaffold of the new changelog to CHANGELOG.md. Importantly, the scaffold will contain a new + version number with patch version incremented: + + ``` + ## TigerBeetle 0.16.3 <- Double check this version. + + Released 2024-08-29 + + - [#2256](https://github.com/tigerbeetle/tigerbeetle/pull/2256) + Build: Check zig version + - [#2248](https://github.com/tigerbeetle/tigerbeetle/pull/2248) + vopr: heal *both* wal header sectors before replica startup + + ### Safety And Performance + + - + + ### Features + + - + + ### Internals + + - + + ### TigerTracks 🎧 + + - []() + ``` + + If the current release is being skipped, replace the header with `## TigerBeetle (unreleased)`. + +3. Fill in the changelog: + - categorize pull requests into three buckets. + - drop minor pull requests + - group related PRs into a single bullet point + - double-check that the version looks right + - if there are any big features in the release, write about them in the lead paragraph. + - for safety/perf changes, formulate them from the user's perspective, to clearly communicate the + implications + - pick the tiger track! + +4. Commit the changelog and submit a pull request for review. + +5. After the PR is merged, push to the `release` branch: + + ```console + $ git fetch origin && git push origin origin/main:release + ``` + +6. Post a tweet-able summary of the changelog and an idea for the release sketch to Slack. + +7. From this point on, the CFO will be fuzzing the release branch over the weekend. + +8. Ping release manager for the next week in Slack. + +### Monday + +1. On Monday (different release manager!) check that there are no VOPR failures on the release + branch. + +2. If there are any untriaged issues on DevHub, triage them: + - if possible, address the issue immediately, + - otherwise, if it is unactionable, close with a comment or apply triaged label, + - otherwise, redirect the issue towards someone who can triage it. + +3. Trigger the release workflow via + [GitHub web interface](https://github.com/tigerbeetle/tigerbeetle/actions/workflows/release.yml). + Be sure to trigger workflow from the `release` branch, otherwise the release will fail due to + permissions. + +4. Ask someone else to approve the GitHub workflow. + +5. Add the new release sketch to the corresponding release page on + . + +### Error Handling + +The release process is idempotent for client packages: the release script checks whether each +package version is already published and skips re-publishing if so. This means it is safe to re-run +the release workflow, whether the release failed completely or only partially (e.g., the Node.js +package was uploaded but the Java package failed). Fix any underlying issue, delete the release +draft, and re-trigger the workflow. No version number will be burned. + +It could also be the case that a release was successful, but some issue with the code is discovered +and a quick fix is necessary. The preferred approach is to do a normal fix-forward release. While +releases are normally weekly, it is possible to do several releases in a single day. Note that a +fix-forward release would go through the normal VSR upgrade protocol. + +Finally, if the release is bad _and_ the normal upgrade protocol doesn't work (e.g., a replica +crashes on startup immediately), it is possible to make a release which uses the same VSR release +triple and is considered to be the "same" release from the perspective of the protocol. In this +case, the git tag and the VSR release in the binary would differ. To make this kind of release, +adjust `version_info.release_triple` manually in `release.zig`. + +### Validation Error Handling + +If validation fails due to a bug in the validation code itself (e.g., a client's +`validate_release`), fixing it on `main` is enough: `release_validate.yml` runs validation from +`main` against a check out of the last released tag. + +## What Is a Release? + +TigerBeetle is distributed in binary form. There are two main reasons for this: + +- to ensure correctness and rule out large classes of configuration errors, the + actual machine code must be tested. +- Zig is not stable yet. Binary releases insulate the users from this + instability and keep the language as an implementation detail. + +TigerBeetle binary is released in lockstep with client libraries. At the moment, +implementation of the client libraries is tightly integrated and shares code +with TigerBeetle, requiring matching versions. + +Canonical form of the "release" is a `dist/` folder with the following +artifacts: + +- `tigerbeetle/` subdirectory with `.zip` archived `tigerbeetle` binaries built + for all supported architectures. +- `dotnet/` subdirectory with a NuGet package. +- `go/` subdirectory with the source code of the go client and precompiled + native libraries for all supported platforms. +- `java/` subdirectory with a `.jar` file. +- `node/` subdirectory with a `.tgz` package for npm. + +## Publishing + +Release artifacts are uploaded to appropriate package registries. GitHub release +is used for synchronization: + +- a draft release is created at the start of the publishing process, +- artifacts are uploaded to GitHub releases, npm, Maven Central, and NuGet. For Go, a new commit is + pushed to . Similarly, docs are uploaded to + . +- if publishing to all registries were successfully, the release is marked as + non-draft. + +All publishing keys are stored as GitHub Actions in the `release` environment. For Go and docs, +a personal access token is used. These tokens expire after a year, to refresh a token: + +- Create fine grained personal access token, PAT, using your personal GitHub account ([GitHub + documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token)) +- Scope the token to the tigerbeetle github organization. +- Grant write access to the relevant repo (use separate tokens for different repositories). +- Update the token in the `release` environment in the `tigerbeetle` repository. + +## Versioning + +Because releases are frequent, we avoid specifying the version in the source +code. The source of truth for version is the CHANGELOG.md file. The version at +the top becomes the version of the new release. + +Version numbers are monotonic, but can have gaps, see the error handling part of release manager +algorithm. + +## Changelog + +Purposes of the changelog: + +- For everyone: give project a visible "pulse". +- For TigerBeetle developers: tell fine grained project evolution story, form + shared context, provide material for the monthly newsletter. +- For TigerBeetle users: inform about all visible and potentially relevant + changes. + +As such: + +- Consider skipping over trivial changes in the changelog. +- Don't skip over meaningful changes of the internals, even if they are not + externally visible. +- If there is a story behind a series of pull requests, tell it. +- And don't forget the TigerTrack of the week! + +## Release Logistics + +Releases are triggered manually, on Monday. Default release rotation is on the +devhub: . + +The middle name is the default release manager for the _current_ week. They should execute [Release +Manager Algorithm](#release-manager-algorithm) on Monday. If the release manager isn't available on +Monday, a volunteer picks up that release. + +## Skipping Release + +Because releases are frequent, it is not a problem to skip a single release. In fact, allowing to +easily skip a release is one of the explicit purposes of the present process. + +If there's any pull request that we feel pressured should land in the next release, the default +response is to land the PR under its natural pace, and skip the release instead. + +Similarly, if there's a question of whether we should do a release or to skip one, the default +answer is to skip. Skipping is cheap! + +If the release is skipped, the changelog is still written and merged on Monday, using the following +header: `## TigerBeetle (unreleased)`. + +For the next release, you should (1) manually set the next valid version number and (2) merge all +previously unreleased changes into a single, versioned changelog entry to inform users +who are upgrading. diff --git a/ocam/docs/internals/sync.md b/ocam/docs/internals/sync.md new file mode 100644 index 00000000..d7da7dfc --- /dev/null +++ b/ocam/docs/internals/sync.md @@ -0,0 +1,176 @@ +# State Sync + +State sync synchronizes the state of a lagging replica with the healthy cluster. + +State sync is used when a lagging replica's log no longer intersects with the cluster's current +log — [WAL repair](./vsr.md#protocol-repair-wal) cannot catch the replica up. + +(VRR refers to state sync as "state transfer", but we already have +[transfers](../reference/transfer.md) elsewhere.) + +In the context of state sync, "state" refers to: + +1. the superblock `vsr_state.checkpoint` +2. the grid (manifest, free set, and client sessions blocks) +3. the grid (LSM table data; acquired blocks only) +4. client replies + +State sync consists of four protocols: + +- [Sync Superblock](./vsr.md#protocol-request-view) (syncs 1) +- [Repair Grid](./vsr.md#protocol-repair-grid) (syncs 2) +- [Sync Forest](./vsr.md#protocol-sync-forest) (syncs 3) +- [Sync Client Replies](./vsr.md#protocol-sync-client-replies) (syncs 4) + +The target of superblock-sync is the latest checkpoint of the healthy cluster. When we catch up to +the latest checkpoint (or very close to it), then we can transition back to a healthy state. + +State sync is lazy — logically, sync is completed when the superblock is synced. The data +pointed to by the new superblock can be transferred on-demand. + +The state (superblock) and the WAL are updated atomically — [`view`](./vsr.md#view) +message includes both. + +## Glossary + +Replica roles: + +- _syncing replica_: A replica performing superblock-sync. (Any step within _1_-_5_ of the + [sync algorithm](#algorithm)) +- _healthy replica_: A replica _not_ performing superblock-sync — part of the active cluster. + +Checkpoints: + +- [_checkpoint id_/_checkpoint identifier_](#checkpoint-identifier): Uniquely identifies a + particular checkpoint reproducibly across replicas. It is a hash over the entire state. +- _Durable checkpoint_: A checkpoint whose state is present on at least replication quorum different + replicas. + +## Algorithm + +0. [Sync is needed](#0-scenarios). +1. [Trigger sync in response to `view`](#1-triggers). +2. Interrupt the in-progress commit process: + 2.1. Wait for write operations to finish. + 2.2. Cancel potentially stalled read operations. (See `Grid.cancel()`.) + 2.3. Wait for cancellation to finish. +3. Install the new checkpoint and matching headers into the superblock: + - Bump `vsr_state.checkpoint.header` to the sync target header. + - Bump `vsr_state.checkpoint.parent_checkpoint_id` to the checkpoint id that is previous to our + sync target (i.e. it isn't _our_ previous checkpoint). + - Bump `replica.commit_min`. + - Set `vsr_state.sync_op_min` to the minimum op which has not been repaired. + - Set `vsr_state.sync_op_max` to the maximum op which has not been repaired. + - Set `replica.sync_tables_op_range` if it is not already set. (See below). +4. Repair [replies](./vsr.md#protocol-sync-client-replies) and + [free set, client sessions, and manifest blocks](./vsr.md#protocol-repair-grid) + that were created within the `vsr_state.sync_op_{min,max}` range. + Repair [table blocks](./vsr.md#protocol-sync-forest) that were created within + `replica.sync_tables_op_range`. +5. As part of the [*next checkpoint*](#5-conclusion), update the superblock with: + - Set `vsr_state.sync_op_min = 0` + - Set `vsr_state.sync_op_max = 0` + +If the replica starts up with `vsr_state.sync_op_max ≠ 0`, go to step _4_. + +If we receive a new sync target while we were still syncing the old one, +`replica.sync_tables_op_range` is not updated immediately. We don't start syncing the tables from +the new sync range until all the tables from the old sync range are completed. (This is the "state +sync ratchet"). + +### 0: Scenarios + +Scenarios requiring state sync: + +1. A replica was down/partitioned/slow for a while and the rest of the cluster moved on. The lagging + replica is too far behind to catch up via WAL repair. +2. A replica was just formatted and is being added to the cluster (i.e. via + [reconfiguration](./vsr.md#protocol-reconfiguration)). The new replica is too far behind to catch + up via WAL repair. + +Deciding between WAL repair and state sync: + +* If a replica lags by more than one checkpoint behind the primary, it must use state sync. +* If a replica is on the same checkpoint as the primary, it can only repair WAL. +* If a replica is just one checkpoint behind, either WAL repair or state sync might be necessary: + * State sync is incorrect if there is only a single other replica on the next checkpoint --- the + replica that is ahead could have its state corrupted. + * WAL repair is incorrect if all reachable peer replicas have already wrapped their logs and + evicted some prepares from the preceding checkpoint. + * Summarizing, if the next checkpoint is durable (replicated on a quorum of replicas), the + lagging replica must eventually state sync. + +### 1: Triggers + +State sync is triggered when a replica receives a `view` message with a more advanced +checkpoint. + +If a replica isn't making progress committing because a grid block or a prepare can't be repaired +for some time, the replica proactively sends `get_view` to initiate the sync (see +`repair_sync_timeout`). + +### 5: Conclusion + +We wait until the next checkpoint to reset the `superblock.vsr_state.sync_op_{min,max}` range, +rather than updating it immediately like a view change. + +That is to avoid the following scenario: + +1. Start sync to checkpoint `X`. +2. Commit atop checkpoint `X`, but not far enough to reach the next checkpoint `Y`. +3. At op `X+a`, we use table `t` as part of a commit or compaction. Suppose that table `t` will be + synced but has not yet. (The repair uses `grid.read_global_queue`.) +4. At op `X+b`, we release table `t` as part of compaction. Suppose this occurs before `t` has been + synced. +5. Finish sync. (Still mid-way between `X` and `Y`). +6. Crash. Restart. +7. Replay commits atop `X`. Despite having completed sync and having no storage corruption, we are + missing `t`. + +(Note that repairs via `read_global_queue` _usually_ write to the grid, but it is not guaranteed +(e.g. if `GridBlocksMissing` is full).) + +A variant of this scenario: When we support snapshots, it will be possible to release `t` without +ever requiring it earlier (i.e. omit step 3). In that case, at the moment of restart (before replay) +our superblock would claim to have a clean data file (no pending state sync) but be missing blocks +which it references. + +## Concepts + +### Syncing Replica + +Syncing replicas participate in replication normally. They can append prepares, commit, and are +eligible to become primaries. In particular, a syncing replica can advance its own checkpoint as a +part of the normal commit process. + +The only restriction is that syncing replicas don't contribute to their checkpoint's replication +quorum. That is, for the cluster as a whole to advance the checkpoint, there must be at least a +replication quorum of healthy replicas. + +The mechanism for discovering sufficiently replicated (durable) checkpoints uses `prepare_ok` +messages. Sending a `prepare_ok` signals that the replica has a recent checkpoint fully synced. As a +consequence, observing a `commit_max` sufficiently ahead of a checkpoint signifies the durability of +the checkpoint. + +For this reason, syncing replicas withhold `prepare_ok` until `commit_max` confirms that their +checkpoint is fully replicated on a quorum of different replicas. See `op_prepare_max`, +`op_prepare_ok_max` and `op_repair_min` for details. + +### Checkpoint Identifier + +A _checkpoint id_ is a hash of the superblock `CheckpointState`. + +A checkpoint identifier is attached to the following message types: + +- `command=commit`: Current checkpoint identifier of sender. +- `command=ping`: Current checkpoint identifier of sender. +- `command=prepare`: The attached checkpoint id is the checkpoint id during which the corresponding + prepare was originally prepared. +- `command=prepare_ok`: The attached checkpoint id is the checkpoint id during which the + corresponding prepare was originally prepared. + +### Storage Determinism + +When everything works, storage is deterministic. If non-determinism is detected (via checkpoint id +mismatches) the replica which detects the mismatch will panic. This scenario should prompt operator +investigation and manual intervention. diff --git a/ocam/docs/internals/talks.md b/ocam/docs/internals/talks.md new file mode 100644 index 00000000..bc7f32a0 --- /dev/null +++ b/ocam/docs/internals/talks.md @@ -0,0 +1,255 @@ +# Talks + +## June 28, 2024: Durability and the Art of Consensus + +In this talk from Systems Distributed '24, join Joran Dirk Greef as he pushes past the limits, +over-specifications and assumptions of popular protocols, to build a backup system from first +principles. + +https://www.youtube.com/watch?v=tRgvaqpQPwE + +## June 06, 2024: Money2020 + +Joran Dirk Greef explains how the world is becoming more transactional, that general purpose +databases designed 20 to 30 years ago won't power the future of online transactions, and how +TigerBeetle is designed to help. + +https://youtu.be/wkHP30tCZmw + +## May 14, 2024: Biodigital Jazz! + +Joran Dirk Greef presents at Software You Can Love on the intersection of software, art +(and business) at TigerBeetle. + +https://www.youtube.com/watch?v=C98cyJ-wJuY + + +## March 22, 2024: Redesigning OLTP for a New Order of Magnitude + +Joran Dirk Greef discusses TigerBeetle, a new database, and why OLTP has a growing impedance +mismatch, why the OLTP workload is becoming more contentious, why row locks, why storage +faults, write stalls, and why non-determinism is now a problem. + +https://www.youtube.com/watch?v=32LMicc0gRA + +## February 26, 2024: The FASTEST and SAFEST Database + +Joran Dirk Greef gives an entertaining and informative overview of the problems TigerBeetle +solves, technical details about how it works, and shows a demo of the simulator at the end. +ThePrimeagen was blown away. + +https://youtu.be/sC1B3d9C_sI?si=Pn6OfKSkyLoHc2Z5 + +## February 26, 2024: TigerBeetle Release Pipeline (Behind the Scenes!) + +In this impromptu talk, matklad discusses build and release process of TigerBeetle. + +https://www.youtube.com/watch?v=eFTQzhfO6nc + +## October 23, 2023: P99 CONF 2023 | A Deterministic Walk Down TigerBeetle’s main() Street + +Learn how to use Zig to implement a fully deterministic distributed system which will never +fail with an out of memory error, for predictable performance and 700x faster tests, +with matklad. + + +https://www.youtube.com/watch?v=AGxAnkrhDGY + +## March 27, 2023: A New Era for Database Design with TigerBeetle at QCon London 2023 + +Joran Dirk Greef discusses pivotal moments in database design and how +they influenced the design decisions for TigerBeetle, a distributed +financial accounting database. + +https://www.youtube.com/watch?v=ehYcCTHRyFs + +## March 9, 2023: How to Thought Lead and the Double Entry Accounting Database + +> I had a great chat with Joran of Tigerbeetle, the hottest animal +> themed database on the market, about Zig, distributed database design, +> and exchanging notes on how to Cross the Chasm with a brand new +> database. + +https://www.youtube.com/watch?v=QwXddaB8tj0 + +## February 10, 2023: TigerStyle! (Or How To Design Safer Systems in Less Time) + +Our final talk from Systems Distributed '23, Joran Dirk Greef dives into how to design safer +systems in less time. + +https://www.youtube.com/watch?v=w3WYdYyjek4 + +## January 18, 2023: Why Accounting Needs Its Own Database with Joran Greef of Tiger Beetle on The datastack Show + +This week on The Data Stack Show, Eric and Kostas chatted with Joran +Greef, Founder & CEO of Tiger Beetle. During the episode, Joran +discusses his journey from accounting to coding, why double-entry +accounting is important for databases, safety issues in financial +software, the need for low latency and high throughput, and more. + +https://datastackshow.com/podcast/why-accounting-needs-its-own-database-with-joran-greef-of-tiger-beetle/ + +## November 23, 2022: TigerBeetle: Magical Memory Tour! on CMU Database Group - ¡Databases! – A Database Seminar Series + +TigerBeetle is an open source distributed financial accounting +database designed for mission critical safety and performance to track +financial transactions at scale. TigerBeetle is coded to NASA’s Power +of Ten Rules for Safety Critical Code. All memory is statically +allocated at startup for predictable and efficient resource +usage. Function arguments and return values are verified at runtime by +over three thousand assertions. Deterministic Simulation Testing +accelerates the maturation process of TigerBeetle’s VSR consensus +protocol and LSM storage engine, through fault injection of network +faults as well as storage faults such as misdirected or corrupt reads +and writes. TigerBeetle is being designed to process a million journal +entries per second on commodity hardware, using io_uring for high +performance I/O, and Direct I/O and fixed-size cache line aligned data +structures for zero-copy and zero-deserialization. TigerBeetle is +written in Andrew Kelley’s Zig. + +https://www.youtube.com/watch?v=FyGukn77gqA + +## November 12, 2022: TigerBeetle, a Financial Accounting Database for Interledger + +https://www.youtube.com/watch?v=Whp4RfW3K_U&t=6568s + +## November 5, 2022: Tornow Talks TigerBeetle! + +The legendary Principal Engineer from Temporal, Dominik Tornow, joins +us on set to interview Joran about TigerBeetle. + +The history, mission, primitives, invariants, distributed database +design, and deterministic simulation testing, plus a deep dive into +the two-phase commit protocol, the right place in the distributed +systems stack to handle errors (or make an apology!) and an exciting +new conference! + +https://www.youtube.com/watch?v=ZW_emZ4683A + +## October 28, 2022: Zig's I/O and Concurrency Story at Software You Can Love 2022 + +Async I/O and concurrency have come to be features expected from any +general purpose language. Zig is in a unique spot where it has +interesting ideas but hasn’t settled on any yet. With the rise of +multi-core CPUs and io_uring, the landscape for concurrent programming +is shifting and it’s important that Zig adapts. Instead of providing +solutions, let’s explore what the options are and discuss the status +quo, the Future (no pun intended), and what it all means for a +language and ecosystem in development. + +https://www.youtube.com/watch?v=Ul8OO4vQMTw + +## October 17, 2022: Building a Database with Joran Dirk Greef on Software Unscripted + +Richard and Joran Greef talk about the TigerBeetle database, an +impressive feat of engineering effort which Joran has been building to +solve real-world problems his team has encountered at work. + +https://podcasts.apple.com/us/podcast/building-a-database-with-joran-greef/id1602572955?i=1000582870854 + +## September 29, 2022: Ledgers at Scale! With Chris Riccomini + +In which we unpack the past, present (and future!) of ledgers. + +With Chris Riccomini — a Distinguished Engineer, author, investor and +formerly of WePay, LinkedIn and PayPal—who's built some of the +scaliest of them! + +https://www.youtube.com/watch?v=xQ7Gmkb9zts + +## August 30, 2022: Twitter Spaces Recording: Coil x TigerBeetle: Databases of the Future + +Audio Recording from 8-25-22: Joran Dirk Greef from TigerBeetle +interviewed by Adrian Hope-Bailie (Fynbos) from Coil's Twitter Spaces + +We are very excited to have Joran from the TigerBeetle team together +with Adrian from Fynbos discussing…lessons from building TigerBeetle, +a high-performance database which handles 1 million transactions per +second. + +We’re going to talk about why TigerBeetle is a distributed database +that is specifically built for financial accounting, and what it +brings to an already vibrant landscape of open source databases. + +## August 15: 2022: VOPR'izing TigerBeetle + +Sarah takes us deep into the Matrix, where “VOPR” bots run TigerBeetle +clusters in thousands of simulated worlds—to find, classify and report +correctness and liveness bugs to GitHub as issues, all without human +intervention. + +https://www.youtube.com/watch?v=0esGaX5XekM + +## August 6, 2022: A New Era for Databases With Alex Gallego (Redpanda) + +A deep dive into distributed database design with Alex Gallego, +founder and CEO of Redpanda: + +- How have hardware trends changed database design? +- What exactly is a database? +- How do we build and test distributed databases? +- And how do we package them into a product that people love? + +https://www.youtube.com/watch?v=jC_803mW448 + +## July 13, 2022: TigerBeetle - A Million Financial Transactions per Second in Zig on Zig SHOWTIME + +https://www.youtube.com/watch?v=BH2jvJ74npM + +## June 7, 2022: Let's Remix Distributed Database Design at Recurse Center + +When we talk about consensus protocols and storage engines, it's often +in isolation. So the distributed systems community will talk about +protocols like Viewstamped Replication, Paxos, or RAFT, and the +storage community will talk about storage faults and engines like +LevelDB and RocksDB. Today, let's talk about both—and remix +distributed database design! + +https://www.youtube.com/watch?v=rNmZZLant9o + +## May 1, 2022: TigerBeetle's LSM-Forest at HYTRADBOI '22 + +A fast-paced introduction to TigerBeetle's mission, architecture, +global consensus, local storage engine, and our thinking on +LSM-trees—how to make them fast, fault-tolerant, and above all, fun to +test! + +https://www.youtube.com/watch?v=yBBpUMR8dHw + +## January 11, 2022: Introducing TigerBeetle's LSM-Forest + +Why and how to implement a distributed deterministic LSM-Tree storage +engine, and then grow this into an LSM-Forest! + +Simulation testing, storage faults, persistent read snapshots that +survive crashes, new IO APIs like io_uring, reduced read/write +amplification, incremental/pipelined compaction, static allocation, +optimal memory usage and more... + +https://www.youtube.com/watch?v=LikJDDhwmXA + +## October 3, 2021: Paper #74. Viewstamped Replication Revisited on DistSys Reading Group + +In the 74th meeting we discussed yet another foundational paper -- +Viewstamped Replication. In particular, we focused on the 2012 +revisited version of the paper: "Viewstamped Replication Revisited." + +https://www.youtube.com/watch?v=Wii1LX_ltIs + +## September 18, 2021: Revisiting Viewstamped Replication with Brian Oki and James Cowling on Zig SHOWTIME + +https://www.youtube.com/watch?v=ps106zjmjhw + +## September 17, 2021: Viewstamped Replication Made Famous on Zig SHOWTIME + +https://www.youtube.com/watch?v=qeWyc8G-lq4 + +## May 22, 2021: PaPoC '21 Lightning Talk - Viewstamped Replication Made Famous + +A 3-minute, 1-slide lightning talk given by Joran Dirk Greef on 26 +April 2020 at the 8th Workshop on Principles and Practice of +Consistency for Distributed Data (part of EuroSys '21), on +TigerBeetle's production implementation of Viewstamped Replication +Revisited and Protocol Aware Recovery for Consensus-Based Storage. + +https://www.youtube.com/watch?v=tlz0FbzGGGo diff --git a/ocam/docs/internals/testing.md b/ocam/docs/internals/testing.md new file mode 100644 index 00000000..861b553b --- /dev/null +++ b/ocam/docs/internals/testing.md @@ -0,0 +1,68 @@ +# Testing + +Documentation for (roughly) code in the `src/testing` directory. + +## VOPR Output + +### Columns + +1. Replica index. +2. Event: + - `!`: crash + - `^`: recover + - ` `: commit + - `$`: sync + - `X`: reformat + - `[`: checkpoint start + - `]`: checkpoint done +3. Role (according to the replica itself): + - `/`: primary + - `\`: backup + - `|`: standby + - `~`: syncing + - `#`: (crashed) + - `F`: (reformatting) +4. Status: + - The column (e.g. `. ` vs ` .`) corresponds to the replica index. (This can help identify events' replicas at a quick glance.) + - The symbol indicates the `replica.status`. + - `.`: `normal` + - `v`: `view_change` + - `r`: `recovering` + - `h`: `recovering_head` + - `s`: `sync` +5. View: e.g. `74V` indicates `replica.view=74`. +6. Checkpoint and Commit: e.g. `83/_90/_98C` indicates that: + - the highest checkpointed op at the replica is `83` (`replica.op_checkpoint()=83`), + - on top of that checkpoint, the replica applied ops up to and including `90` (`replica.commit_min=90`), + - replica knows that ops at least up to `98` are committed in the cluster (`replica.commit_max=98`). +7. Journal op: e.g. `87:150Jo` indicates that the minimum op in the journal is `87` and the maximum is `150`. +8. Journal faulty/dirty: `0/1Jd` indicates that the journal has 0 faulty headers and 1 dirty headers. +9. WAL prepare ops: e.g. `85:149Wo` indicates that the op of the oldest prepare in the WAL is `85` and the op of the newest prepare in the WAL is `149`. +10. Syncing ops: e.g. `<0:123>` indicates that `vsr_state.sync_op_min=0` and `vsr_state.sync_op_max=123`. +11. Release version: e.g. `v1:2` indicates that the replica is running release version `1`, and that its maximum available release is `2`. +12. Grid blocks acquired: e.g. `167Ga` indicates that the grid has `167` blocks currently in use. +13. Grid blocks queued `grid.read_remote_queue`: e.g. `0G!` indicates that there are `0` reads awaiting remote fulfillment. +14. Grid blocks queued `grid_blocks_missing`: e.g. `0G?` indicates that there are `0` blocks awaiting remote repair. +15. Pipeline prepares (primary-only): e.g. `1/4Pp` indicates that the primary's pipeline has 2 prepares queued, out of a capacity of 4. +16. Pipeline requests (primary-only): e.g. `0/3Pq` indicates that the primary's pipeline has 0 requests queued, out of a capacity of 3. + +### Example + +(The first line labels the columns, but is not part of the actual VOPR output). + +``` + 1 2 3 4-------- 5--- 6---------- 7------- 8----- 9------- 10----- 11-- 12----- 13- 14- 15--- 16--- + + 3 [ / . 3V 71/_99/_99C 68:_99Jo 0/_0J! 68:_99Wo <__0:__0> v1:2 183Ga 0G! 0G? 0/4Pp 0/3Rq + 4 ^ \ . 2V 23/_23/_46C 19:_50Jo 0/_0J! 19:_50Wo <__0:__0> v1:2 nullGa 0G! 0G? + 2 \ . 3V 71/_99/_99C 68:_99Jo 0/_0J! 68:_99Wo <__0:__0> v1:2 183Ga 0G! 0G? + 2 [ \ . 3V 71/_99/_99C 68:_99Jo 0/_0J! 68:_99Wo <__0:__0> v1:2 183Ga 0G! 0G? + 6 | . 3V 71/_99/_99C 68:_99Jo 0/_0J! 68:_99Wo <__0:__0> v1:2 183Ga 0G! 0G? + 6 [ | . 3V 71/_99/_99C 68:_99Jo 0/_0J! 68:_99Wo <__0:__0> v1:2 183Ga 0G! 0G? + 3 ] / . 3V 95/_99/_99C 68:_99Jo 0/_0J! 68:_99Wo <__0:__0> v1:2 167Ga 0G! 0G? 0/4Pp 0/3Rq + 2 ] \ . 3V 95/_99/_99C 68:_99Jo 0/_0J! 68:_99Wo <__0:__0> v1:2 167Ga 0G! 0G? + 1 \ . 3V 71/_99/_99C 68:_99Jo 0/_1J! 67:_98Wo <__0:__0> v1:2 183Ga 0G! 0G? + 1 [ \ . 3V 71/_99/_99C 68:_99Jo 0/_1J! 67:_98Wo <__0:__0> v1:2 183Ga 0G! 0G? + 5 | . 3V 71/_99/_99C 68:_99Jo 0/_0J! 68:_99Wo <__0:__0> v1:2 183Ga 0G! 0G? + 5 [ | . 3V 71/_99/_99C 68:_99Jo 0/_0J! 68:_99Wo <__0:__0> v1:2 183Ga 0G! 0G? +``` diff --git a/ocam/docs/internals/upgrades.md b/ocam/docs/internals/upgrades.md new file mode 100644 index 00000000..c96cb676 --- /dev/null +++ b/ocam/docs/internals/upgrades.md @@ -0,0 +1,87 @@ +# Upgrades + +Upgrades in TigerBeetle are handled by bundling multiple underlying TigerBeetle binaries of +different versions, into a single binary, known as "Multiversion Binaries". + +The idea behind multiversion binaries is to give operators a great experience when upgrading +TigerBeetle clusters: + +Upgrades should be simple, involve minimal downtime and be robust, while not requiring external +coordination. + +Multiple versions in a single binary are required for two reasons: +* It allows a replica to crash after the binary has been upgraded, and still come back online. + * It also allows for deployments, like Docker, where the binary is immutable and the process + has to be terminated to learn about new versions from itself. +* It allows for migrations over a range to happen easily without having to manually jump from + version to version. + +The upgrade instructions look something like: + +``` +# SSH to each replica, in no particular order: +cd /tmp +wget https://github.com/tigerbeetle/tigerbeetle/releases/download/0.15.4/tigerbeetle-x86_64-linux.zip +unzip tigerbeetle-x86_64-linux.zip + +# Put the binary on the same file system as the target, so mv is atomic. +mv tigerbeetle /usr/bin/tigerbeetle-new + +mv /usr/bin/tigerbeetle /usr/bin/tigerbeetle-old +mv /usr/bin/tigerbeetle-new /usr/bin/tigerbeetle +``` + +When the primary determines that all replicas have the new binary, it'll [coordinate the + upgrade](https://github.com/tigerbeetle/tigerbeetle/pull/1670). + +There are three main parts to multiversion binaries: building, monitoring and executing, with +platform specific parts in each. + +## Building +Physically, multiversion binaries are regular TigerBeetle ELF / PE / MachO[^2] files that have two +extra sections[^3] embedded into them - marked as `noload` so that they're not memory mapped: +* `.tb_mvh` or TigerBeetleMultiVersionHeader - a header struct containing information on past + versions embedded as well as offsets, sizes, checksums and the like. +* `.tb_mvb` or TigerBeetleMultiVersionBody - a concatenated pack of binaries. The offsets in + `.tb_mvh` refer into here. + +[^2]: MachO binaries are constructed as fat binaries, using unused, esoteric CPU identifiers to +signal the header and body, for both x86_64 and arm64. + +[^3]: The short names are for compatibility with Windows: PE supports up to 8 characters for +section names without getting more complicated. + +These are added by an explicit objcopy step in the release process, _after_ the regular build is +done. After the epoch, the build process only needs to pull the last TigerBeetle release from +GitHub, to access its embedded pack to build its own. + +### Bootstrapping +0.15.3 is considered the epoch release, but it doesn't know about any future versions of +TigerBeetle or how to read the metadata yet. This means that if the build process pulled in that +exact release, when running on a 0.15.3 data file, 0.15.3 would be executed and nothing further +would happen. There is a [special backport +release](https://github.com/tigerbeetle/tigerbeetle/pull/1935), that embeds the fact that 0.15.4 is +available to solve this problem. The release code for 0.15.4 builds this version for 0.15.3, +instead of downloading it from GitHub. + +Additionally, since 0.15.3 can't read its own binary (see Monitoring below), restarting the replica +manually after copying it in is needed. + +Once 0.15.4 is running, no more special cases are needed. + +## Monitoring +On a 1 second timer, TigerBeetle `stat`s its binary file, looking for changes. Should anything +differ (besides `atime`) it'll re-read the binary into memory, verify checksums and metadata, and +start advertising new versions without requiring a restart. + +This optimization allows skipping a potentially expensive WAL replay when upgrading: the previous +version is what will checkpoint to the new version, at which point the exec happens. + +## Executing +The final step is executing into the new version of TigerBeetle. On Linux, this is handled by +`execveat` which allows executing from a `memfd`. If executing the latest release, `exec_current` +re-execs the `memfd` as-is. If executing an older release, `exec_release` copies it out of the +pack, verifies its checksum, and then executes it. + +One key point is that the newest version is always what starts up and determines what version to +run. diff --git a/ocam/docs/internals/vopr.md b/ocam/docs/internals/vopr.md new file mode 100644 index 00000000..0d1f9576 --- /dev/null +++ b/ocam/docs/internals/vopr.md @@ -0,0 +1,73 @@ +# Deterministic Simulation Testing + +Deterministic Simulation Testing (DST) is one of our favorite parts about TigerBeetle, and it is a +key way that we improve the system's reliability. + +Simulation testing enables us to run the production TigerBeetle code under a wide variety of +conditions to ensure that the cluster behaves properly. Because our simulator is deterministic based +on a _seed_ number and the Git commit, we can perfectly reproduce any bugs discovered in testing for +easy local debugging. Crucially, VOPR can speed up time arbitrarily. One minute of VOPR time is +equivalent to days of real-world testing. + +## Live Simulator in the Browser + +You can see the simulator in action at ! + +The three modes show TigerBeetle handling different types of network and hardware conditions -- and +you can inject different faults yourself 🔨🧊⚡. + +## The VOPR + +_The VOPR_, or The Viewstamped Operation Replicator, is our name for our deterministic simulator. +(The name was inspired by the AI supercomputer in the 1983 movie +[WarGames](https://www.imdb.com/title/tt0086567/), which was called the War Operation Plan Response +or WOPR, which constantly simulated scenarios in order to learn.) + +The key purpose of the VOPR is to test TigerBeetle's safety and liveness, and it focuses on +consensus and the cluster's recovery mechanisms. + +In the simulator, all non-deterministic parts of the system are stubbed out. This includes the +clock, network, and disk operations. + +The VOPR uses a random seed to tune parameters for injecting different types of faults into the +simulation. For example, it may drop and reorder packets, partition the network, or corrupt reads +and writes to the "disk". + +Using those conditions, the simulator commits several hundred batches of operations and checks that +they are applied as expected. + +When a simulation causes any type of failure, the seed and Git commit hash can be used to replay +back the exact simulation and bug. If you are interested in understanding how we debug and fix +failures discovered in simulation, you can watch this +[IronBeetle episode where @matklad live debugs a real simulator failure](https://youtu.be/kZ3xVeO0vBw?si=gaHgOzrN-X86CAmi). + +Using the same deterministic simulation infrastructure, we also test for +[specific cases](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/vsr/replica_test.zig) that +are hard or slow to replicate through random simulation. + +## Assertions and Checkers + +Simulation testing pairs particularly well with TigerBeetle's heavy use of assertions. Throughout +the code base there are thousands of assertions checking that all manner of invariants hold true. + +TigerBeetle is somewhat unique in that it keeps these assertions on, even in production. The logic +is that it is far better to stop operating than to continue operating in an incorrect state. + +Assertions are a force multiplier when used with simulation testing and fuzzing. If any assertion is +broken under a specific set of circumstances, the simulation will crash and we debug that failure. + +On top of the assertions in the code, the simulator also includes a variety of additional checkers +that verify the correctness of the cluster's state. For example, TigerBeetle replicas' data files +are designed to be byte-for-byte identical across caught-up nodes in the cluster. Some of the +storage checkers verify that this is the case across simulations. + +## Inspiration + +TigerBeetle's approach to DST was heavily inspired by the work of +[FoundationDB](https://apple.github.io/foundationdb/testing.html) and +[Antithesis](https://www.antithesis.com/solutions/problems_we_solve/). + +## Learn More + +- [Simulation Testing for Liveness (Blog)](https://tigerbeetle.com/blog/2023-07-06-simulation-testing-for-liveness) +- [Deterministic Simulation Testing (Video)](https://youtu.be/el-LqUTv00M?si=ltKilzPSW8c7nKVQ) diff --git a/ocam/docs/internals/vsr.md b/ocam/docs/internals/vsr.md new file mode 100644 index 00000000..a784dba8 --- /dev/null +++ b/ocam/docs/internals/vsr.md @@ -0,0 +1,300 @@ +# VSR + +Documentation for (roughly) code in the `src/vsr` directory. + +## Glossary + +Consensus: + +- _checkpoint_: Ensure that all updates from the past wrap of the WAL are durable in the _grid_, then advance the replica's recovery point by updating the superblock. After a checkpoint, the checkpointed WAL entries are safe to be overwritten by the next wrap. (Sidenote: in consensus literature this is sometimes called snapshotting. But we use that term to mean something else.) +- _header_: Identifier for many kinds of messages, including each entry in the VSR log. Passed around instead of the entry when the full entry is not needed (such as view change). +- _journal_: The in-memory data structure that manages the WAL. +- _nack_: Short for negative acknowledgement. Used to determine (during a view change) which entries can be truncated from the log. See [Protocol Aware Recovery](https://www.usenix.org/system/files/conference/fast18/fast18-alagappan.pdf). +- _op_: Short for op-number. An op is assigned to each request that is submitted by the user before being stored in the log. An op is a monotonically increasing integer identifying each message to be handled by consensus. When messages with the same op in different views conflict, view change picks one version to commit. Each user batch (which may contain many batch entries) corresponds to one op. Each op is identified (once inside the VSR log) by a _header_. +- _superblock_: All local state for the replica that cannot be replicated remotely. Loss is protected against by storing `config.superblock_copies` copies of the superblock. +- _view_: A replica is _primary_ for one view. Views are monotonically increasing integers that are incremented each time a new primary is selected. + +Consensus terminology largely follows the [VRR] paper, but uses `JoinView`/`View`/`ExitView` instead of `DoViewChange`/`StartView`/`StartViewChange`, to put the state (`View`) into the spotlight, as per Fred Brooks. + +Storage: + +- _zone_: The TigerBeetle data file is made up of zones. The superblock is one zone. +- _grid_: The zone on disk where LSM trees and metadata for them reside. +- _WAL_: Write-ahead log. It is implemented as two on-disk ring buffers. Entries are only overwritten after they have been checkpointed. +- _state sync_: The process of syncing checkpointed data (LSM root information, the _grid_, and the superblock freeset). When a replica lags behind the cluster far enough that their WALs no longer intersect, the lagging replica must state sync to catch up. + +## Protocols + +### Commands + +| `vsr.Header.Command` | Source | Target | Protocols | +| ------------------------: | ------: | -----------: | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `ping` | replica | replica | [Ping (Replica-Replica)](#protocol-ping-replica-replica) | +| `pong` | replica | replica | [Ping (Replica-Replica)](#protocol-ping-replica-replica) | +| `ping_client` | client | replica | [Ping (Replica-Client)](#protocol-ping-replica-client) | +| `pong_client` | replica | client | [Ping (Replica-Client)](#protocol-ping-replica-client) | +| `request` | client | primary | [Normal](#protocol-normal) | +| `prepare` | primary | replica | [Normal](#protocol-normal), [Repair WAL](#protocol-repair-wal) | +| `prepare_ok` | replica | primary | [Normal](#protocol-normal), [Repair WAL](#protocol-repair-wal) | +| `reply` | primary | client | [Normal](#protocol-normal), [Repair Client Replies](#protocol-repair-client-replies), [Sync Client Replies](#protocol-sync-client-replies) | +| `commit` | primary | backup | [Normal](#protocol-normal) | +| `exit_view` | replica | all replicas | [Start-View-Change](#protocol-start-view-change) | +| `join_view` | replica | all replicas | [View-Change](#protocol-view-change) | +| `view` | primary | backup | [Request/View](#protocol-request-view), [State Sync](./sync.md) | +| `get_view` | backup | primary | [Request/View](#protocol-request-view) | +| `get_headers` | replica | replica | [Repair Journal](#protocol-repair-journal) | +| `get_prepare` | replica | replica | [Repair WAL](#protocol-repair-wal) | +| `get_reply` | replica | replica | [Repair Client Replies](#protocol-repair-client-replies), [Sync Client Replies](#protocol-sync-client-replies) | +| `headers` | replica | replica | [Repair Journal](#protocol-repair-journal) | +| `eviction` | primary | client | [Client](#protocol-client) | +| `get_blocks` | replica | replica | [Sync Forest](#protocol-sync-forest), [Repair Grid](#protocol-repair-grid) | +| `block` | replica | replica | [Sync Forest](#protocol-sync-forest), [Repair Grid](#protocol-repair-grid) | + +### Recovery + +Unlike [VRR], TigerBeetle does not implement Recovery Protocol (see §4.3). +Instead, replicas persist their VSR state to the superblock. +This ensures that a recovering replica never backtracks to an older view (from the point of view of the cluster). + +### Protocol: Ping (Replica-Replica) + +Replicas send `command=ping`/`command=pong` messages to one another to synchronize clocks. + +### Protocol: Ping (Replica-Client) + +Clients send `command=ping_client` (and receive `command=pong_client`) messages to (from) replicas to learn the cluster's current view. + +### Protocol: Normal + +Normal protocol prepares and commits requests (from clients) and sends replies (to clients). + +1. The client sends a `command=request` message to the primary. (If the client's view is outdated, the receiver will forward the message on to the actual primary). +2. The primary converts the `command=request` to a `command=prepare` (assigning it an `op` and `timestamp`). +3. Each replica (in a chain beginning with the primary) performs the following steps concurrently: + - Write the prepare to the WAL. + - Forward the prepare to the next replica in the chain. +4. Each replica sends a `command=prepare_ok` message to the primary once it has written the prepare to the WAL. +5. When a primary collects a [replication quorum](#quorums) of `prepare_ok`s _and_ it has committed all preceding prepares, it commits the prepare. +6. The primary replies to the client. +7. The backups are informed that the prepare was committed by either: + - a subsequent prepare, or + - a periodic `command=commit` heartbeat message. + +```mermaid +sequenceDiagram + participant C0 as Client + participant R0 as Replica 0 (primary) + participant R1 as Replica 1 (backup) + participant R2 as Replica 2 (backup) + + C0->>R0: Request A + + R0->>+R0: Prepare A + R0->>+R1: Prepare A + R1->>+R2: Prepare A + + R0->>-R0: Prepare-Ok A + R1->>-R0: Prepare-Ok A + R0->>C0: Reply A + R2->>-R0: Prepare-Ok A +``` + +See also: + +- [VRR](https://hdl.handle.net/1721.1/71763) §4.1 + +### Protocol: Start-View-Change + +Start-View-Change (ExitView, EV) protocol initiates [view-changes](#protocol-view-change) with minimal disruption. + +Unlike the Start-View-Change described in [VRR](https://pmg.csail.mit.edu/papers/vr-revisited.pdf) §4.2, this protocol runs in both `status=normal` and `status=view_change` (not just `status=view_change`). + +1. Depending on the replica's status: + - `status=normal` & primary: When the replica has not recently received a `prepare_ok` (and it has a prepare in flight), pause broadcasting `command=commit`. + - `status=normal` & backup: When the replica has not recently received a `command=commit`, broadcast `command=exit_view` to all replicas (including self). + - `status=view_change`: If the replica has not completed a view-change recently, send a `command=exit_view` to all replicas (including self). +2. (Periodically retry sending the EV). +3. If the backup receives a `command=commit` or changes views (respectively), stop the `command=exit_view` retries. +4. If the replica collects a [view-change quorum](#quorums) of EV messages, transition to `status=view_change` for the next view. (That is, increment the replica's view and start sending a JV). + +This protocol approach enables liveness under asymmetric network partitions. For example, a replica which can send to the cluster but not receive may send EVs, but if the remainder of the cluster is healthy, they will never achieve a quorum, so the view is stable. When the partition heals, the formerly-isolated replica may rejoin the original view (if it was isolated in `status=normal`) or a new view (if it was isolated in `status=view_change`). + +See also: + +- [Raft does not Guarantee Liveness in the face of Network Faults](https://decentralizedthoughts.github.io/2020-12-12-raft-liveness-full-omission/) ("PreVote and CheckQuorum") +- ["Consensus: Bridging Theory and Practice"](https://web.stanford.edu/~ouster/cgi-bin/papers/OngaroPhD.pdf) §6.2 "Leaders" describes periodically committing a heartbeat to detect stale leaders. + +### Protocol: View-Change + +A replica sends `command=join_view` to all replicas, with the `view` it is attempting to start. + +- The _primary_ of the `view` collects a [view-change quorum](#quorums) of JVs. +- The _backup_ of the `view` uses to `join_view` to update its current `view` (transitioning to `status=view_change`). + +JVs include headers from prepares which are: + +- _present_: A valid header, corresponding to a valid prepare in the replica's WAL. +- _missing_: A valid header, corresponding to a prepare that the replica has not prepared/acked. +- _corrupt_: A valid header, corresponding to a corrupt prepare in the replica's WAL. +- _blank_: A placeholder (fake) header, corresponding to a header that the replica has never seen. +- _fault_: A placeholder (fake) header, corresponding to a header that the replica _may have_ prepared/acked. + +If the new primary collects a _nack quorum_ of _blank_ headers for a particular possibly-uncommitted op, it truncates the log. + +These cases are farther distinguished during [WAL repair](#protocol-repair-wal). + +When the primary collects its JV quorum: + +1. If any JV in the quorum is ahead of the primary by more than one checkpoint, + the new primary "forfeits" (that is, it immediately triggers another view change). +2. If any JV in the quorum is ahead of the primary by more than one checkpoint, + and any messages in the next checkpoint are possibly committed, + the new primary forfeits. +3. The primary installs the headers to its suffix. +4. Then the primary repairs its headers. ([Protocol: Repair Journal](#protocol-repair-journal)). +5. Then the primary repairs its prepares. ([Protocol: Repair WAL](#protocol-repair-wal)) (and potentially truncates uncommitted ops). +6. Then primary commits all prepares which are not known to be uncommitted. +7. Then the primary transitions to `status=normal` and broadcasts a `command=view`. + +### Protocol: Request/View + +#### `get_view` + +A backup sends a `command=get_view` to the primary of a view when any of the following occur: + +- the backup learns about a newer view via a `command=commit` message, or +- the backup learns about a newer view via a `command=prepare` message, or +- the backup discovers `commit_max` exceeds `min(op_head, op_checkpoint_next_trigger)` (during repair), +- the backup can't make progress committing and needs to state sync, or +- a replica recovers to `status=recovering_head` + +#### `view` + +When a `status=normal` primary receives `command=get_view`, it replies with a `command=view`. +`command=view` includes: +- The view's current suffix — the headers of the latest messages in the view. +- The current checkpoint (see [State Sync](./sync.md)). + +Together, the checkpoint and the view headers fully specify the logical and physical state of the view. + +Upon receiving a `view` for the new view, the backup installs the checkpoint if needed, installs the suffix, transitions to `status=normal`, and begins repair. + +A `view` contains the following headers (which may overlap): + +- The suffix: `pipeline_prepare_queue_max` headers from the head op down. +- The "hooks": the header of any previous checkpoint triggers within our repairable range. + This helps a lagging replica catch up. (There are at most 2). + +### Protocol: Repair Journal + +`get_headers` and `headers` repair gaps or breaks in a replica's journal headers. +Repaired headers are a prerequisite for [repairing prepares](#protocol-repair-wal). + +Because the headers are repaired backwards (from the head) by hash-chaining, it is safe for both backups and transitioning primaries. + +Gaps/breaks in a replica's journal headers may occur: + +- On a backup, receiving nonconsecutive ops, leaving a gap in its headers. +- On a backup, which has not finished repair. +- On a new primary during a view-change, which has not finished repair. + +### Protocol: Repair WAL + +The replica's journal tracks which prepares the WAL requires — i.e. headers for which either: + +- no prepare was ever received, or +- the prepare was received and written, but was since discovered to be corrupt + +During repair, missing/damaged prepares are requested & repaired chronologically, which: + +- improves the chances that older entries will be available, i.e. not yet overwritten +- enables better pipelining of repair and commit. + +In response to a `get_prepare`: + +- Respond with the `command=prepare` message with the requested prepare, if available and valid. +- Otherwise do not reply. (e.g. the corresponding slot in the WAL is corrupt) + +Per [PAR's CTRL Protocol](https://www.usenix.org/system/files/conference/fast18/fast18-alagappan.pdf), we do not nack corrupt entries, since they _might_ be the prepare being requested. + +See also [State Sync](./sync.md) protocol — the extent of WAL that the replica can/should repair +depends on the checkpoint. + +### Protocol: Repair Client Replies + +The replica stores the latest reply to each active client. + +During repair, corrupt client replies are requested & repaired. + +In response to a `get_reply`: + +- Respond with the `command=reply` message (the requested reply), if available and valid. +- Otherwise do not reply. + +### Protocol: Client + +1. Client sends `command=request operation=register` to registers with the cluster by starting a new request-reply hashchain. (See also: [Protocol: Normal](#protocol-normal)). +2. Client receives `command=reply operation=register` from the cluster. (If the cluster is at the maximum number of clients, it evicts the oldest). +3. Repeat: + 1. Send `command=request` to cluster. + 2. If the client has been evicted, receive `command=eviction` from the cluster. (The client must re-register before sending more requests.) + 3. If the client has not been evicted, receive `command=reply` from cluster. + +See also: + +- [Integration: Client Session Lifecycle](../../reference/sessions.md#lifecycle) +- [Integration: Client Session Eviction](../../reference/sessions.md#eviction) + +### Protocol: Repair Grid + +Grid repair is triggered when a replica discovers a corrupt (or missing) grid block. + +1. The repairing replica sends a `command=get_blocks` to any other replica. The message body contains a list of block `address`/`checksum`s. +2. Upon receiving a `command=get_blocks`, a replica reads its own grid to check for the requested blocks. For each matching block found, reply with the `command=block` message (the block itself). +3. Upon receiving a `command=block`, a replica writes the block to its grid, and resolves the reads that were blocked on it. + +Note that _both sides_ of grid repair can run while the grid is being opened during replica startup. +That is, a replica can help other replicas repair and repair itself simultaneously. + +TODO Describe state sync fallback. + +### Protocol: Sync Client Replies + +Sync missed client replies using [Protocol: Repair Grid](#protocol-repair-client-replies). + +See [State Sync](./sync.md) for details. + +### Protocol: Sync Forest + +Sync missed LSM manifest and table blocks using [Protocol: Repair Grid](#protocol-repair-grid). + +See [State Sync](./sync.md) for details. + +### Protocol: Reconfiguration + +TODO (Unimplemented) + +## Quorums + +- The _replication quorum_ is the minimum number of replicas required to complete a commit. +- The _view-change quorum_ is the minimum number of replicas required to complete a view-change. +- The _nack quorum_ is the minimum number of unique nacks required to truncate an uncommitted op. + +With the default configuration: + +| **Replica Count** | 1 | 2 | 3 | 4 | 5 | 6 | +| ---------------------: | --: | ----: | --: | --: | --: | --: | +| **Replication Quorum** | 1 | 2 | 2 | 2 | 3 | 3 | +| **View-Change Quorum** | 1 | 2 | 2 | 3 | 3 | 4 | +| **Nack Quorum** | 1 | **1** | 2 | 3 | 3 | 4 | + +See also: + +- `constants.quorum_replication_max` for configuration. +- [Flexible Paxos](https://fpaxos.github.io/) + +## Further reading +- [Viewstamped Replication Revisited](https://hdl.handle.net/1721.1/71763) +- [Protocol Aware Recovery](https://www.usenix.org/system/files/conference/fast18/fast18-alagappan.pdf) + +[VRR]: https://hdl.handle.net/1721.1/71763 diff --git a/ocam/docs/operating/README.md b/ocam/docs/operating/README.md new file mode 100644 index 00000000..f3761d68 --- /dev/null +++ b/ocam/docs/operating/README.md @@ -0,0 +1,15 @@ +# Operating + +This section is for anyone managing their own TigerBeetle cluster. While tiger beetles thrive even +in the harshest conditions, there's certainly a preferred way to handle one! + +- [Installing](./installing.md) lists all the various way to get the freshest TigerBeetle + binary. +- [Hardware](./hardware.md) specifies the host requirements. +- [Cluster](./cluster.md) specifies the overall cluster requirements and recommendations. +- [Deploying](./deploying/) spells out deployment process and its variations. +- [Monitoring](./monitoring.md) details how to monitor a TigerBeetle cluster. +- [Upgrading](./upgrading.md) explains how to move to a newer TigerBeetle version with a few seconds of downtime. +- [Recovering](./recovering.md) explains how to repair the cluster when a replica is permanently + lost. +- [Change Data Capture](./cdc.md) explains how to stream data out of TigerBeetle. diff --git a/ocam/docs/operating/cdc.md b/ocam/docs/operating/cdc.md new file mode 100644 index 00000000..cb6f65a6 --- /dev/null +++ b/ocam/docs/operating/cdc.md @@ -0,0 +1,219 @@ +# Change Data Capture + +TigerBeetle can stream changes (transfers and balance updates) to message queues using +the AMQP 0.9.1 protocol, which is compatible with RabbitMQ and various other message brokers. + +See [Installing](./installing.md) for instructions on how to deploy the TigerBeetle binary. + +Here’s how to start the CDC job: + +```console +./tigerbeetle amqp --addresses=127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002 --cluster=0 \ + --host=127.0.0.1 \ + --vhost=/ \ + --user=guest --password=guest \ + --publish-exchange=tigerbeetle +``` + +Here what the arguments mean: + +* `--addresses` specify IP addresses of all the replicas in the cluster. + **The order of addresses must correspond to the order of replicas**. + +* `--cluster` specifies a globally unique 128 bit cluster ID. + +* `--host` the AMQP host address in the format `ip:port`.
+ Both IPv4 and IPv6 addresses are supported. + If `port` is omitted, the AMQP default `5672` is used.
+ Multiple addresses (for clustered environments) and DNS names are **not supported**.
+ The operator must resolve the IP address of the preferred/reachable server.
+ The CDC job will exit with a non-zero code in case of any connectivity or configuration issue + with the AMQP server. + +* `--vhost` the AMQP virtual host name. + +* `--user` the AMQP username. + +* `--password` the AMQP password.
+ Only PLAIN authentication is supported. + +* `--publish-exchange` the exchange name.
+ Must be a pre-existing exchange provided by the operator.
+ Optional. May be omitted if `--publish-routing-key` is present. + +* `--publish-routing-key` the routing key used in combination with the exchange.
+ Optional. May be omitted if `publish-exchange` is present. + +* `--event-count-max` the maximum number of events fetched from TigerBeetle + and published to the AMQP server per batch.
+ Optional. Defaults to `2730` if omitted. + +* `--idle-interval-ms` the time interval in milliseconds to wait before querying again + when the last query returned no events.
+ Optional. Defaults to `1000` ms if omitted. + +* `--requests-per-second-limit` throttles the maximum number of requests per second made + to TigerBeetle.
+ Must be greater than zero.
+ Optional. No limit if omitted. + +* `--amqp-timeout-seconds` the maximum time, in seconds, to wait for a reply from + the AMQP server. If exceeded, the process exits with a non-zero code.
+ Must be greater than 0.
+ Optional. Defaults to `30` seconds if omitted. + +* `--tigerbeetle-timeout-seconds` the maximum time, in seconds, to wait for a reply from + the TigerBeetle cluster. If exceeded, the process exits with a non-zero code.
+ Must be greater than 0.
+ Optional. Defaults to `30` seconds if omitted. + +* `--timestamp-last` overrides the last published timestamp, resuming from this point.
+ This is a TigerBeetle timestamp with nanosecond precision.
+ Optional. If omitted, the last acknowledged timestamp is used. + +## Message content: + +Messages are published with custom headers, +allowing users to implement routing and filtering rules. + +Message headers: + +| Key | AMQP data type | Description | +|-----------------------|--------------------|------------------------------------------| +| `event_type` | `string` | The event type. | +| `ledger` | `long_long_int` | The ledger of the transfer and accounts. | +| `transfer_code` | `long_int` | The transfer code. | +| `debit_account_code` | `long_int` | The debit account code. | +| `credit_account_code` | `long_int` | The credit account code. | +| `app_id` | `string` | Constant `tigerbeetle`. | +| `content_type` | `string` | Constant `application/json` | +| `delivery_mode` | `short_short_uint` | Constant `2` which means _persistent_. | +| `timestamp` | `timestamp` | The event timestamp.¹ | + +> ¹ _AMQP timestamps are represented in seconds, so TigerBeetle timestamps are truncated.
+ Use the `timestamp` field in the message body for full nanosecond precision._ + +Message body: + +Each _event_ published contains information about the [transfer](../reference/transfer.md) +and the [account](../reference/account.md)s involved. + +* `type`: The type of event.
+ One of `single_phase`, `two_phase_pending`, `two_phase_posted`, `two_phase_voided` or + `two_phase_expired`.
+ See the [Two-Phase Transfers](../coding/two-phase-transfers.md) for more details. + +* `timestamp`: The event timestamp.
+ Usually, it's the same as the transfer's timestamp, + except when `event_type == 'two_phase_expired'` when it's the expiry timestamp. + +* `ledger`: The [ledger](../coding/data-modeling.md#ledgers) code. + +* `transfer`: Full details of the [transfer](../reference/transfer.md).
+ For `two_phase_expired` events, it's the pending transfer that was reverted. + +* `debit_account`: Full details of the [debit account](../reference/transfer.md#debit_account_id), + with the balance _as of_ the time of the event. + +* `credit_account`: Full details of the [credit account](../reference/transfer.md#credit_account_id), + with the balance _as of_ the time of the event. + +The message body is encoded as a UTF-8 JSON without line breaks or spaces. +Long integers such as `u128` and `u64` are encoded as JSON strings to improve interoperability. + +Here is a formatted example (with indentation and line breaks) for readability. + +```json +{ + "timestamp": "1745328372758695656", + "type": "single_phase", + "ledger": 2, + "transfer": { + "id": 9082709, + "amount": 3794, + "pending_id": 0, + "user_data_128": "79248595801719937611592367840129079151", + "user_data_64": "13615171707598273871", + "user_data_32": 3229992513, + "timeout": 0, + "code": 20295, + "flags": 0, + "timestamp": "1745328372758695656" + }, + "debit_account": { + "id": 3750, + "debits_pending": 0, + "debits_posted": 8463768, + "credits_pending": 0, + "credits_posted": 8861179, + "user_data_128": "118966247877720884212341541320399553321", + "user_data_64": "526432537153007844", + "user_data_32": 4157247332, + "code": 1, + "flags": 0, + "timestamp": "1745328270103398016" + }, + "credit_account": { + "id": 6765, + "debits_pending": 0, + "debits_posted": 8669204, + "credits_pending": 0, + "credits_posted": 8637251, + "user_data_128": "43670023860556310170878798978091998141", + "user_data_64": "12485093662256535374", + "user_data_32": 1924162092, + "code": 1, + "flags": 0, + "timestamp": "1745328270103401031" + } +} +``` + +## Guarantees + +TigerBeetle guarantees _at-least-once_ semantics when publishing to message brokers, +and makes a best effort to prevent duplicate messages. +However, during crash recovery, the CDC job may replay unacknowledged messages that could have +been already delivered to consumers. + +It is the consumer's responsibility to perform **idempotency checks** when processing messages. + +## Upgrading + +The CDC job requires TigerBeetle cluster version `0.16.43` or greater. + +The same [upgrade planning](./upgrading.md#planning-for-upgrades) recommended for clients applies +to the CDC job. The CDC job version must not be newer than the cluster version, and if so will fail +with an error message. + +Any transactions _originally_ created by TigerBeetle versions before `0.16.29` have the following +limitations for CDC processing: + +- Events of type `two_phase_expired` are **not** supported. +- Only transfers where both the debit and credit accounts have the + [`flags.history`](../reference/account.md#flagshistory) enabled are visible to CDC. + +Transactions committed after version `0.16.29` are fully compatible with CDC and do not require +the `history` flag. + +## CDC to RabbitMQ (AMQP 0.9.1) in production + +### High Availability + +The CDC job is single instance. Starting a second `tigerbeetle amqp` with the same `cluster_id` +will exit with a non-zero exit code. For high availability, the CDC job could be monitored for +crashes and restarted in case a failure. + +The CDC job itself is stateless, and will resume from the last event acknowledged by RabbitMQ, +however it may replay events that weren't acknowledged but received by the exchange. + +### TLS Support + +For secure `AMQPS` connections, we recommend using a TLS Tunnel to wrap the connection between +TigerBeetle and RabbitMQ. + +### Event Replay + +By default, when the CDC job starts, it resumes from the timestamp of the last acknowledged event in +RabbitMQ. This can be overridden to using `--timestamp-last`. For example, `--timestamp-last=0` will +replay all events. diff --git a/ocam/docs/operating/cluster.md b/ocam/docs/operating/cluster.md new file mode 100644 index 00000000..41ef3dae --- /dev/null +++ b/ocam/docs/operating/cluster.md @@ -0,0 +1,50 @@ +# Cluster Recommendations + +A TigerBeetle **cluster** is a set of machines each running the TigerBeetle server for strict +serializability, high availability and durability. The TigerBeetle server is a single binary. + +Each server operates on a single local data file. + +The TigerBeetle server binary plus its single data file is called a **replica**. + +A cluster guarantees strict serializability, the highest level of consistency, by automatically +electing a primary replica to order and backup transactions across replicas in the cluster. + +## Fault Tolerance + +**The optimal, recommended size for any production cluster is 6 replicas.** + +Given a cluster of 6 replicas: + +- 4/6 replicas are required to elect a new primary if the old primary fails. +- A cluster remains highly available (able to process transactions), preserving strict + serializability, provided that at least 3/6 machines have not failed (provided that the primary + has not also failed) or provided that at least 4/6 machines have not failed (if the primary also + failed and a new primary needs to be elected). +- A cluster preserves durability (surviving, detecting, and repairing corruption of any data file) + provided that the cluster remains available. If machines go offline temporarily and the cluster + becomes available again later, the cluster will be able to repair data file corruption once + availability is restored. +- A cluster will correctly remain unavailable if too many machine failures have occurred to preserve + data. In other words, TigerBeetle is designed to operate correctly or else to shut down safely if + safe operation with respect to strict serializability is no longer possible due to permanent data + loss. + +### Geographic Fault Tolerance + +All 6 replicas may be within the same data center (zero geographic fault tolerance), or spread +across 2 or more data centers, availability zones or regions (“sites”) for geographic fault +tolerance. + +**For mission critical availability, the optimal number of sites is 3**, since each site would then +contain 2 replicas so that the loss of an entire site would not impair the availability of the +cluster. + +Sites should preferably be within a few milliseconds of each other, since each transaction must be +replicated across sites before being committed. + +### Hardware Fault Tolerance + +It is important to ensure independent fault domains for each replica's data file, that each +replica's data file is stored on a separate disk (required), machine (required), rack (recommended), +data center (recommended) etc. diff --git a/ocam/docs/operating/deploying/README.md b/ocam/docs/operating/deploying/README.md new file mode 100644 index 00000000..49699378 --- /dev/null +++ b/ocam/docs/operating/deploying/README.md @@ -0,0 +1,51 @@ +# Deploying + +TigerBeetle is a single, statically linked binary without external dependencies, so the overall +deployment procedure is simple: + +- Get the `tigerbeetle` binary onto each of the cluster's machines (see + [Installing](../installing.md)). +- Format the data files, specifying cluster id, replica count, and replica index. +- Start replicas, specifying path to the data file and addresses of all replicas in the cluster. + +Here's how to deploy a three replica cluster running on a single machine: + +```console +curl -Lo tigerbeetle.zip https://linux.tigerbeetle.com && unzip tigerbeetle.zip && ./tigerbeetle version +./tigerbeetle format --cluster=0 --replica-count=3 --replica=0 ./0_0.tigerbeetle +./tigerbeetle format --cluster=0 --replica-count=3 --replica=1 ./0_1.tigerbeetle +./tigerbeetle format --cluster=0 --replica-count=3 --replica=2 ./0_2.tigerbeetle + +./tigerbeetle start --addresses=127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002 ./0_0.tigerbeetle & +./tigerbeetle start --addresses=127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002 ./0_1.tigerbeetle & +./tigerbeetle start --addresses=127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002 ./0_2.tigerbeetle & +``` + +Here's what the arguments mean: + +* `--cluster` specifies a globally unique 128 bit cluster ID. It is recommended to use a random + number for a cluster id, cluster ID `0` is reserved for testing. +* `--replica-count` specifies the size of the cluster. In the current version of TigerBeetle, + cluster size can not be changed after creation, but this limitation will be lifted in the future. +* `--replica` is a zero-based index of the current replica. While `--cluster` and `--replica-count` + arguments must match across all replicas of the cluster, `--replica` arguments must be unique. +* `./0_0.tigerbeetle` is a path to the data file. It doesn't matter how you name it, but the + suggested naming schema is `${CLUSTER_ID}_${REPLICA_INDEX}.tigerbeetle`. +* `--addresses` specify IP addresses of all the replicas in the cluster. **The order of addresses + must correspond to the order of replicas**. In particular, the `--addresses` argument must be the + same for all replicas and all clients, and the address at the replica index must correspond to + replica's own address. + +Production deployment differs in three aspects (see [Cluster Recommendations](../cluster.md)): + +- Each replica runs on a dedicated machine. +- Six replicas are used rather than three. +- There's a supervisor process to restart a replica process after a crash. + +## Deployment Recipes + +We have recipes for some commonly used deployment tools: + +- [systemd](./systemd.md) +- [Docker](./docker.md) +- [Managed](./managed-service.md) diff --git a/ocam/docs/operating/deploying/docker.md b/ocam/docs/operating/deploying/docker.md new file mode 100644 index 00000000..31c3d8b6 --- /dev/null +++ b/ocam/docs/operating/deploying/docker.md @@ -0,0 +1,214 @@ +# Docker + +TigerBeetle can be run using Docker. However, it is not recommended. + +TigerBeetle is distributed as a single, small, statically-linked binary. It +should be easy to run directly on the target machine. Using Docker as an +abstraction adds complexity while providing relatively little in this case. + +## Image + +The Docker image is available from the GitHub Container Registry: + + + +## Format the Data File + +When using Docker, the data file must be mounted as a volume: + +```shell +docker run --security-opt seccomp=unconfined \ + -v $(pwd)/data:/data ghcr.io/tigerbeetle/tigerbeetle \ + format --cluster=0 --replica=0 --replica-count=1 /data/0_0.tigerbeetle +``` + +```console +info(io): creating "0_0.tigerbeetle"... +info(io): allocating 660.140625MiB... +``` + +## Run the Server + +```console +docker run -it --security-opt seccomp=unconfined \ + -p 3000:3000 -v $(pwd)/data:/data ghcr.io/tigerbeetle/tigerbeetle \ + start --addresses=0.0.0.0:3000 /data/0_0.tigerbeetle +``` + +```console +info(io): opening "0_0.tigerbeetle"... +info(main): 0: cluster=0: listening on 0.0.0.0:3000 +``` + +## Run a Multi-Node Cluster Using Docker Compose + +Format the data file for each replica: + +```console +docker run --security-opt seccomp=unconfined -v $(pwd)/data:/data ghcr.io/tigerbeetle/tigerbeetle format --cluster=0 --replica=0 --replica-count=3 /data/0_0.tigerbeetle +docker run --security-opt seccomp=unconfined -v $(pwd)/data:/data ghcr.io/tigerbeetle/tigerbeetle format --cluster=0 --replica=1 --replica-count=3 /data/0_1.tigerbeetle +docker run --security-opt seccomp=unconfined -v $(pwd)/data:/data ghcr.io/tigerbeetle/tigerbeetle format --cluster=0 --replica=2 --replica-count=3 /data/0_2.tigerbeetle +``` + +Note that the data file stores which replica in the cluster the file belongs to. + +Then, create a docker-compose.yml file: + +```yaml +version: "3.7" + +## +# Note: this example might only work with linux + using `network_mode:host` because of 2 reasons: +# +# 1. When specifying an internal docker network, other containers are only available using dns based routing: +# e.g. from tigerbeetle_0, the other replicas are available at `tigerbeetle_1:3002` and +# `tigerbeetle_2:3003` respectively. +# +# 2. Tigerbeetle performs some validation of the ip address provided in the `--addresses` parameter +# and won't let us specify a custom domain name. +# +# The workaround for now is to use `network_mode:host` in the containers instead of specifying our +# own internal docker network +## + +services: + tigerbeetle_0: + image: ghcr.io/tigerbeetle/tigerbeetle + command: "start --addresses=0.0.0.0:3001,0.0.0.0:3002,0.0.0.0:3003 /data/0_0.tigerbeetle" + network_mode: host + volumes: + - ./data:/data + security_opt: + - "seccomp=unconfined" + + tigerbeetle_1: + image: ghcr.io/tigerbeetle/tigerbeetle + command: "start --addresses=0.0.0.0:3001,0.0.0.0:3002,0.0.0.0:3003 /data/0_1.tigerbeetle" + network_mode: host + volumes: + - ./data:/data + security_opt: + - "seccomp=unconfined" + + tigerbeetle_2: + image: ghcr.io/tigerbeetle/tigerbeetle + command: "start --addresses=0.0.0.0:3001,0.0.0.0:3002,0.0.0.0:3003 /data/0_2.tigerbeetle" + network_mode: host + volumes: + - ./data:/data + security_opt: + - "seccomp=unconfined" +``` + +And run it: + +```console +docker-compose up +``` + +```console +docker-compose up +Starting tigerbeetle_0 ... done +Starting tigerbeetle_2 ... done +Recreating tigerbeetle_1 ... done +Attaching to tigerbeetle_0, tigerbeetle_2, tigerbeetle_1 +tigerbeetle_1 | info(io): opening "0_1.tigerbeetle"... +tigerbeetle_2 | info(io): opening "0_2.tigerbeetle"... +tigerbeetle_0 | info(io): opening "0_0.tigerbeetle"... +tigerbeetle_0 | info(main): 0: cluster=0: listening on 0.0.0.0:3001 +tigerbeetle_2 | info(main): 2: cluster=0: listening on 0.0.0.0:3003 +tigerbeetle_1 | info(main): 1: cluster=0: listening on 0.0.0.0:3002 +tigerbeetle_0 | info(message_bus): connected to replica 1 +tigerbeetle_0 | info(message_bus): connected to replica 2 +tigerbeetle_1 | info(message_bus): connected to replica 2 +tigerbeetle_1 | info(message_bus): connection from replica 0 +tigerbeetle_2 | info(message_bus): connection from replica 0 +tigerbeetle_2 | info(message_bus): connection from replica 1 +tigerbeetle_0 | info(clock): 0: system time is 83ns ahead +tigerbeetle_2 | info(clock): 2: system time is 83ns ahead +tigerbeetle_1 | info(clock): 1: system time is 78ns ahead + +... and so on ... +``` + +## Troubleshooting + +### `error: PermissionDenied` + +If you see this error at startup, it is likely because you are running Docker +25.0.0 or newer, which blocks io_uring by default. Set +`--security-opt seccomp=unconfined` to fix it. + +### `exited with code 137` + +If you see this error without any logs from TigerBeetle, it is likely that the +Linux OOMKiller is killing the process. If you are running Docker inside a +virtual machine (such as is required on Docker or Podman for macOS), try +increasing the virtual machine memory limit. + +Alternatively, in a development environment, you can lower the size of the cache +so TigerBeetle uses less memory. For example, set `--cache-grid=256MiB` when +running `tigerbeetle start`. + +### Debugging panics + +If TigerBeetle panics and you can reproduce the panic, you can get a better +stack trace by switching to a debug image (by using the `:debug` Docker image +tag). + +```console +docker run -p 3000:3000 -v $(pwd)/data:/data ghcr.io/tigerbeetle/tigerbeetle:debug \ + start --addresses=0.0.0.0:3000 /data/0_0.tigerbeetle +``` + +### On MacOS + +#### `error: SystemResources` + +If you get `error: SystemResources` when running TigerBeetle in Docker on macOS, +the container may be blocking TigerBeetle from locking memory, which is necessary both for io_uring +and to prevent the kernel's use of swap from bypassing TigerBeetle's storage fault tolerance. + +#### Allowing MEMLOCK + +To raise the memory lock limits under Docker, execute one of the following: + +1. Run `docker run` with `--cap-add IPC_LOCK` +2. Run `docker run` with `--ulimit memlock=-1:-1` +3. Or modify the defaults in `$HOME/.docker/daemon.json` and restart the Docker + for Mac application: + +```json +{ + ... other settings ... + "default-ulimits": { + "memlock": { + "Hard": -1, + "Name": "memlock", + "Soft": -1 + } + }, + ... other settings ... +} +``` + +If you are running TigerBeetle with Docker Compose, you will need to add the +`IPC_LOCK` capability like this: + +```yaml +... rest of docker-compose.yml ... + +services: + tigerbeetle_0: + image: ghcr.io/tigerbeetle/tigerbeetle + command: "start --addresses=0.0.0.0:3001,0.0.0.0:3002,0.0.0.0:3003 /data/0_0.tigerbeetle" + network_mode: host + cap_add: # HERE + - IPC_LOCK # HERE + volumes: + - ./data:/data + +... rest of docker-compose.yml ... +``` + +See https://github.com/tigerbeetle/tigerbeetle/issues/92 for discussion. diff --git a/ocam/docs/operating/deploying/managed-service.md b/ocam/docs/operating/deploying/managed-service.md new file mode 100644 index 00000000..8f09dac5 --- /dev/null +++ b/ocam/docs/operating/deploying/managed-service.md @@ -0,0 +1,10 @@ +# Fully Managed + +For enterprises committed to excellence, TigerBeetle's world-class team provides: + +- fully managed cross-cloud deployments with automated disaster recovery; +- 24/7 responsiveness with proactive monitoring. + +Dedicated expertise from senior engineers ensures success (and sleep at night) at every step -- +from chart of accounts design and proof-of-concept, through production to monster scale. Contact +us at to set up a call. diff --git a/ocam/docs/operating/deploying/systemd.md b/ocam/docs/operating/deploying/systemd.md new file mode 100644 index 00000000..40cc4855 --- /dev/null +++ b/ocam/docs/operating/deploying/systemd.md @@ -0,0 +1,150 @@ +# Deploying with systemd + +The following includes an example systemd unit for running TigerBeetle with Linux systems that use +systemd. The unit is configured to start a single-node cluster, so you may need to adjust it for +other cluster configurations. + +### **tigerbeetle.service** +```toml +[Unit] +Description=TigerBeetle Replica +Documentation=https://docs.tigerbeetle.com/ +After=network-online.target +Wants=network-online.target systemd-networkd-wait-online.service + +[Service] +AmbientCapabilities=CAP_IPC_LOCK + +Environment=TIGERBEETLE_CACHE_GRID_SIZE=1GiB +Environment=TIGERBEETLE_ADDRESSES=3001 +Environment=TIGERBEETLE_REPLICA_COUNT=1 +Environment=TIGERBEETLE_REPLICA_INDEX=0 +Environment=TIGERBEETLE_CLUSTER_ID=0 +Environment=TIGERBEETLE_DATA_FILE=%S/tigerbeetle/0_0.tigerbeetle + +DevicePolicy=closed +DynamicUser=true +LockPersonality=true +ProtectClock=true +ProtectControlGroups=true +ProtectHome=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectProc=noaccess +ProtectSystem=strict +RestrictAddressFamilies=AF_INET AF_INET6 +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true + +StateDirectory=tigerbeetle +StateDirectoryMode=700 + +Type=exec +ExecStart=/usr/local/bin/tigerbeetle start --cache-grid=${TIGERBEETLE_CACHE_GRID_SIZE} --addresses=${TIGERBEETLE_ADDRESSES} ${TIGERBEETLE_DATA_FILE} + +[Install] +WantedBy=multi-user.target +``` + +## Adjusting + +You can adjust multiple aspects of this systemd service. +Each specific adjustment is listed below with instructions. + +It is not recommended to adjust some values directly in the service file. +When this is the case, the instructions will ask you to instead use systemd's drop-in file support. +Here's how to do that: + +1. Install the service unit in systemd (usually by adding it to `/etc/systemd/system`). +2. Create a drop-in file to override the environment variables. + Run `systemctl edit tigerbeetle.service`. + This will bring you to an editor with instructions. +3. Add your overrides. + Example: + ```toml + [Service] + Environment=TIGERBEETLE_CACHE_GRID_SIZE=4GiB + Environment=TIGERBEETLE_ADDRESSES=0.0.0.0:3001 + ``` + +### Pre-start script + +You can place the following script in `/usr/local/bin`. +This script is responsible for ensuring that a replica data file exists. +It will create a data file if it doesn't exist. + +#### **tigerbeetle-pre-start.sh** +```bash +#!/bin/sh +set -eu + +if ! test -e "${TIGERBEETLE_DATA_FILE}"; then + /usr/local/bin/tigerbeetle format --cluster="${TIGERBEETLE_CLUSTER_ID}" --replica="${TIGERBEETLE_REPLICA_INDEX}" --replica-count="${TIGERBEETLE_REPLICA_COUNT}" "${TIGERBEETLE_DATA_FILE}" +fi +``` + +The script assumes that `/bin/sh` exists and points to a POSIX-compliant shell, and the `test` utility is either built-in or in the script's search path. +If this is not the case, adjust the script's shebang. + +Add the following line to `tigerbeetle.service` before `ExecStart`. + +``` +ExecStartPre=/usr/local/bin/tigerbeetle-pre-start.sh +``` + +The service then executes the `tigerbeetle-pre-start.sh` script before starting TigerBeetle. + +### TigerBeetle executable + +The `tigerbeetle` executable is assumed to be installed in `/usr/local/bin`. +If this is not the case, adjust both `tigerbeetle.service` and `tigerbeetle-pre-start.sh` to use the correct location. + +### Environment variables + +This service uses environment variables to provide default values for a simple single-node cluster. +To configure a different cluster structure, or a cluster with different values, adjust the values in the environment variables. +It is **not recommended** to change these default values directly in the service file, because it may be important to revert to the default behavior later. +Instead, use systemd's drop-in file support. + +### State directory and replica data file path + +This service configures a state directory, which means that systemd will make sure the directory is created before the service starts, and the directory will have the correct permissions. +This is especially important because the service uses systemd's dynamic user capabilities. +systemd forces the state directory to be in `/var/lib`, which means that this service will have its replica data file at `/var/lib/tigerbeetle/`. +It is **not recommended** to adjust the state directory directly in the service file, because it may be important to revert to the default behavior later. +Instead, use systemd's drop-in file support. +If you do so, remember to also adjust the `TIGERBEETLE_DATA_FILE` environment variable, because it also hardcodes the `tigerbeetle` state directory value. + +Due to systemd's dynamic user capabilities, the replica data file path will not be owned by any existing user of the system. + +### Hardening configurations + +Some hardening configurations are enabled for added security when running the service. +It is **not recommended** to change these, since they have additional implications on all other configurations and values defined in this service file. +If you wish to change those, you are expected to understand those implications and make any other adjustments accordingly. + +### Development mode + +The service was created assuming it'll be used in a production scenario. + +In case you want to use this service for development as well, you may need to adjust the `ExecStart` line to include the `--development` flag if your development environment doesn't support Direct IO, or if you require smaller cache sizes and/or batch sizes due to memory constraints. + +### Memory Locking + +TigerBeetle requires `RLIMIT_MEMLOCK` to be set high enough to: + +1. initialize io_uring, which requires memory shared with the kernel to be locked, as well as +2. lock all allocated memory, and so prevent the kernel from swapping any pages to disk, which would not only affect performance but also bypass TigerBeetle's storage fault-tolerance. + +If the required memory cannot be locked, then the environment should be modified either by (in order of preference): + +1. giving the local `tigerbeetle` binary the `CAP_IPC_LOCK` capability (`sudo setcap "cap_ipc_lock=+ep" ./tigerbeetle`), or +2. raising the global `memlock` value under `/etc/security/limits.conf`, or else +3. disabling swap (io_uring may still require an RLIMIT increase). + +Memory locking is disabled for development environments when using the `--development` flag. + +For Linux running under Docker, refer to [Allowing MEMLOCK](./docker.md#allowing-memlock). diff --git a/ocam/docs/operating/hardware.md b/ocam/docs/operating/hardware.md new file mode 100644 index 00000000..e7f807a0 --- /dev/null +++ b/ocam/docs/operating/hardware.md @@ -0,0 +1,44 @@ +# Hardware + +TigerBeetle is designed to operate and provide more than adequate performance even on commodity +hardware. + +## Storage + +Local NVMe drives are highly recommended for production deployments, and there's no requirement for +RAID. + +In cloud or more complex deployments, remote block storage (e.g., EBS, NVMe-oF) may be used but will +be slower and care must be taken to ensure +[independent fault domains](./cluster.md#hardware-fault-tolerance) across replicas. + +Currently, TigerBeetle uses around 16TiB for 40 billion transfers. If you wish to use more capacity +than a single disk, RAID 10 / RAID 0 is recommended over parity RAID levels. + +The data file is created before the server is initially run and grows automatically. TigerBeetle has +been more extensively tested on ext4, but ext4 only supports data files up to 16TiB. XFS is +supported, but has seen less testing. TigerBeetle can also be run against the raw block device. + +## Memory + +ECC memory is required for production deployments. + +A replica requires at least 6 GiB RAM per machine. Between 16 GiB and 32 GiB or more (depending on +budget) is recommended to be allocated to each replica for caching. TigerBeetle uses static +allocation and will use exactly how much memory is explicitly allocated to it for caching via +command line argument. + +## CPU + +TigerBeetle requires only a single core per replica machine. TigerBeetle at present does not +utilize more cores, but may in future. + +It's recommended to have at least one additional core free for the operating system. + +## Network + +A minimum of a 1Gbps network connection is recommended. + +## Multitenancy + +There are no restrictions on sharing a server with other tenant processes. diff --git a/ocam/docs/operating/installing.md b/ocam/docs/operating/installing.md new file mode 100644 index 00000000..229d1fb5 --- /dev/null +++ b/ocam/docs/operating/installing.md @@ -0,0 +1,68 @@ +# Installing + +## Quick Install + +
+Linux + +```console +curl -Lo tigerbeetle.zip https://linux.tigerbeetle.com && unzip tigerbeetle.zip +./tigerbeetle version +``` +
+ +
+macOS + +```console +curl -Lo tigerbeetle.zip https://mac.tigerbeetle.com && unzip tigerbeetle.zip +./tigerbeetle version +``` +
+ +
+Windows + +```console +powershell -command "curl.exe -Lo tigerbeetle.zip https://windows.tigerbeetle.com; Expand-Archive tigerbeetle.zip ." +.\tigerbeetle version +``` +
+ +## Latest Release + +You can download prebuilt binaries for the latest release here: + +| | Linux | Windows | MacOS | +| :------ | :------------------------------ | :------------------------------- | :-------------------------------- | +| x86_64 | [tigerbeetle-x86_64-linux.zip] | [tigerbeetle-x86_64-windows.zip] | [tigerbeetle-universal-macos.zip] | +| aarch64 | [tigerbeetle-aarch64-linux.zip] | N/A | [tigerbeetle-universal-macos.zip] | + +[tigerbeetle-aarch64-linux.zip]: + https://github.com/tigerbeetle/tigerbeetle/releases/latest/download/tigerbeetle-aarch64-linux.zip +[tigerbeetle-universal-macos.zip]: + https://github.com/tigerbeetle/tigerbeetle/releases/latest/download/tigerbeetle-universal-macos.zip +[tigerbeetle-x86_64-linux.zip]: + https://github.com/tigerbeetle/tigerbeetle/releases/latest/download/tigerbeetle-x86_64-linux.zip +[tigerbeetle-x86_64-windows.zip]: + https://github.com/tigerbeetle/tigerbeetle/releases/latest/download/tigerbeetle-x86_64-windows.zip + +## Past Releases + +The releases page lists all past and current releases: + + + +TigerBeetle can be upgraded with a few seconds of downtime, this is documented in [Upgrading](./upgrading.md). + +## Building from Source + +Building from source is easy, but is not recommended for production deployments, as extra care is +needed to ensure compatibility with clients and upgradability. Refer to the +[internal documentation](https://github.com/tigerbeetle/tigerbeetle/tree/main/docs/internals) for +compilation instructions. + +## Client Libraries + +Client libraries for .NET, Go, Java, Node.js, and Python are published to the respective package +repositories, see [Clients](../coding/clients/). diff --git a/ocam/docs/operating/monitoring.md b/ocam/docs/operating/monitoring.md new file mode 100644 index 00000000..6f987a66 --- /dev/null +++ b/ocam/docs/operating/monitoring.md @@ -0,0 +1,68 @@ +# Monitoring + +TigerBeetle supports emitting metrics via StatsD, and uses the +[DogStatsD format for tags.](https://docs.datadoghq.com/developers/dogstatsd/datagram_shell?tab=metrics) + +This requires a StatsD compatible agent running locally. The Datadog Agent works out of the +box with its default configuration, as does Telegraf's [StatsD plugin](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/statsd/README.md), +with `datadog_extensions` enabled. + +You can enable emitting metrics by adding the following CLI flags to each replica, depending on your +[deployment method](./deploying/): + +``` +--experimental --statsd=127.0.0.1:8125 +``` + +The `--statsd` argument must be specified as an `IP:Port` address (IPv4 or IPv6). DNS names are not +currently supported. + +All TigerBeetle metrics are namespaced under `tb.` and are tagged with `cluster` (the cluster ID +specified at format time) and `replica` (the replica index). Specific metrics might have additional +tags. You can see a full list of metrics and cardinality by running `tigerbeetle inspect metrics`. + +## Specific Metrics + +### Overall status +The `replica_status` metric corresponds to the overall status of the replica. If it's anything other +than `0`, it should be alerted on as it indicates a non-normal status. The full values are: + +| Value | Status | Explanation | +|-------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------| +| 0 | normal | The replica is functioning normally. | +| 1 | view_change | The replica is doing a view change. | +| 2 | recovering | The replica is recovering. Usually, this will be present on startup before immediately transitioning to normal. | +| 3 | recovering_head | The replica's persistent state is corrupted, and it can't participate in consensus. It will try and recover from the remainder of the cluster. | + +### State sync status +The `replica_sync_stage` metric corresponds to the state sync stage. If this is anything other than +`0`, the replica is undergoing state sync and should be alerted on. + +### Operations timing +The `replica_request` timing metric can help inform how long requests are taking. This is tagged +with the operation type (e.g., `create_accounts`) and is the closest measure of how long a request +takes end to end, from the replica's point of view. + +It's recommended to additionally add metrics around your TigerBeetle client code, to measure the +full request latency, including things like network delay which aren't captured here. + +### Cache monitoring and sizing +The `grid_cache_hits` and `grid_cache_misses` metrics can help inform if your grid cache +(`--cache-grid`) is sized too small for your workload. + +## System Monitoring +In addition to TigerBeetle's own metrics, it's recommended to monitor and alert on a few additional +system level metrics. These are: + +* Disk space used, on the path that has the TigerBeetle data file. +* NTP clock sync status. +* Memory utilization: once started, TigerBeetle will use a fixed amount of memory and not change. A + change in memory utilization can indicate a problem with other processes on the server. +* CPU utilization: TigerBeetle will use at most a single core at present. CPU utilization exceeding + a single core can indicate a problem with other processes on the server. + +While a specific alerting threshold is hard to define for the following, they are useful to monitor +to help diagnose problems: + +* Network bandwidth utilization. +* Disk bandwidth utilization. diff --git a/ocam/docs/operating/recovering.md b/ocam/docs/operating/recovering.md new file mode 100644 index 00000000..396b1f1a --- /dev/null +++ b/ocam/docs/operating/recovering.md @@ -0,0 +1,30 @@ +# Recovering + +If a replica's data file is permanently lost (for example, if the SSD fails) then a new data file +must be reformatted to restore the cluster. + +The `tigerbeetle format` command must **not** be used for this purpose. The issue is that +`tigerbeetle format` would create a replica that believes that any operation that it hasn't seen can +be safely nack'd -- unaware of the promises it made which were lost with the old data file. This +could cause the cluster to lose committed data. + +Instead of `tigerbeetle format`, use the `tigerbeetle recover` command (see below). + +Note that `tigerbeetle recover` requires the cluster to be healthy and capable of view-changing. + +Once `tigerbeetle recover` succeeds, run `tigerbeetle start` as normal. At this point, the new +replica will rejoin the cluster and state sync to repair itself. + +## Example + +```console +./tigerbeetle recover \ + --cluster=0 \ + --addresses=127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002 \ + --replica=2 \ + --replica-count=3 \ + ./0_2.tigerbeetle +``` + +(`--addresses` should include an address for the recovering replica, but it can be any address as it +is just a placeholder.) diff --git a/ocam/docs/operating/upgrading.md b/ocam/docs/operating/upgrading.md new file mode 100644 index 00000000..a2ee1d5c --- /dev/null +++ b/ocam/docs/operating/upgrading.md @@ -0,0 +1,133 @@ +# Upgrading + +TigerBeetle guarantees storage stability and provides forward upgradeability. In other words, data +files created by a particular past version of TigerBeetle can be migrated to any future version of +TigerBeetle. + +Migration is automatic and the upgrade process is usually as simple as: +* Upgrade the replicas, by replacing the `./tigerbeetle` binary with a newer version on each + replica (they will restart automatically when needed). +* Upgrade the clients, by updating the corresponding client libraries, recompiling and redeploying + as usual. + +There's no need to stop the cluster for upgrades, and the client upgrades can be rolled out +gradually as any change to the client code might. + +NOTE: if you are upgrading from 0.15.3 (the first stable version), the upgrade procedure is more +involved, see the [release notes for 0.15.4](https://github.com/tigerbeetle/tigerbeetle/releases/tag/0.15.4). + +## API Stability + +At the moment, TigerBeetle doesn't guarantee complete API stability. +See [API Changes](../coding/api-changes.md) for the history of changes introduced in the TigerBeetle +Client libraries.
+Subscribe to the [tracking issue #2231](https://github.com/tigerbeetle/tigerbeetle/issues/2231) +to receive notifications about breaking changes. + +## Planning for upgrades +When upgrading TigerBeetle, each release specifies two important versions: +* the oldest release that can be upgraded from and, +* the oldest supported client version. + +It's critical to make sure that the release you intend to upgrade from is supported by the release +you're upgrading to. This is a hard requirement, but also a hard guarantee: if you wish to upgrade +to `0.15.20` which says it supports down to `0.15.5`, `0.15.5` _will_ work and `0.15.4` _will not_. +You will have to perform multiple upgrades in this case. + +The upgrade process involves first upgrading the replicas, followed by upgrading the clients. The +client version *cannot* be newer than the replica version, and will fail with an error message if +so. Provided the supported version ranges overlap, coordinating the upgrade between clients and +replicas is not required. + +Upgrading causes a short period of unavailability as the replicas restart. This is on the order of +5 seconds, and will show up as a latency spike on requests. The TigerBeetle clients will internally +retry any requests during the period. + +Even though this period is short, scheduling a maintenance window for upgrades is still +recommended, for an extra layer of safety. + +Any special instructions, like that when upgrading from 0.15.3 to 0.15.4, will be explicitly +mentioned in the [changelog](https://github.com/tigerbeetle/tigerbeetle/blob/main/CHANGELOG.md) +and [release notes](https://github.com/tigerbeetle/tigerbeetle/releases). + +## Upgrading binary-based installations +If TigerBeetle is installed under `/usr/bin/tigerbeetle`, and you wish to upgrade to `0.15.4`: +```bash +# SSH to each replica, in no particular order: +cd /tmp +wget https://github.com/tigerbeetle/tigerbeetle/releases/download/0.15.4/tigerbeetle-x86_64-linux.zip +unzip tigerbeetle-x86_64-linux.zip + +# Put the binary on the same file system as the target, so mv is atomic. +mv tigerbeetle /usr/bin/tigerbeetle-new + +mv /usr/bin/tigerbeetle /usr/bin/tigerbeetle-old +mv /usr/bin/tigerbeetle-new /usr/bin/tigerbeetle + +# Restart TigerBeetle. Only required when upgrading from 0.15.3. +# Otherwise, it will detect new versions are available and coordinate the upgrade itself. +systemctl restart tigerbeetle # or, however you are managing TigerBeetle. +``` + +## Upgrading Docker-based installations +If you're running TigerBeetle inside Kubernetes or Docker, update the tag that is pointed to the +release you wish to upgrade to. Before beginning, it's strongly recommended to have a rolling deploy +strategy set up. + +For example: +``` +image: ghcr.io/tigerbeetle/tigerbeetle:0.15.3 +``` + +becomes +``` +image: ghcr.io/tigerbeetle/tigerbeetle:0.15.4 +``` + +Due to the way upgrades work internally, this will restart with the new binary available, but still +running the older version. TigerBeetle will then coordinate the actual upgrade when all replicas +are ready and have the latest version available. + +## Upgrading clients +Update your language's specific package management, to reference the same version of the +TigerBeetle client: + +### .NET +``` +dotnet add package tigerbeetle --version 0.15.4 +``` + +### Go +``` +go mod edit -require github.com/tigerbeetle/tigerbeetle-go@v0.15.4 +``` + +### Java +Edit your `pom.xml`: + +``` + + com.tigerbeetle + tigerbeetle-java + 0.15.4 + +``` + +### Node.js +``` +npm install --save-exact tigerbeetle-node@0.15.4 +``` + +### Python +``` +pip install tigerbeetle==0.15.4 +``` + +## Troubleshooting +### Upgrading to a newer version with incompatible clients +If a release of TigerBeetle no longer supports the client version you're using, it's still possible +to upgrade, with two options: +* Upgrade the replicas to the latest version. In this case, the clients will stop working for the + duration of the upgrade and unavailability will be extended. +* Upgrade the replicas to the latest release that supports the client version in use, then upgrade + the clients to that version. Repeat this until you're on the latest release. diff --git a/ocam/docs/reference/README.md b/ocam/docs/reference/README.md new file mode 100644 index 00000000..2beb1284 --- /dev/null +++ b/ocam/docs/reference/README.md @@ -0,0 +1,22 @@ +# Reference + +Like the [Coding](../coding/) section, the reference is aimed at programmers building applications +on top of TigerBeetle. While Coding provides a series of topical guides, Reference exhaustively +documents every single aspect of TigerBeetle. Any answer can be found here, but it might take some +digging! + +- [Client Sessions](./sessions.md) +- [Account](./account.md) +- [Transfer](./transfer.md) +- [AccountBalance](./account-balance.md) +- [AccountFilter](./account-filter.md) +- [QueryFilter](./query-filter.md) +- [Requests](./requests/) + - [`create_accounts`](./requests/create_accounts.md) + - [`create_transfers`](./requests/create_transfers.md) + - [`lookup_accounts`](./requests/lookup_accounts.md) + - [`lookup_transfers`](./requests/lookup_transfers.md) + - [`get_account_balances`](./requests/get_account_balances.md) + - [`get_account_transfers`](./requests/get_account_transfers.md) + - [`query_accounts`](./requests/query_accounts.md) + - [`query_transfers`](./requests/query_transfers.md) diff --git a/ocam/docs/reference/account-balance.md b/ocam/docs/reference/account-balance.md new file mode 100644 index 00000000..610b40fc --- /dev/null +++ b/ocam/docs/reference/account-balance.md @@ -0,0 +1,63 @@ +# `AccountBalance` + +An `AccountBalance` is a record storing the [`Account`](./account.md)'s balance at a given point in +time. + +Only Accounts with the flag [`history`](./account.md#flagshistory) set retain +[historical balances](https://docs.tigerbeetle.com/reference/requests/get_account_balances). + +## Fields + +### `timestamp` + +This is the time the account balance was updated, as nanoseconds since UNIX epoch. + +The timestamp refers to the same [`Transfer.timestamp`](./transfer.md#timestamp) which changed the +[`Account`](./account.md). + +The amounts refer to the account balance recorded _after_ the transfer execution. + +Constraints: + +- Type is 64-bit unsigned integer (8 bytes) + +### `debits_pending` + +Amount of [pending debits](./account.md#debits_pending). + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) + +### `debits_posted` + +Amount of [posted debits](./account.md#debits_posted). + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) + +### `credits_pending` + +Amount of [pending credits](./account.md#credits_pending). + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) + +### `credits_posted` + +Amount of [posted credits](./account.md#credits_posted). + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) + +### `reserved` + +This space may be used for additional data in the future. + +Constraints: + +- Type is 56 bytes +- Must be zero diff --git a/ocam/docs/reference/account-filter.md b/ocam/docs/reference/account-filter.md new file mode 100644 index 00000000..9038bf7e --- /dev/null +++ b/ocam/docs/reference/account-filter.md @@ -0,0 +1,116 @@ +# `AccountFilter` + +An `AccountFilter` is a record containing the filter parameters for querying +the [account transfers](./requests/get_account_transfers.md) +and the [account historical balances](./requests/get_account_balances.md). + +## Fields + +### `account_id` + +The unique [identifier](account.md#id) of the account for which the results will be retrieved. + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) +- Must not be zero or `2^128 - 1` + +### `user_data_128` + +Filter the results by the field [`Transfer.user_data_128`](transfer.md#user_data_128). +Optional; set to zero to disable the filter. + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) + +### `user_data_64` + +Filter the results by the field [`Transfer.user_data_64`](transfer.md#user_data_64). +Optional; set to zero to disable the filter. + +Constraints: + +- Type is 64-bit unsigned integer (8 bytes) + +### `user_data_32` + +Filter the results by the field [`Transfer.user_data_32`](transfer.md#user_data_32). +Optional; set to zero to disable the filter. + +Constraints: + +- Type is 32-bit unsigned integer (4 bytes) + +### `code` + +Filter the results by the [`Transfer.code`](transfer.md#code). +Optional; set to zero to disable the filter. + +Constraints: + +- Type is 16-bit unsigned integer (2 bytes) + +### `reserved` + +This space may be used for additional data in the future. + +Constraints: + +- Type is 58 bytes +- Must be zero + +### `timestamp_min` + +The minimum [`Transfer.timestamp`](transfer.md#timestamp) from which results will be returned, inclusive range. +Optional; set to zero to disable the lower-bound filter. + +Constraints: + +- Type is 64-bit unsigned integer (8 bytes) +- Must be less than `2^63`. + +### `timestamp_max` + +The maximum [`Transfer.timestamp`](transfer.md#timestamp) from which results will be returned, inclusive range. +Optional; set to zero to disable the upper-bound filter. + +Constraints: + +- Type is 64-bit unsigned integer (8 bytes) +- Must be less than `2^63`. + +### `limit` + +The maximum number of results that can be returned by this query. + +Limited by the [maximum message size](../coding/requests.md#batching-events). + +Constraints: + +- Type is 32-bit unsigned integer (4 bytes) +- Must not be zero + +### `flags` + +A bitfield that specifies querying behavior. + +Constraints: + +- Type is 32-bit unsigned integer (4 bytes) + +#### `flags.debits` + +Whether or not to include results where the field [`debit_account_id`](transfer.md#debit_account_id) +matches the parameter [`account_id`](#account_id). + +#### `flags.credits` + +Whether or not to include results where the field [`credit_account_id`](transfer.md#credit_account_id) +matches the parameter [`account_id`](#account_id). + +#### `flags.reversed` + +Whether the results are sorted by timestamp in chronological or reverse-chronological order. If the +flag is not set, the event that happened first (has the smallest timestamp) will come first. If the +flag is set, the event that happened last (has the largest timestamp) will come first. diff --git a/ocam/docs/reference/account.md b/ocam/docs/reference/account.md new file mode 100644 index 00000000..07f588b0 --- /dev/null +++ b/ocam/docs/reference/account.md @@ -0,0 +1,284 @@ +# `Account` + +An `Account` is a record storing the cumulative effect of committed [transfers](./transfer.md). + +### Updates + +Account fields _cannot be changed by the user_ after creation. However, debits and credits fields +are updated by TigerBeetle as transfers move money to and from an account. + +### Deletion + +Accounts **cannot be deleted** after creation. This provides a strong guarantee for an audit trail +-- and the account record is only 128 bytes. + +If an account is no longer in use, you may want to +[zero out its balance](../coding/recipes/close-account.md). + +### Guarantees + +- Accounts are immutable. They are never modified once they are successfully created (excluding + balance fields, which are modified by transfers). +- There is at most one `Account` with a particular [`id`](#id). +- The sum of all accounts' [`debits_pending`](#debits_pending) equals the sum of all accounts' + [`credits_pending`](#credits_pending). +- The sum of all accounts' [`debits_posted`](#debits_posted) equals the sum of all accounts' + [`credits_posted`](#credits_posted). + +## Fields + +### `id` + +This is a unique, client-defined identifier for the account. + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) +- Must not be zero or `2^128 - 1` (the highest 128-bit unsigned integer) +- Must not conflict with another account in the cluster + +See the [`id` section in the data modeling doc](../coding/data-modeling.md#id) for more +recommendations on choosing an ID scheme. + +Note that account IDs are unique for the cluster -- not per ledger. If you want to store a +relationship between accounts, such as indicating that multiple accounts on different ledgers belong +to the same user, you should store a user ID in one of the [`user_data`](#user_data_128) fields. + +### `debits_pending` + +`debits_pending` counts debits reserved by pending transfers. When a pending transfer posts, voids, +or times out, the amount is removed from `debits_pending`. + +Money in `debits_pending` is reserved — that is, it cannot be spent until the corresponding pending +transfer resolves. + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) +- Must be zero when the account is created + +### `debits_posted` + +Amount of posted debits. + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) +- Must be zero when the account is created + +### `credits_pending` + +`credits_pending` counts credits reserved by pending transfers. When a pending transfer posts, +voids, or times out, the amount is removed from `credits_pending`. + +Money in `credits_pending` is reserved — that is, it cannot be spent until the corresponding pending +transfer resolves. + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) +- Must be zero when the account is created + +### `credits_posted` + +Amount of posted credits. + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) +- Must be zero when the account is created + +### `user_data_128` + +This is an optional 128-bit secondary identifier to link this account to an external entity or +event. + +When set to zero, no secondary identifier will be associated with the account, therefore only +non-zero values can be used as [query filter](./query-filter.md). + +As an example, you might use a +[ULID](../coding/data-modeling.md#tigerbeetle-time-based-identifiers-recommended) that ties together +a group of accounts. + +For more information, see [Data Modeling](../coding/data-modeling.md#user_data). + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) + +### `user_data_64` + +This is an optional 64-bit secondary identifier to link this account to an external entity or event. + +When set to zero, no secondary identifier will be associated with the account, therefore only +non-zero values can be used as [query filter](./query-filter.md). + +As an example, you might use this field store an external timestamp. + +For more information, see [Data Modeling](../coding/data-modeling.md#user_data). + +Constraints: + +- Type is 64-bit unsigned integer (8 bytes) + +### `user_data_32` + +This is an optional 32-bit secondary identifier to link this account to an external entity or event. + +When set to zero, no secondary identifier will be associated with the account, therefore only +non-zero values can be used as [query filter](./query-filter.md). + +As an example, you might use this field to store a timezone or locale. + +For more information, see [Data Modeling](../coding/data-modeling.md#user_data). + +Constraints: + +- Type is 32-bit unsigned integer (4 bytes) + +### `reserved` + +This space may be used for additional data in the future. + +Constraints: + +- Type is 4 bytes +- Must be zero + +### `ledger` + +This is an identifier that partitions the sets of accounts that can transact with each other. + +See [data modeling](../coding/data-modeling.md#ledgers) for more details about how to think about +setting up your ledgers. + +Constraints: + +- Type is 32-bit unsigned integer (4 bytes) +- Must not be zero + +### `code` + +This is a user-defined enum denoting the category of the account. + +As an example, you might use codes `1000`-`3340` to indicate asset accounts in general, where `1001` +is Bank Account and `1002` is Money Market Account and `2003` is Motor Vehicles and so on. + +Constraints: + +- Type is 16-bit unsigned integer (2 bytes) +- Must not be zero + +### `flags` + +A bitfield that toggles additional behavior. + +Constraints: + +- Type is 16-bit unsigned integer (2 bytes) +- Some flags are mutually exclusive; see + [`flags_are_mutually_exclusive`](./requests/create_accounts.md#flags_are_mutually_exclusive). + +#### `flags.linked` + +This flag links the result of this account creation to the result of the next one in the request, +such that they will either succeed or fail together. + +The last account in a chain of linked accounts does **not** have this flag set. + +You can read more about [linked events](../coding/linked-events.md). + +#### `flags.debits_must_not_exceed_credits` + +When set, transfers will be rejected that would cause this account's debits to exceed credits. +Specifically when +`account.debits_pending + account.debits_posted + transfer.amount > account.credits_posted`. + +This cannot be set when `credits_must_not_exceed_debits` is also set. + +#### `flags.credits_must_not_exceed_debits` + +When set, transfers will be rejected that would cause this account's credits to exceed debits. +Specifically when +`account.credits_pending + account.credits_posted + transfer.amount > account.debits_posted`. + +This cannot be set when `debits_must_not_exceed_credits` is also set. + +#### `flags.history` + +When set, the account will retain the history of balances at each transfer. + +Note that the [`get_account_balances`](./requests/get_account_balances.md) operation only works for +accounts with this flag set. + +#### `flags.imported` + +When set, allows importing historical `Account`s with their original [`timestamp`](#timestamp). + +TigerBeetle will not use the [cluster clock](../coding/time.md) to assign the timestamp, allowing +the user to define it, expressing _when_ the account was effectively created by an external +event. + +To maintain system invariants regarding auditability and traceability, some constraints are +necessary: + +- It is not allowed to mix events with the `imported` flag set and _not_ set in the same batch. + The application must submit batches of imported events separately. + +- User-defined timestamps must be **unique** and expressed as nanoseconds since the UNIX epoch. + No two objects can have the same timestamp, even different objects like an `Account` and a `Transfer` cannot share the same timestamp. + +- User-defined timestamps must be a past date, never ahead of the cluster clock at the time the + request arrives. + +- Timestamps must be strictly increasing. + + Even user-defined timestamps that are required to be past dates need to be at least one + nanosecond ahead of the timestamp of the last account committed by the cluster. + + Since the timestamp cannot regress, importing past events can be naturally restrictive without + coordination, as the last timestamp can be updated using the cluster clock during regular + cluster activity. Instead, it's recommended to import events only on a fresh cluster or + during a scheduled maintenance window. + + It's recommended to submit the entire batch as a [linked chain](#flagslinked), ensuring that + if any account fails, none of them are committed, preserving the last timestamp unchanged. + This approach gives the application a chance to correct failed imported accounts, re-submitting + the batch again with the same user-defined timestamps. + +#### `flags.closed` + +When set, the account will reject further transfers, +except for [voiding two-phase transfers](transfer.md#modes) that are still pending. + +- This flag can be set during the account creation. +- This flag can also be set by sending a [two-phase pending transfer](transfer.md#flagspending) + with the [`Transfer.flags.closing_debit`](transfer.md#flagsclosing_debit) + and/or [`Transfer.flags.closing_credit`](transfer.md#flagsclosing_credit) flags set. +- This flag can be _unset_ by [voiding](transfer.md#flagsvoid_pending_transfer) the two-phase + pending transfer that closed the account. + +### `timestamp` + +This is the time the account was created, as nanoseconds since UNIX epoch. +You can read more about [Time in TigerBeetle](../coding/time.md). + +Constraints: + +- Type is 64-bit unsigned integer (8 bytes) +- Must be `0` when the `Account` is created with [`flags.imported`](#flagsimported) _not_ set + + It is set by TigerBeetle to the moment the account arrives at the cluster. + +- Must be greater than `0` and less than `2^63` when the `Account` is created with + [`flags.imported`](#flagsimported) set + +## Internals + +If you're curious and want to learn more, you can find the source code for this struct in +[src/tigerbeetle.zig](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/tigerbeetle.zig). +Search for `const Account = extern struct {`. + +You can find the source code for creating an account in +[src/state_machine.zig](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/state_machine.zig). +Search for `fn create_account(`. diff --git a/ocam/docs/reference/query-filter.md b/ocam/docs/reference/query-filter.md new file mode 100644 index 00000000..86e6c8cd --- /dev/null +++ b/ocam/docs/reference/query-filter.md @@ -0,0 +1,115 @@ +# `QueryFilter` + +A `QueryFilter` is a record containing the filter parameters for +[querying accounts](./requests/query_accounts.md) +and [querying transfers](./requests/query_transfers.md). + +## Fields + +### `user_data_128` + +Filter the results by the field [`Account.user_data_128`](account.md#user_data_128) or +[`Transfer.user_data_128`](transfer.md#user_data_128). +Optional; set to zero to disable the filter. + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) + +### `user_data_64` + +Filter the results by the field [`Account.user_data_64`](account.md#user_data_64) or +[`Transfer.user_data_64`](transfer.md#user_data_64). +Optional; set to zero to disable the filter. + +Constraints: + +- Type is 64-bit unsigned integer (8 bytes) + +### `user_data_32` + +Filter the results by the field [`Account.user_data_32`](account.md#user_data_32) or +[`Transfer.user_data_32`](transfer.md#user_data_32). +Optional; set to zero to disable the filter. + +Constraints: + +- Type is 32-bit unsigned integer (4 bytes) + +### `ledger` + +Filter the results by the field [`Account.ledger`](account.md#ledger) or +[`Transfer.ledger`](transfer.md#ledger). +Optional; set to zero to disable the filter. + +Constraints: + +- Type is 32-bit unsigned integer (4 bytes) + +### `code` + +Filter the results by the field [`Account.code`](account.md#code) or +[`Transfer.code`](transfer.md#code). +Optional; set to zero to disable the filter. + +Constraints: + +- Type is 16-bit unsigned integer (2 bytes) + +### `reserved` + +This space may be used for additional data in the future. + +Constraints: + +- Type is 6 bytes +- Must be zero + +### `timestamp_min` + +The minimum [`Account.timestamp`](account.md#timestamp) or +[`Transfer.timestamp`](transfer.md#timestamp) from which results will be returned, +inclusive range. +Optional; set to zero to disable the lower-bound filter. + +Constraints: + +- Type is 64-bit unsigned integer (8 bytes) +- Must not be `2^64 - 1` + +### `timestamp_max` + +The maximum [`Account.timestamp`](account.md#timestamp) or +[`Transfer.timestamp`](transfer.md#timestamp) from which results will be returned, +inclusive range. +Optional; set to zero to disable the upper-bound filter. + +Constraints: + +- Type is 64-bit unsigned integer (8 bytes) +- Must not be `2^64 - 1` + +### `limit` + +The maximum number of results that can be returned by this query. + +Limited by the [maximum message size](../coding/requests.md#batching-events). + +Constraints: + +- Type is 32-bit unsigned integer (4 bytes) +- Must not be zero + +### `flags` + +A bitfield that specifies querying behavior. + +Constraints: + +- Type is 32-bit unsigned integer (4 bytes) + +#### `flags.reversed` + +Whether the results are sorted by timestamp in chronological or reverse-chronological order. If the +flag is not set, the event that happened first (has the smallest timestamp) will come first. If the +flag is set, the event that happened last (has the largest timestamp) will come first. diff --git a/ocam/docs/reference/requests/README.md b/ocam/docs/reference/requests/README.md new file mode 100644 index 00000000..63850ccf --- /dev/null +++ b/ocam/docs/reference/requests/README.md @@ -0,0 +1,16 @@ +# Requests + +TigerBeetle supports the following request types: + +- [`create_accounts`](./create_accounts.md): create [`Account`s](../account.md) +- [`create_transfers`](./create_transfers.md): create [`Transfer`s](../transfer.md) +- [`lookup_accounts`](./lookup_accounts.md): fetch `Account`s by `id` +- [`lookup_transfers`](./lookup_transfers.md): fetch `Transfer`s by `id` +- [`get_account_transfers`](./get_account_transfers.md): fetch `Transfer`s by `debit_account_id` or + `credit_account_id` +- [`get_account_balances`](./get_account_balances.md): fetch the historical account balance by the + `Account`'s `id`. +- [`query_accounts`](./query_accounts.md): query `Account`s +- [`query_transfers`](./query_transfers.md): query `Transfer`s + +_More request types, including more powerful queries, are coming soon!_ diff --git a/ocam/docs/reference/requests/create_accounts.md b/ocam/docs/reference/requests/create_accounts.md new file mode 100644 index 00000000..c14144df --- /dev/null +++ b/ocam/docs/reference/requests/create_accounts.md @@ -0,0 +1,217 @@ +# `create_accounts` + +Create one or more [`Account`](../account.md)s. + +## Event + +A batch of accounts to create. +See [`Account`](../account.md) for constraints. + +## Result + +An array containing the result for each account in the event batch. + +### `timestamp` + +- For [successful accounts](#created), it is the [`timestamp`](../account.md#timestamp) + assigned to the account object. +- For [existing accounts](#exists), it is the [`timestamp`](../account.md#timestamp) + of the original object. +- For all other results, it indicates the time at which validation occurred. + +
+Client release < 0.17.0 + +Create results are sparse, containing only failed events and the `index` +of the account within the events batch. + +The network protocol does not include a [`created`](#created) result for successfully +created accounts. + +
+ +### `status` + +Status codes are listed in this section in order of descending precedence — that is, if more than +one error is applicable to the account being created, only the result listed first is returned. + +#### `created` + +The account was successfully created; it did not previously exist. + +#### `linked_event_failed` + +The account was not created. One or more of the accounts in the +[linked chain](../account.md#flagslinked) is invalid, so the whole chain failed. + +#### `linked_event_chain_open` + +The account was not created. The [`Account.flags.linked`](../account.md#flagslinked) flag was set on +the last event in the batch, which is not legal. (`flags.linked` indicates that the chain continues +to the next operation). + +#### `imported_event_expected` + +The account was not created. The [`Account.flags.imported`](../account.md#flagsimported) was +set on the first account of the batch, but not all accounts in the batch. +Batches cannot mix imported accounts with non-imported accounts. + +#### `imported_event_not_expected` + +The account was not created. The [`Account.flags.imported`](../account.md#flagsimported) was +expected to _not_ be set, as it's not allowed to mix accounts with different `imported` flag +in the same batch. The first account determines the entire operation. + +#### `timestamp_must_be_zero` + +This result only applies when [`Account.flags.imported`](../account.md#flagsimported) is _not_ set. + +The account was not created. The [`Account.timestamp`](../account.md#timestamp) is nonzero, but +must be zero. The cluster is responsible for setting this field. + +The [`Account.timestamp`](../account.md#timestamp) can only be assigned when creating accounts +with [`Account.flags.imported`](../account.md#flagsimported) set. + +#### `imported_event_timestamp_out_of_range` + +This result only applies when [`Account.flags.imported`](../account.md#flagsimported) is set. + +The account was not created. The [`Account.timestamp`](../account.md#timestamp) is out of range, +but must be a user-defined timestamp greater than `0` and less than `2^63`. + +#### `imported_event_timestamp_must_not_advance` + +This result only applies when [`Account.flags.imported`](../account.md#flagsimported) is set. + +The account was not created. The user-defined [`Account.timestamp`](../account.md#timestamp) is +greater than the current [cluster time](../../coding/time.md), but it must be a past timestamp. + +#### `reserved_field` + +The account was not created. [`Account.reserved`](../account.md#reserved) is nonzero, but must be +zero. + +#### `reserved_flag` + +The account was not created. `Account.flags.reserved` is nonzero, but must be zero. + +#### `id_must_not_be_zero` + +The account was not created. [`Account.id`](../account.md#id) is zero, which is a reserved value. + +#### `id_must_not_be_int_max` + +The account was not created. [`Account.id`](../account.md#id) is `2^128 - 1`, which is a reserved +value. + +#### `exists_with_different_flags` + +An account with the same `id` already exists, but with different [`flags`](../account.md#flags). + +#### `exists_with_different_user_data_128` + +An account with the same `id` already exists, but with different +[`user_data_128`](../account.md#user_data_128). + +#### `exists_with_different_user_data_64` + +An account with the same `id` already exists, but with different +[`user_data_64`](../account.md#user_data_64). + +#### `exists_with_different_user_data_32` + +An account with the same `id` already exists, but with different +[`user_data_32`](../account.md#user_data_32). + +#### `exists_with_different_ledger` + +An account with the same `id` already exists, but with different [`ledger`](../account.md#ledger). + +#### `exists_with_different_code` + +An account with the same `id` already exists, but with different [`code`](../account.md#code). + +#### `exists` + +An account with the same `id` already exists. + +With the possible exception of the following fields, the existing account is identical to the +account in the request: + +- `timestamp` +- `debits_pending` +- `debits_posted` +- `credits_pending` +- `credits_posted` + +To correctly [recover from application crashes](../../coding/reliable-transaction-submission.md), +many applications should handle `exists` exactly as [`created`](#created). + +#### `flags_are_mutually_exclusive` + +The account was not created. An account cannot be created with the specified combination of +[`Account.flags`](../account.md#flags). + +The following flags are mutually exclusive: + +- [`Account.flags.debits_must_not_exceed_credits`](../account.md#flagsdebits_must_not_exceed_credits) +- [`Account.flags.credits_must_not_exceed_debits`](../account.md#flagscredits_must_not_exceed_debits) + +#### `debits_pending_must_be_zero` + +The account was not created. [`Account.debits_pending`](../account.md#debits_pending) is nonzero, +but must be zero. + +An account's debits and credits are only modified by transfers. + +#### `debits_posted_must_be_zero` + +The account was not created. [`Account.debits_posted`](../account.md#debits_posted) is nonzero, but +must be zero. + +An account's debits and credits are only modified by transfers. + +#### `credits_pending_must_be_zero` + +The account was not created. [`Account.credits_pending`](../account.md#credits_pending) is nonzero, +but must be zero. + +An account's debits and credits are only modified by transfers. + +#### `credits_posted_must_be_zero` + +The account was not created. [`Account.credits_posted`](../account.md#credits_posted) is nonzero, +but must be zero. + +An account's debits and credits are only modified by transfers. + +#### `ledger_must_not_be_zero` + +The account was not created. [`Account.ledger`](../account.md#ledger) is zero, but must be nonzero. + +#### `code_must_not_be_zero` + +The account was not created. [`Account.code`](../account.md#code) is zero, but must be nonzero. + +#### `imported_event_timestamp_must_not_regress` + +This result only applies when [`Account.flags.imported`](../account.md#flagsimported) is set. + +The account was not created. The user-defined [`Account.timestamp`](../account.md#timestamp) +regressed, but it must be greater than the last timestamp assigned to any `Account` in the cluster and cannot be equal to the timestamp of any existing [`Transfer`](../transfer.md). + +## Client libraries + +For language-specific docs see: + +- [.NET library](/src/clients/dotnet/README.md#creating-accounts) +- [Java library](/src/clients/java/README.md#creating-accounts) +- [Go library](/src/clients/go/README.md#creating-accounts) +- [Node.js library](/src/clients/node/README.md#creating-accounts) +- [Python library](/src/clients/python/README.md#creating-accounts) + +## Internals + +If you're curious and want to learn more, you can find the source code for creating an account in +[src/state_machine.zig](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/state_machine.zig). +Search for `fn create_account(` and `fn execute(`. diff --git a/ocam/docs/reference/requests/create_transfers.md b/ocam/docs/reference/requests/create_transfers.md new file mode 100644 index 00000000..e06b234d --- /dev/null +++ b/ocam/docs/reference/requests/create_transfers.md @@ -0,0 +1,684 @@ +# `create_transfers` + +Create one or more [`Transfer`](../transfer.md)s. A successfully created transfer will modify the +amount fields of its [debit](../transfer.md#debit_account_id) and +[credit](../transfer.md#credit_account_id) accounts. + +## Event + +A batch of transfers to create. +See [`Transfer`](../transfer.md) for constraints. + +## Result + +An array containing the result for each transfer in the events batch. + +### `timestamp` + +- For [successful transfers](#created), it is the [`timestamp`](../transfer.md#timestamp) + assigned to the transfer object. +- For [existing transfers](#exists), it is the [`timestamp`](../transfer.md#timestamp) + of the original object. +- For all other results, it indicates the time at which validation occurred. + +
+Client release < 0.17.0 + +Create results are sparse, containing only failed events and the `index` +of the transfer within the events batch. + +The network protocol does not include a [`created`](#created) result for successfully +created transfers. + +
+ +### `status` + +Status codes are listed in this section in order of descending precedence — that is, if more than +one error is applicable to the transfer being created, only the result listed first is returned. + +#### `created` + +The transfer was successfully created; did not previously exist. + +#### `linked_event_failed` + +The transfer was not created. One or more of the other transfers in the +[linked chain](../transfer.md#flagslinked) is invalid, so the whole chain failed. + +#### `linked_event_chain_open` + +The transfer was not created. The [`Transfer.flags.linked`](../transfer.md#flagslinked) flag was +set on the last event in the batch, which is not legal. (`flags.linked` indicates that the chain +continues to the next operation). + +#### `imported_event_expected` + +The transfer was not created. The [`Transfer.flags.imported`](../transfer.md#flagsimported) was +set on the first transfer of the batch, but not all transfers in the batch. +Batches cannot mix imported transfers with non-imported transfers. + +#### `imported_event_not_expected` + +The transfer was not created. The [`Transfer.flags.imported`](../transfer.md#flagsimported) was +expected to _not_ be set, as it's not allowed to mix transfers with different `imported` flag +in the same batch. The first transfer determines the entire operation. + +#### `timestamp_must_be_zero` + +This result only applies when [`Account.flags.imported`](../account.md#flagsimported) is _not_ set. + +The transfer was not created. The [`Transfer.timestamp`](../transfer.md#timestamp) is nonzero, but +must be zero. The cluster is responsible for setting this field. + +The [`Transfer.timestamp`](../transfer.md#timestamp) can only be assigned when creating transfers +with [`Transfer.flags.imported`](../transfer.md#flagsimported) set. + +#### `imported_event_timestamp_out_of_range` + +This result only applies when [`Transfer.flags.imported`](../transfer.md#flagsimported) is set. + +The transfer was not created. The [`Transfer.timestamp`](../transfer.md#timestamp) is out of range, +but must be a user-defined timestamp greater than `0` and less than `2^63`. + +#### `imported_event_timestamp_must_not_advance` + +This result only applies when [`Transfer.flags.imported`](../transfer.md#flagsimported) is set. + +The transfer was not created. The user-defined [`Transfer.timestamp`](../transfer.md#timestamp) is +greater than the current [cluster time](../../coding/time.md), but it must be a past timestamp. + +#### `reserved_flag` + +The transfer was not created. `Transfer.flags.reserved` is nonzero, but must be zero. + +#### `id_must_not_be_zero` + +The transfer was not created. [`Transfer.id`](../transfer.md#id) is zero, which is a reserved value. + +#### `id_must_not_be_int_max` + +The transfer was not created. [`Transfer.id`](../transfer.md#id) is `2^128 - 1`, which is a reserved +value. + +#### `exists_with_different_flags` + +A transfer with the same `id` already exists, but with different [`flags`](../transfer.md#flags). + +#### `exists_with_different_pending_id` + +A transfer with the same `id` already exists, but with a different +[`pending_id`](../transfer.md#pending_id). + +#### `exists_with_different_timeout` + +A transfer with the same `id` already exists, but with a different +[`timeout`](../transfer.md#timeout). + +#### `exists_with_different_debit_account_id` + +A transfer with the same `id` already exists, but with a different +[`debit_account_id`](../transfer.md#debit_account_id). + +#### `exists_with_different_credit_account_id` + +A transfer with the same `id` already exists, but with a different +[`credit_account_id`](../transfer.md#credit_account_id). + +#### `exists_with_different_amount` + +A transfer with the same `id` already exists, but with a different +[`amount`](../transfer.md#amount). + +If the transfer has [`flags.balancing_debit`](../transfer.md#flagsbalancing_debit) or +[`flags.balancing_credit`](../transfer.md#flagsbalancing_credit) set, then the actual amount +transferred exceeds this failed transfer's `amount`. + +#### `exists_with_different_user_data_128` + +A transfer with the same `id` already exists, but with a different +[`user_data_128`](../transfer.md#user_data_128). + +#### `exists_with_different_user_data_64` + +A transfer with the same `id` already exists, but with a different +[`user_data_64`](../transfer.md#user_data_64). + +#### `exists_with_different_user_data_32` + +A transfer with the same `id` already exists, but with a different +[`user_data_32`](../transfer.md#user_data_32). + +#### `exists_with_different_ledger` + +A transfer with the same `id` already exists, but with a different [`ledger`](../transfer.md#ledger). + +#### `exists_with_different_code` + +A transfer with the same `id` already exists, but with a different [`code`](../transfer.md#code). + +#### `exists` + +A transfer with the same `id` already exists. + +If the transfer has [`flags.balancing_debit`](../transfer.md#flagsbalancing_debit) or +[`flags.balancing_credit`](../transfer.md#flagsbalancing_credit) set, then the existing +transfer may have a different [`amount`](../transfer.md#amount), limited to the maximum +`amount` of the transfer in the request. + +If the transfer has [`flags.post_pending_transfer`](../transfer.md#flagspost_pending_transfer) +set, then the existing transfer may have a different [`amount`](../transfer.md#amount): +- If the original posted amount was less than the pending amount, + then the transfer amount must be equal to the posted amount. +- Otherwise, the transfer amount must be greater than or equal to the pending amount. + +
+Client release < 0.16.0 + +If the transfer has [`flags.balancing_debit`](../transfer.md#flagsbalancing_debit) or +[`flags.balancing_credit`](../transfer.md#flagsbalancing_credit) set, then the existing +transfer may have a different [`amount`](../transfer.md#amount), limited to the maximum +`amount` of the transfer in the request. + +
+ +Otherwise, with the possible exception of the `timestamp` field, the existing transfer is identical +to the transfer in the request. + +To correctly [recover from application crashes](../../coding/reliable-transaction-submission.md), +many applications should handle `exists` exactly as [`created`](#created). + +#### `id_already_failed` + +The transfer was not created. A previous transfer with the same [`id`](../transfer.md#id) failed +due to one of the following _transient errors_: + +- [`debit_account_not_found`](#debit_account_not_found) +- [`credit_account_not_found`](#credit_account_not_found) +- [`pending_transfer_not_found`](#pending_transfer_not_found) +- [`exceeds_credits`](#exceeds_credits) +- [`exceeds_debits`](#exceeds_debits) +- [`debit_account_already_closed`](#debit_account_already_closed) +- [`credit_account_already_closed`](#credit_account_already_closed) + +Transient errors depend on the database state at a given point in time, and each attempt +is uniquely associated with the corresponding [`Transfer.id`](../transfer.md#id). +This behavior guarantees that retrying a transfer will not produce a different outcome +(either success or failure). + +Without this mechanism, a transfer that previously failed could succeed if retried when the +underlying state changes (e.g., the target account has sufficient credits). + +**Note:** The application should retry an event only if it was unable to acknowledge the last +response (e.g., due to an application restart) or because it is correcting a previously rejected +malformed request (e.g., due to an application bug). +If the application intends to submit the transfer again even after a transient error, it must +generate a new [idempotency id](../../coding/data-modeling.md#id). + +
+Client release < 0.16.4 + +The [`id`](../transfer.md#id) is never checked against failed transfers, regardless of the error. +Therefore, a transfer that failed due to a transient error could succeed if retried later. + +
+ +#### `flags_are_mutually_exclusive` + +The transfer was not created. A transfer cannot be created with the specified combination of +[`Transfer.flags`](../transfer.md#flags). + +Flag compatibility (✓ = compatible, ✗ = mutually exclusive): + +- [`flags.pending`](../transfer.md#flagspending) + - ✗ [`flags.post_pending_transfer`](../transfer.md#flagspost_pending_transfer) + - ✗ [`flags.void_pending_transfer`](../transfer.md#flagsvoid_pending_transfer) + - ✓ [`flags.balancing_debit`](../transfer.md#flagsbalancing_debit) + - ✓ [`flags.balancing_credit`](../transfer.md#flagsbalancing_credit) + - ✓ [`flags.closing_debit`](../transfer.md#flagsclosing_debit) + - ✓ [`flags.closing_credit`](../transfer.md#flagsclosing_credit) + - ✓ [`flags.imported`](../transfer.md#flagsimported) +- [`flags.post_pending_transfer`](../transfer.md#flagspost_pending_transfer) + - ✗ [`flags.pending`](../transfer.md#flagspending) + - ✗ [`flags.void_pending_transfer`](../transfer.md#flagsvoid_pending_transfer) + - ✗ [`flags.balancing_debit`](../transfer.md#flagsbalancing_debit) + - ✗ [`flags.balancing_credit`](../transfer.md#flagsbalancing_credit) + - ✗ [`flags.closing_debit`](../transfer.md#flagsclosing_debit) + - ✗ [`flags.closing_credit`](../transfer.md#flagsclosing_credit) + - ✓ [`flags.imported`](../transfer.md#flagsimported) +- [`flags.void_pending_transfer`](../transfer.md#flagsvoid_pending_transfer) + - ✗ [`flags.pending`](../transfer.md#flagspending) + - ✗ [`flags.post_pending_transfer`](../transfer.md#flagspost_pending_transfer) + - ✗ [`flags.balancing_debit`](../transfer.md#flagsbalancing_debit) + - ✗ [`flags.balancing_credit`](../transfer.md#flagsbalancing_credit) + - ✗ [`flags.closing_debit`](../transfer.md#flagsclosing_debit) + - ✗ [`flags.closing_credit`](../transfer.md#flagsclosing_credit) + - ✓ [`flags.imported`](../transfer.md#flagsimported) +- [`flags.balancing_debit`](../transfer.md#flagsbalancing_debit) + - ✓ [`flags.pending`](../transfer.md#flagspending) + - ✗ [`flags.void_pending_transfer`](../transfer.md#flagsvoid_pending_transfer) + - ✗ [`flags.post_pending_transfer`](../transfer.md#flagspost_pending_transfer) + - ✓ [`flags.balancing_credit`](../transfer.md#flagsbalancing_credit) + - ✓ [`flags.closing_debit`](../transfer.md#flagsclosing_debit) + - ✓ [`flags.closing_credit`](../transfer.md#flagsclosing_credit) + - ✓ [`flags.imported`](../transfer.md#flagsimported) +- [`flags.balancing_credit`](../transfer.md#flagsbalancing_credit) + - ✓ [`flags.pending`](../transfer.md#flagspending) + - ✗ [`flags.void_pending_transfer`](../transfer.md#flagsvoid_pending_transfer) + - ✗ [`flags.post_pending_transfer`](../transfer.md#flagspost_pending_transfer) + - ✓ [`flags.balancing_debit`](../transfer.md#flagsbalancing_debit) + - ✓ [`flags.closing_debit`](../transfer.md#flagsclosing_debit) + - ✓ [`flags.closing_credit`](../transfer.md#flagsclosing_credit) + - ✓ [`flags.imported`](../transfer.md#flagsimported) +- [`flags.closing_debit`](../transfer.md#flagsclosing_debit) + - ✓ [`flags.pending`](../transfer.md#flagspending) + - ✗ [`flags.post_pending_transfer`](../transfer.md#flagspost_pending_transfer) + - ✗ [`flags.void_pending_transfer`](../transfer.md#flagsvoid_pending_transfer) + - ✓ [`flags.balancing_debit`](../transfer.md#flagsbalancing_debit) + - ✓ [`flags.balancing_credit`](../transfer.md#flagsbalancing_credit) + - ✓ [`flags.closing_credit`](../transfer.md#flagsclosing_credit) + - ✓ [`flags.imported`](../transfer.md#flagsimported) +- [`flags.closing_credit`](../transfer.md#flagsclosing_credit) + - ✓ [`flags.pending`](../transfer.md#flagspending) + - ✗ [`flags.post_pending_transfer`](../transfer.md#flagspost_pending_transfer) + - ✗ [`flags.void_pending_transfer`](../transfer.md#flagsvoid_pending_transfer) + - ✓ [`flags.balancing_debit`](../transfer.md#flagsbalancing_debit) + - ✓ [`flags.balancing_credit`](../transfer.md#flagsbalancing_credit) + - ✓ [`flags.closing_debit`](../transfer.md#flagsclosing_debit) + - ✓ [`flags.imported`](../transfer.md#flagsimported) +- [`flags.imported`](../transfer.md#flagsimported) + - ✓ [`flags.pending`](../transfer.md#flagspending) + - ✓ [`flags.post_pending_transfer`](../transfer.md#flagspost_pending_transfer) + - ✓ [`flags.void_pending_transfer`](../transfer.md#flagsvoid_pending_transfer) + - ✓ [`flags.balancing_debit`](../transfer.md#flagsbalancing_debit) + - ✓ [`flags.balancing_credit`](../transfer.md#flagsbalancing_credit) + - ✓ [`flags.closing_debit`](../transfer.md#flagsclosing_debit) + - ✓ [`flags.closing_credit`](../transfer.md#flagsclosing_credit) + +#### `debit_account_id_must_not_be_zero` + +The transfer was not created. [`Transfer.debit_account_id`](../transfer.md#debit_account_id) is +zero, but must be a valid account id. + +#### `debit_account_id_must_not_be_int_max` + +The transfer was not created. [`Transfer.debit_account_id`](../transfer.md#debit_account_id) is +`2^128 - 1`, but must be a valid account id. + +#### `credit_account_id_must_not_be_zero` + +The transfer was not created. [`Transfer.credit_account_id`](../transfer.md#credit_account_id) is +zero, but must be a valid account id. + +#### `credit_account_id_must_not_be_int_max` + +The transfer was not created. [`Transfer.credit_account_id`](../transfer.md#credit_account_id) is +`2^128 - 1`, but must be a valid account id. + +#### `accounts_must_be_different` + +The transfer was not created. [`Transfer.debit_account_id`](../transfer.md#debit_account_id) and +[`Transfer.credit_account_id`](../transfer.md#credit_account_id) must not be equal. + +That is, an account cannot transfer money to itself. + +#### `pending_id_must_be_zero` + +The transfer was not created. Only post/void transfers can reference a pending transfer. + +Either: + +- [`Transfer.flags.post_pending_transfer`](../transfer.md#flagspost_pending_transfer) must be set, + or +- [`Transfer.flags.void_pending_transfer`](../transfer.md#flagsvoid_pending_transfer) must be set, + or +- [`Transfer.pending_id`](../transfer.md#pending_id) must be zero. + +#### `pending_id_must_not_be_zero` + +The transfer was not created. +[`Transfer.flags.post_pending_transfer`](../transfer.md#flagspost_pending_transfer) or +[`Transfer.flags.void_pending_transfer`](../transfer.md#flagsvoid_pending_transfer) is set, but +[`Transfer.pending_id`](../transfer.md#pending_id) is zero. A posting or voiding transfer must +reference a [`pending`](../transfer.md#flagspending) transfer. + +#### `pending_id_must_not_be_int_max` + +The transfer was not created. [`Transfer.pending_id`](../transfer.md#pending_id) is `2^128 - 1`, +which is a reserved value. + +#### `pending_id_must_be_different` + +The transfer was not created. [`Transfer.pending_id`](../transfer.md#pending_id) is set to the same +id as [`Transfer.id`](../transfer.md#id). Instead it should refer to a different (existing) +transfer. + +#### `timeout_reserved_for_pending_transfer` + +The transfer was not created. [`Transfer.timeout`](../transfer.md#timeout) is nonzero, but only +[pending](../transfer.md#flagspending) transfers have nonzero timeouts. + +#### `closing_transfer_must_be_pending` + +The transfer was not created. [`Transfer.flags.pending`](../transfer.md#flagspending) is not set, +but closing transfers must be two-phase pending transfers. + +If either [`Transfer.flags.closing_debit`](../transfer.md#flagsclosing_debit) or +[`Transfer.flags.closing_credit`](../transfer.md#flagsclosing_credit) is set, +[`Transfer.flags.pending`](../transfer.md#flagspending) must also be set. + +This ensures that closing transfers are reversible by +[voiding](../transfer.md#flagsvoid_pending_transfer) the pending transfer, and requires that the +reversal operation references the corresponding closing transfer, guarding against unexpected +interleaving of close/unclose operations. + +#### `amount_must_not_be_zero` + +**Deprecated**: This error code is only returned to clients prior to release `0.16.0`. +Since `0.16.0`, zero-amount transfers are permitted. + +
+Client release < 0.16.0 + +The transfer was not created. [`Transfer.amount`](../transfer.md#amount) is zero, but must be +nonzero. + +Every transfer must move value. Only posting and voiding transfer amounts may be zero — when zero, +they will move the full pending amount. + +
+ +#### `ledger_must_not_be_zero` + +The transfer was not created. [`Transfer.ledger`](../transfer.md#ledger) is zero, but must be +nonzero. + +#### `code_must_not_be_zero` + +The transfer was not created. [`Transfer.code`](../transfer.md#code) is zero, but must be nonzero. + +#### `debit_account_not_found` + +The transfer was not created. [`Transfer.debit_account_id`](../transfer.md#debit_account_id) must +refer to an existing `Account`. + +This is a [transient error](#id_already_failed). +The [`Transfer.id`](../transfer.md#id) associated with this particular attempt will always fail +upon retry, even if the underlying issue is resolved. +To succeed, a new [idempotency id](../../coding/data-modeling.md#id) must be submitted. + +#### `credit_account_not_found` + +The transfer was not created. [`Transfer.credit_account_id`](../transfer.md#credit_account_id) must +refer to an existing `Account`. + +This is a [transient error](#id_already_failed). +The [`Transfer.id`](../transfer.md#id) associated with this particular attempt will always fail +upon retry, even if the underlying issue is resolved. +To succeed, a new [idempotency id](../../coding/data-modeling.md#id) must be submitted. + +### `accounts_must_have_the_same_ledger` + +The transfer was not created. The accounts referred to by +[`Transfer.debit_account_id`](../transfer.md#debit_account_id) and +[`Transfer.credit_account_id`](../transfer.md#credit_account_id) must have an identical +[`ledger`](../account.md#ledger). + +[Currency exchange](../../coding/recipes/currency-exchange.md) is implemented with multiple +transfers. + +#### `transfer_must_have_the_same_ledger_as_accounts` + +The transfer was not created. The accounts referred to by +[`Transfer.debit_account_id`](../transfer.md#debit_account_id) and +[`Transfer.credit_account_id`](../transfer.md#credit_account_id) are equivalent, but differ from the +[`Transfer.ledger`](../transfer.md#ledger). + +#### `pending_transfer_not_found` + +The transfer was not created. The transfer referenced by +[`Transfer.pending_id`](../transfer.md#pending_id) does not exist. + +This is a [transient error](#id_already_failed). +The [`Transfer.id`](../transfer.md#id) associated with this particular attempt will always fail +upon retry, even if the underlying issue is resolved. +To succeed, a new [idempotency id](../../coding/data-modeling.md#id) must be submitted. + +#### `pending_transfer_not_pending` + +The transfer was not created. The transfer referenced by +[`Transfer.pending_id`](../transfer.md#pending_id) exists, but does not have +[`flags.pending`](../transfer.md#flagspending) set. + +#### `pending_transfer_has_different_debit_account_id` + +The transfer was not created. The transfer referenced by +[`Transfer.pending_id`](../transfer.md#pending_id) exists, but with a different +[`debit_account_id`](../transfer.md#debit_account_id). + +The post/void transfer's `debit_account_id` must either be `0` or identical to the pending +transfer's `debit_account_id`. + +#### `pending_transfer_has_different_credit_account_id` + +The transfer was not created. The transfer referenced by +[`Transfer.pending_id`](../transfer.md#pending_id) exists, but with a different +[`credit_account_id`](../transfer.md#credit_account_id). + +The post/void transfer's `credit_account_id` must either be `0` or identical to the pending +transfer's `credit_account_id`. + +#### `pending_transfer_has_different_ledger` + +The transfer was not created. The transfer referenced by +[`Transfer.pending_id`](../transfer.md#pending_id) exists, but with a different +[`ledger`](../transfer.md#ledger). + +The post/void transfer's `ledger` must either be `0` or identical to the pending transfer's +`ledger`. + +#### `pending_transfer_has_different_code` + +The transfer was not created. The transfer referenced by +[`Transfer.pending_id`](../transfer.md#pending_id) exists, but with a different +[`code`](../transfer.md#code). + +The post/void transfer's `code` must either be `0` or identical to the pending transfer's `code`. + +#### `exceeds_pending_transfer_amount` + +The transfer was not created. The transfer's [`amount`](../transfer.md#amount) exceeds the `amount` +of its [pending](../transfer.md#pending_id) transfer. + +#### `pending_transfer_has_different_amount` + +The transfer was not created. The transfer is attempting to +[void](../transfer.md#flagsvoid_pending_transfer) a pending transfer. The voiding transfer's +[`amount`](../transfer.md#amount) must be either `0` or exactly the `amount` of the pending +transfer. + +To partially void a transfer, create a [posting transfer](../transfer.md#flagspost_pending_transfer) +with an amount less than the pending transfer's `amount`. + +
+Client release < 0.16.0 + +To partially void a transfer, create a [posting transfer](../transfer.md#flagspost_pending_transfer) +with an amount between `0` and the pending transfer's `amount`. + +
+ +#### `pending_transfer_already_posted` + +The transfer was not created. The referenced [pending](../transfer.md#pending_id) transfer was +already posted by a [`post_pending_transfer`](../transfer.md#flagspost_pending_transfer). + +#### `pending_transfer_already_voided` + +The transfer was not created. The referenced [pending](../transfer.md#pending_id) transfer was +already voided by a [`void_pending_transfer`](../transfer.md#flagsvoid_pending_transfer). + +#### `pending_transfer_expired` + +The transfer was not created. The referenced [pending](../transfer.md#pending_id) transfer was +already voided because its [timeout](../transfer.md#timeout) has passed. + +#### `imported_event_timestamp_must_not_regress` + +This result only applies when [`Transfer.flags.imported`](../transfer.md#flagsimported) is set. + +The transfer was not created. The user-defined [`Transfer.timestamp`](../transfer.md#timestamp) +regressed, but it must be greater than the last timestamp assigned to any `Transfer` in the cluster and cannot be equal to the timestamp of any existing [`Account`](../account.md). + +#### `imported_event_timestamp_must_postdate_debit_account` + +This result only applies when [`Transfer.flags.imported`](../transfer.md#flagsimported) is set. + +The transfer was not created. [`Transfer.debit_account_id`](../transfer.md#debit_account_id) must +refer to an `Account` whose [`timestamp`](../account.md#timestamp) is less than the +[`Transfer.timestamp`](../transfer.md#timestamp). + +#### `imported_event_timestamp_must_postdate_credit_account` + +This result only applies when [`Transfer.flags.imported`](../transfer.md#flagsimported) is set. + +The transfer was not created. [`Transfer.credit_account_id`](../transfer.md#credit_account_id) must +refer to an `Account` whose [`timestamp`](../account.md#timestamp) is less than the +[`Transfer.timestamp`](../transfer.md#timestamp). + +#### `imported_event_timeout_must_be_zero` + +This result only applies when [`Transfer.flags.imported`](../transfer.md#flagsimported) is set. + +The transfer was not created. The [`Transfer.timeout`](../transfer.md#timeout) is nonzero, but +must be zero. + +It's possible to import [pending](../transfer.md#flagspending) transfers with a user-defined +timestamp, but since it's not driven by the cluster clock, it cannot define a timeout for +automatic expiration. +In those cases, the [two-phase post or rollback](../../coding/two-phase-transfers.md) must be +done manually. + +#### `debit_account_already_closed` + +The transfer was not created. [`Transfer.debit_account_id`](../transfer.md#debit_account_id) must +refer to an `Account` whose [`Account.flags.closed`](../account.md#flagsclosed) is not already set. + +This is a [transient error](#id_already_failed). +The [`Transfer.id`](../transfer.md#id) associated with this particular attempt will always fail +upon retry, even if the underlying issue is resolved. +To succeed, a new [idempotency id](../../coding/data-modeling.md#id) must be submitted. + +#### `credit_account_already_closed` + +The transfer was not created. [`Transfer.credit_account_id`](../transfer.md#credit_account_id) must +refer to an `Account` whose [`Account.flags.closed`](../account.md#flagsclosed) is not already set. + +This is a [transient error](#id_already_failed). +The [`Transfer.id`](../transfer.md#id) associated with this particular attempt will always fail +upon retry, even if the underlying issue is resolved. +To succeed, a new [idempotency id](../../coding/data-modeling.md#id) must be submitted. + +#### `overflows_debits_pending` + +The transfer was not created. `debit_account.debits_pending + transfer.amount` would overflow a +128-bit unsigned integer. + +#### `overflows_credits_pending` + +The transfer was not created. `credit_account.credits_pending + transfer.amount` would overflow a +128-bit unsigned integer. + +#### `overflows_debits_posted` + +The transfer was not created. `debit_account.debits_posted + transfer.amount` would overflow a +128-bit unsigned integer. + +#### `overflows_credits_posted` + +The transfer was not created. `debit_account.credits_posted + transfer.amount` would overflow a +128-bit unsigned integer. + +#### `overflows_debits` + +The transfer was not created. +`debit_account.debits_pending + debit_account.debits_posted + transfer.amount` would overflow a +128-bit unsigned integer. + +#### `overflows_credits` + +The transfer was not created. +`credit_account.credits_pending + credit_account.credits_posted + transfer.amount` would overflow a +128-bit unsigned integer. + +#### `overflows_timeout` + +The transfer was not created. `transfer.timestamp + (transfer.timeout * 1_000_000_000)` would +exceed `2^63`. + +[`Transfer.timeout`](../transfer.md#timeout) is converted to nanoseconds. + +This computation uses the [`Transfer.timestamp`](../transfer.md#timestamp) value assigned by the +replica, not the `0` value sent by the client. + +#### `exceeds_credits` + +The transfer was not created. + +The [debit account](../transfer.md#debit_account_id) has +[`flags.debits_must_not_exceed_credits`](../account.md#flagsdebits_must_not_exceed_credits) set, but +`debit_account.debits_pending + debit_account.debits_posted + transfer.amount` would exceed +`debit_account.credits_posted`. + +This is a [transient error](#id_already_failed). +The [`Transfer.id`](../transfer.md#id) associated with this particular attempt will always fail +upon retry, even if the underlying issue is resolved. +To succeed, a new [idempotency id](../../coding/data-modeling.md#id) must be submitted. + +
+Client release < 0.16.0 + +If [`flags.balancing_debit`](../transfer.md#flagsbalancing_debit) is set, then +`debit_account.debits_pending + debit_account.debits_posted + 1` would exceed +`debit_account.credits_posted`. + +
+ +#### `exceeds_debits` + +The transfer was not created. + +The [credit account](../transfer.md#credit_account_id) has +[`flags.credits_must_not_exceed_debits`](../account.md#flagscredits_must_not_exceed_debits) set, but +`credit_account.credits_pending + credit_account.credits_posted + transfer.amount` would exceed +`credit_account.debits_posted`. + +This is a [transient error](#id_already_failed). +The [`Transfer.id`](../transfer.md#id) associated with this particular attempt will always fail +upon retry, even if the underlying issue is resolved. +To succeed, a new [idempotency id](../../coding/data-modeling.md#id) must be submitted. + +
+Client release < 0.16.0 + +If [`flags.balancing_credit`](../transfer.md#flagsbalancing_credit) is set, then +`credit_account.credits_pending + credit_account.credits_posted + 1` would exceed +`credit_account.debits_posted`. + +
+ +## Client libraries + +For language-specific docs see: + +- [.NET library](/src/clients/dotnet/README.md#create-transfers) +- [Java library](/src/clients/java/README.md#create-transfers) +- [Go library](/src/clients/go/README.md#create-transfers) +- [Node.js library](/src/clients/node/README.md#create-transfers) +- [Python library](/src/clients/python/README.md#create-transfers) + +## Internals + +If you're curious and want to learn more, you can find the source code for creating a transfer in +[src/state_machine.zig](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/state_machine.zig). +Search for `fn create_transfer(` and `fn execute(`. diff --git a/ocam/docs/reference/requests/get_account_balances.md b/ocam/docs/reference/requests/get_account_balances.md new file mode 100644 index 00000000..b3cfbb59 --- /dev/null +++ b/ocam/docs/reference/requests/get_account_balances.md @@ -0,0 +1,39 @@ +# `get_account_balances` + +Fetch the historical [`AccountBalance`](../account-balance.md)s of a given [`Account`](../account.md). + +**Only accounts created with the [`history`](../account.md#flagshistory) flag set retain historical +balances.** This is off by default. + +- Each balance returned has a corresponding transfer with the same + [`timestamp`](../transfer.md#timestamp). See the + [`get_account_transfers`](get_account_transfers.md) operation for more details. + +- The amounts refer to the account balance recorded _after_ the transfer execution. + +- [Pending](../transfer.md#flagspending) balances automatically removed due to + [timeout](../transfer.md#timeout) expiration don't change historical balances. + +## Event + +The account filter. +See [`AccountFilter`](../account-filter.md) for constraints. + +## Result + +- If the account has the flag [`history`](../account.md#flagshistory) set and any matching + balances exist, return an array of [`AccountBalance`](../account-balance.md)s. +- If the account does not have the flag [`history`](../account.md#flagshistory) set, + return nothing. +- If no matching balances exist, return nothing. +- If any constraint is violated, return nothing. + +## Client libraries + +For language-specific docs see: + +* [.NET library](/src/clients/dotnet/README.md#get-account-balances) +* [Java library](/src/clients/java/README.md#get-account-balances) +* [Go library](/src/clients/go/README.md#get-account-balances) +* [Node.js library](/src/clients/node/README.md#get-account-balances) +* [Python library](/src/clients/python/README.md#get-account-balances) diff --git a/ocam/docs/reference/requests/get_account_transfers.md b/ocam/docs/reference/requests/get_account_transfers.md new file mode 100644 index 00000000..adad1939 --- /dev/null +++ b/ocam/docs/reference/requests/get_account_transfers.md @@ -0,0 +1,28 @@ +# `get_account_transfers` + +Fetch [`Transfer`](../transfer.md)s involving a given [`Account`](../account.md). + +## Event + +The account filter. +See [`AccountFilter`](../account-filter.md) for constraints. + +## Result + +- Return a (possibly empty) array of [`Transfer`](../transfer.md)s that match the filter. +- If any constraint is violated, return nothing. +- By default, `Transfer`s are sorted chronologically by `timestamp`. You can use the + [`reversed`](../account-filter.md#flagsreversed) to change this. +- The result is always limited in size. If there are more results, you need to page through them + using the `AccountFilter`'s [`timestamp_min`](../account-filter.md#timestamp_min) and/or + [`timestamp_max`](../account-filter.md#timestamp_max). + +## Client libraries + +For language-specific docs see: + +- [.NET library](/src/clients/dotnet/README.md#get-account-transfers) +- [Java library](/src/clients/java/README.md#get-account-transfers) +- [Go library](/src/clients/go/README.md#get-account-transfers) +- [Node.js library](/src/clients/node/README.md#get-account-transfers) +- [Python library](/src/clients/python/README.md#get-account-transfers) diff --git a/ocam/docs/reference/requests/lookup_accounts.md b/ocam/docs/reference/requests/lookup_accounts.md new file mode 100644 index 00000000..d00d6ef0 --- /dev/null +++ b/ocam/docs/reference/requests/lookup_accounts.md @@ -0,0 +1,40 @@ +# `lookup_accounts` + +Fetch one or more accounts by their `id`s. + +⚠️ Note that you **should not** check an account's balance using this request before creating a +transfer. That would not be atomic and the balance could change in between the check and the +transfer. Instead, set the +[`debits_must_not_exceed_credits`](../account.md#flagsdebits_must_not_exceed_credits) or +[`credits_must_not_exceed_debits`](../account.md#flagscredits_must_not_exceed_debits) flag on the +accounts to limit their account balances. More complex conditional transfers can be expressed using +[balance-conditional transfers](../../coding/recipes/balance-conditional-transfers.md). + +⚠️ It is not possible currently to look up more than a full batch (8189) of accounts atomically. +When issuing multiple `lookup_accounts` calls, it can happen that other operations will interleave +between the calls leading to read skew. Consider using the +[`history`](../account.md#flagshistory) flag to enable atomic lookups. + +## Event + +An [`id`](../account.md#id) belonging to a [`Account`](../account.md). + +## Result + +- If the account exists, return the [`Account`](../account.md). +- If the account does not exist, return nothing. + +## Client libraries + +For language-specific docs see: + +- [.NET library](/src/clients/dotnet/README.md#account-lookup) +- [Java library](/src/clients/java/README.md#account-lookup) +- [Go library](/src/clients/go/README.md#account-lookup) +- [Node.js library](/src/clients/node/README.md#account-lookup) + +## Internals + +If you're curious and want to learn more, you can find the source code for looking up an account in +[src/state_machine.zig](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/state_machine.zig). +Search for `fn execute_lookup_accounts(`. diff --git a/ocam/docs/reference/requests/lookup_transfers.md b/ocam/docs/reference/requests/lookup_transfers.md new file mode 100644 index 00000000..98ebc75d --- /dev/null +++ b/ocam/docs/reference/requests/lookup_transfers.md @@ -0,0 +1,28 @@ +# `lookup_transfers` + +Fetch one or more transfers by their `id`s. + +## Event + +An [`id`](../transfer.md#id) belonging to a [`Transfer`](../transfer.md). + +## Result + +- If the transfer exists, return the [`Transfer`](../transfer.md). +- If the transfer does not exist, return nothing. + +## Client libraries + +For language-specific docs see: + +* [.NET library](/src/clients/dotnet/README.md#transfer-lookup) +* [Java library](/src/clients/java/README.md#transfer-lookup) +* [Go library](/src/clients/go/README.md#transfer-lookup) +* [Node.js library](/src/clients/node/README.md#transfer-lookup) + +## Internals + +If you're curious and want to learn more, you can find the source code +for looking up a transfer in +[src/state_machine.zig](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/state_machine.zig). Search +for `fn execute_lookup_transfers(`. diff --git a/ocam/docs/reference/requests/query_accounts.md b/ocam/docs/reference/requests/query_accounts.md new file mode 100644 index 00000000..8099715f --- /dev/null +++ b/ocam/docs/reference/requests/query_accounts.md @@ -0,0 +1,33 @@ +# `query_accounts` + +Query [`Account`](../account.md)s by the intersection of some fields and by timestamp range. + +⚠️ It is not possible currently to query more than a full batch (8189) of accounts atomically. +When issuing multiple `query_accounts` calls, it can happen that other operations will interleave +between the calls leading to read skew. Consider using the +[`history`](../account.md#flagshistory) flag to enable atomic lookups. + +## Event + +The query filter. +See [`QueryFilter`](../query-filter.md) for constraints. + +## Result + +- Return a (possibly empty) array of [`Account`](../account.md)s that match the filter. +- If any constraint is violated, return nothing. +- By default, `Account`s are sorted chronologically by `timestamp`. You can use the + [`reversed`](../query-filter.md#flagsreversed) to change this. +- The result is always limited in size. If there are more results, you need to page through them + using the `QueryFilter`'s [`timestamp_min`](../query-filter.md#timestamp_min) and/or + [`timestamp_max`](../query-filter.md#timestamp_max). + +## Client libraries + +For language-specific docs see: + +- [.NET library](/src/clients/dotnet/README.md#query-accounts) +- [Java library](/src/clients/java/README.md#query-accounts) +- [Go library](/src/clients/go/README.md#query-accounts) +- [Node.js library](/src/clients/node/README.md#query-accounts) +- [Python library](/src/clients/python/README.md#query-accounts) diff --git a/ocam/docs/reference/requests/query_transfers.md b/ocam/docs/reference/requests/query_transfers.md new file mode 100644 index 00000000..efdc617e --- /dev/null +++ b/ocam/docs/reference/requests/query_transfers.md @@ -0,0 +1,28 @@ +# `query_transfers` + +Query [`Transfer`](../transfer.md)s by the intersection of some fields and by timestamp range. + +## Event + +The query filter. +See [`QueryFilter`](../query-filter.md) for constraints. + +## Result + +- Return a (possibly empty) array of [`Transfer`](../transfer.md)s that match the filter. +- If any constraint is violated, return nothing. +- By default, `Transfer`s are sorted chronologically by `timestamp`. You can use the + [`reversed`](../query-filter.md#flagsreversed) to change this. +- The result is always limited in size. If there are more results, you need to page through them + using the `QueryFilter`'s [`timestamp_min`](../query-filter.md#timestamp_min) and/or + [`timestamp_max`](../query-filter.md#timestamp_max). + +## Client libraries + +For language-specific docs see: + +- [.NET library](/src/clients/dotnet/README.md#query-transfers) +- [Java library](/src/clients/java/README.md#query-transfers) +- [Go library](/src/clients/go/README.md#query-transfers) +- [Node.js library](/src/clients/node/README.md#query-transfers) +- [Python library](/src/clients/python/README.md#query-transfers) diff --git a/ocam/docs/reference/sessions.md b/ocam/docs/reference/sessions.md new file mode 100644 index 00000000..cd578bd6 --- /dev/null +++ b/ocam/docs/reference/sessions.md @@ -0,0 +1,97 @@ +# Client Sessions + +A _client session_ is a sequence of [requests](../coding/requests/README.md) and replies sent between a +client and a cluster. + +A client session may have **at most one in-flight request** — i.e. at most one unique request on the +network for which a reply has not been received. This simplifies consistency and allows the cluster +to statically guarantee capacity in its incoming message queue. Additional requests from the +application are queued by the client, to be dequeued and sent when their preceding request receives +a reply. + +Similar to other databases, TigerBeetle has a [hard limit](#eviction) on the number of concurrent +client sessions. To maximize throughput, users are encouraged to minimize the number of concurrent +clients and [batch](../coding/requests.md#batching-events) as many events as possible per request. + +## Lifecycle + +A client session begins when a client registers itself with the cluster. + +- Each client session has a unique identifier ("client id") — an ephemeral random 128-bit id. +- The client sends a special "register" message which is committed by the cluster, at which point + the client is "registered" — once it receives the reply, it may begin sending requests. +- Client registration is handled automatically by the TigerBeetle client implementation when the + client is initialized, before it sends its first request. +- When a client restarts (for example, the application service running the TigerBeetle client is + restarted) it does not resume its old session — it starts a new session, with a new (random) + client id. + +A client session ends when either: + +- the client session is [evicted](#eviction), or +- the client terminates + +— whichever occurs first. + +## Eviction + +When a client session is registering and the number of active sessions in the cluster is already at +the cluster's concurrent client session +[limit](https://tigerbeetle.com/blog/2022-10-12-a-database-without-dynamic-memory) (`config.clients_max`, 64 +by default), an existing client session must be evicted to make space for the new session. + +- After a session is evicted by the cluster, no future requests from that session will ever execute. +- The evicted session is chosen as the session that committed a request the longest time ago. + +The cluster sends a message to notify the evicted session that it has ended. Typically the evicted +client is no longer active (already terminated), but if it is active, the eviction message causes it +to self-terminate, bubbling up to the application as an `session evicted` error. + +If active clients are terminating with `session evicted` errors, it most likely indicates that the +application is trying to run too many concurrent clients. For performance reasons, it is recommended +to [batch](../coding/requests/README.md#batching-events) as many events as possible into each request sent +by each client. + +## Retries + +A client session will automatically retry a request until either: + +- the client receives a corresponding reply from the cluster, or +- the client is terminated. + +Unlike most database or RPC clients: + +- the TigerBeetle client will never time out +- the TigerBeetle client has no retry limits +- the TigerBeetle client does not surface network errors + +With TigerBeetle's strict consistency model, surfacing these errors at the client/application level +would be misleading. An error would imply that a request did not execute, when that is not known: + +- A request delayed by the network could execute after its timeout. +- A reply delayed by the network could execute before its timeout. + +## Guarantees + +- A client session may have at most one in-flight [request](../coding/requests/README.md). +- A client session [reads its own writes](https://jepsen.io/consistency/models/read-your-writes), + meaning that read operations that happen after a given write operation will observe the effects of + the write. +- A client session observes writes in the order that they occur on the cluster. +- A client session observes [`debits_posted`](./account.md#debits_posted) and + [`credits_posted`](./account.md#credits_posted) as monotonically increasing. That is, a client + session will never see `credits_posted` or `debits_posted` decrease. +- A client session never observes uncommitted updates. +- A client session never observes a broken invariant (e.g. + [`flags.credits_must_not_exceed_debits`](./account.md#flagscredits_must_not_exceed_debits) or + [`flags.linked`](./transfer.md#flagslinked)). +- Multiple client sessions may receive replies out of order relative to one another. For example, if + two clients submit requests around the same time, the client whose request is committed first + might receive the reply later. +- A client session can consider a request executed when it receives a reply for the request. +- If a client session is terminated and restarts, it is guaranteed to see the effects of updates for + which the corresponding reply was received prior to termination. +- If a client session is terminated and restarts, it is _not_ guaranteed to see the effects of + updates for which the corresponding reply was _not_ received prior to the restart. Those updates + may occur at any point in the future, or never. Handling application crash recovery safely + requires [using `id`s to idempotently retry events](../coding/reliable-transaction-submission.md). diff --git a/ocam/docs/reference/transfer.md b/ocam/docs/reference/transfer.md new file mode 100644 index 00000000..d242c26f --- /dev/null +++ b/ocam/docs/reference/transfer.md @@ -0,0 +1,541 @@ +# `Transfer` + +A `transfer` is an immutable record of a financial transaction between two accounts. + +In TigerBeetle, financial transactions are called "transfers" instead of "transactions" because the +latter term is heavily overloaded in the context of databases. + +Note that transfers debit a single account and credit a single account on the same ledger. You can +compose these into more complex transactions using the methods described in +[Currency Exchange](../coding/recipes/currency-exchange.md) and +[Multi-Debit, Multi-Credit Transfers](../coding/recipes/multi-debit-credit-transfers.md). + +### Updates + +Transfers _cannot be modified_ after creation. + +If a detail of a transfer is incorrect and needs to be modified, this is done using +[correcting transfers](../coding/recipes/correcting-transfers.md). + +### Deletion + +Transfers _cannot be deleted_ after creation. + +If a transfer is made in error, its effects can be reversed using a +[correcting transfer](../coding/recipes/correcting-transfers.md). + +### Guarantees + +- Transfers are immutable. They are never modified once they are successfully created. +- There is at most one `Transfer` with a particular [`id`](#id). +- A [pending transfer](../coding/two-phase-transfers.md#reserve-funds-pending-transfer) resolves at + most once. +- Transfer [timeouts](#timeout) are deterministic, driven by the + [cluster's timestamp](../coding/time.md#why-tigerbeetle-manages-timestamps). + +## Modes + +Transfers can either be Single-Phase, where they are executed immediately, or Two-Phase, where they +are first put in a Pending state and then either Posted or Voided. For more details on the latter, +see the [Two-Phase Transfer guide](../coding/two-phase-transfers.md). + +Fields used by each mode of transfer: + +| Field | Single-Phase | Pending | Post-Pending | Void-Pending | +| ----------------------------- | ------------ | -------- | ------------ | ------------ | +| `id` | required | required | required | required | +| `debit_account_id` | required | required | optional | optional | +| `credit_account_id` | required | required | optional | optional | +| `amount` | required | required | required | optional | +| `pending_id` | none | none | required | required | +| `user_data_128` | optional | optional | optional | optional | +| `user_data_64` | optional | optional | optional | optional | +| `user_data_32` | optional | optional | optional | optional | +| `timeout` | none | optional¹| none | none | +| `ledger` | required | required | optional | optional | +| `code` | required | required | optional | optional | +| `flags.linked` | optional | optional | optional | optional | +| `flags.pending` | false | true | false | false | +| `flags.post_pending_transfer` | false | false | true | false | +| `flags.void_pending_transfer` | false | false | false | true | +| `flags.balancing_debit` | optional | optional | false | false | +| `flags.balancing_credit` | optional | optional | false | false | +| `flags.closing_debit` | optional | true | false | false | +| `flags.closing_credit` | optional | true | false | false | +| `flags.imported` | optional | optional | optional | optional | +| `timestamp` | none² | none² | none² | none² | + +> _¹ None if `flags.imported` is set._
+ _² Required if `flags.imported` is set._ + +## Fields + +### `id` + +This is a unique identifier for the transaction. + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) +- Must not be zero or `2^128 - 1` +- Must not conflict with another transfer in the cluster + +See the [`id` section in the data modeling doc](../coding/data-modeling.md#id) for more +recommendations on choosing an ID scheme. + +Note that transfer IDs are unique for the cluster -- not the ledger. If you want to store a +relationship between multiple transfers, such as indicating that multiple transfers on different +ledgers were part of a single transaction, you should store a transaction ID in one of the +[`user_data`](#user_data_128) fields. + +### `debit_account_id` + +This refers to the account to debit the transfer's [`amount`](#amount). + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) +- When `flags.post_pending_transfer` and `flags.void_pending_transfer` are _not_ set: + - Must match an existing account + - Must not be the same as `credit_account_id` +- When `flags.post_pending_transfer` or `flags.void_pending_transfer` are set: + - If `debit_account_id` is zero, it will be automatically set to the pending transfer's + `debit_account_id`. + - If `debit_account_id` is nonzero, it must match the corresponding pending transfer's + `debit_account_id`. +- When `flags.imported` is set: + - The matching account's [timestamp](account.md#timestamp) must be less than or equal to the + transfer's [timestamp](#timestamp). + +### `credit_account_id` + +This refers to the account to credit the transfer's [`amount`](#amount). + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) +- When `flags.post_pending_transfer` and `flags.void_pending_transfer` are _not_ set: + - Must match an existing account + - Must not be the same as `debit_account_id` +- When `flags.post_pending_transfer` or `flags.void_pending_transfer` are set: + - If `credit_account_id` is zero, it will be automatically set to the pending transfer's + `credit_account_id`. + - If `credit_account_id` is nonzero, it must match the corresponding pending transfer's + `credit_account_id`. +- When `flags.imported` is set: + - The matching account's [timestamp](account.md#timestamp) must be less than or equal to the + transfer's [timestamp](#timestamp). + +### `amount` + +This is how much should be debited from the `debit_account_id` account and credited to the +`credit_account_id` account. + +Note that this is an unsigned 128-bit integer. You can read more about using +[debits and credits](../coding/data-modeling.md#debits-vs-credits) to represent positive and +negative balances as well as +[fractional amounts and asset scales](../coding/data-modeling.md#fractional-amounts-and-asset-scale). + +- When `flags.balancing_debit` is set, this is the maximum amount that will be debited/credited, + where the actual transfer amount is determined by the debit account's constraints. +- When `flags.balancing_credit` is set, this is the maximum amount that will be debited/credited, + where the actual transfer amount is determined by the credit account's constraints. +- When `flags.post_pending_transfer` is set, the amount posted will be: + - the pending transfer's amount, when the posted transfer's `amount` is `AMOUNT_MAX` + - the posting transfer's amount, when the posted transfer's `amount` is less than or equal to the + pending transfer's amount. + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) +- When `flags.void_pending_transfer` is set: + - If `amount` is zero, it will be automatically be set to the pending transfer's `amount`. + - If `amount` is nonzero, it must be equal to the pending transfer's `amount`. +- When `flags.post_pending_transfer` is set: + - If `amount` is `AMOUNT_MAX` (`2^128 - 1`), it will automatically be set to the pending + transfer's `amount`. + - If `amount` is not `AMOUNT_MAX`, it must be less than or equal to the pending transfer's + `amount`. + +
+Client release < 0.16.0 + +Additional constraints: + +- When `flags.post_pending_transfer` is set: + - If `amount` is zero, it will be automatically be set to the pending transfer's `amount`. + - If `amount` is nonzero, it must be less than or equal to the pending transfer's `amount`. +- When `flags.balancing_debit` and/or `flags.balancing_credit` is set, if `amount` is zero, it will + automatically be set to the maximum amount that does not violate the corresponding account limits. + (Equivalent to setting `amount = 2^128 - 1`). +- When all of the following flags are not set, `amount` must be nonzero: + - `flags.post_pending_transfer` + - `flags.void_pending_transfer` + - `flags.balancing_debit` + - `flags.balancing_credit` + +
+ +#### Examples + +- For representing fractional amounts (e.g. `$12.34`), see + [Fractional Amounts](../coding/data-modeling.md#fractional-amounts-and-asset-scale). +- For balancing transfers, see [Close Account](../coding/recipes/close-account.md). + +### `pending_id` + +If this transfer will post or void a pending transfer, `pending_id` references that pending +transfer. If this is not a post or void transfer, it must be zero. + +See the section on [Two-Phase Transfers](../coding/two-phase-transfers.md) for more information on +how the `pending_id` is used. + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) +- Must be zero if neither void nor pending transfer flag is set +- Must match an existing transfer's [`id`](#id) if non-zero + +### `user_data_128` + +This is an optional 128-bit secondary identifier to link this transfer to an external entity or +event. + +When set to zero, no secondary identifier will be associated with the transfer, therefore only +non-zero values can be used as [query filter](./query-filter.md). + +When set to zero, if +[`flags.post_pending_transfer`](#flagspost_pending_transfer) or +[`flags.void_pending_transfer`](#flagsvoid_pending_transfer) is set, then +it will be automatically set to the pending transfer's `user_data_128`. + +As an example, you might generate a +[TigerBeetle Time-Based Identifier](../coding/data-modeling.md#tigerbeetle-time-based-identifiers-recommended) +that ties together a group of transfers. + +For more information, see [Data Modeling](../coding/data-modeling.md#user_data). + +Constraints: + +- Type is 128-bit unsigned integer (16 bytes) + +### `user_data_64` + +This is an optional 64-bit secondary identifier to link this transfer to an external entity or +event. + +When set to zero, no secondary identifier will be associated with the transfer, therefore only +non-zero values can be used as [query filter](./query-filter.md). + +When set to zero, if +[`flags.post_pending_transfer`](#flagspost_pending_transfer) or +[`flags.void_pending_transfer`](#flagsvoid_pending_transfer) is set, then +it will be automatically set to the pending transfer's `user_data_64`. + +As an example, you might use this field store an external timestamp. + +For more information, see [Data Modeling](../coding/data-modeling.md#user_data). + +Constraints: + +- Type is 64-bit unsigned integer (8 bytes) + +### `user_data_32` + +This is an optional 32-bit secondary identifier to link this transfer to an external entity or +event. + +When set to zero, no secondary identifier will be associated with the transfer, therefore only +non-zero values can be used as [query filter](./query-filter.md). + +When set to zero, if +[`flags.post_pending_transfer`](#flagspost_pending_transfer) or +[`flags.void_pending_transfer`](#flagsvoid_pending_transfer) is set, then +it will be automatically set to the pending transfer's `user_data_32`. + +As an example, you might use this field to store a timezone or locale. + +For more information, see [Data Modeling](../coding/data-modeling.md#user_data). + +Constraints: + +- Type is 32-bit unsigned integer (4 bytes) + +### `timeout` + +This is the interval in seconds after a [`pending`](#flagspending) transfer's +[arrival at the cluster](#timestamp) that it may be [posted](#flagspost_pending_transfer) or +[voided](#flagsvoid_pending_transfer). Zero denotes absence of timeout. + +Non-pending transfers cannot have a timeout. + +Imported transfers cannot have a timeout. + +TigerBeetle makes a best-effort approach to remove pending balances of expired transfers +automatically: + +- Transfers expire _exactly_ at their expiry time ([`timestamp`](#timestamp) _plus_ `timeout` + converted in nanoseconds). + +- Pending balances will never be removed before its expiry. + +- Expired transfers cannot be manually posted or voided. + +- It is not guaranteed that the pending balance will be removed exactly at its expiry. + + In particular, client requests may observe still-pending balances for expired transfers. + +- Pending balances are removed in chronological order by expiry. If multiple transfers expire at the + same time, then ordered by the transfer's creation [`timestamp`](#timestamp). + + If a transfer `A` has expiry `E₁` and transfer `B` has expiry `E₂`, and `E₁ +Client release < 0.16.0 + +Transfer at most [`amount`](#amount) — automatically transferring less than `amount` as necessary +such that +`debit_account.debits_pending + debit_account.debits_posted ≤ debit_account.credits_posted`. If +`amount` is set to `0`, transfer at most `2^64 - 1` (i.e. as much as possible). + +If the highest amount transferable is `0`, returns +[`exceeds_credits`](./requests/create_transfers.md#exceeds_credits). + + + +##### Examples + +- [Close Account](../coding/recipes/close-account.md) + +#### `flags.balancing_credit` + +Transfer at most [`amount`](#amount) — automatically transferring less than `amount` as necessary +such that +`credit_account.credits_pending + credit_account.credits_posted ≤ credit_account.debits_posted`. + +The `amount` of the recorded transfer is set to the actual amount that was transferred, which is +less than or equal to the amount that was passed to `create_transfers`. + +Retrying a balancing transfer will return +[`exists_with_different_amount`](./requests/create_transfers.md#exists_with_different_amount) +only when the maximum amount passed to `create_transfers` is insufficient to fulfill the amount +that was actually transferred. +Otherwise it may return [`exists`](./requests/create_transfers.md#exists) even if the retry amount +differs from the original value. + +`flags.balancing_credit` is exclusive with the +`flags.post_pending_transfer`/`flags.void_pending_transfer` flags because posting or voiding a +pending transfer will never exceed/overflow either account's limits. + +`flags.balancing_credit` is compatible with (and orthogonal to) `flags.balancing_debit`. + +
+Client release < 0.16.0 + +Transfer at most [`amount`](#amount) — automatically transferring less than `amount` as necessary +such that +`credit_account.credits_pending + credit_account.credits_posted ≤ credit_account.debits_posted`. If +`amount` is set to `0`, transfer at most `2^64 - 1` (i.e. as much as possible). + +If the highest amount transferable is `0`, returns +[`exceeds_debits`](./requests/create_transfers.md#exceeds_debits). + +
+ +##### Examples + +- [Close Account](../coding/recipes/close-account.md) + +#### `flags.closing_debit` + +When set, it will cause the [`Account.flags.closed`](account.md#flagsclosed) flag +of the [debit account](#debit_account_id) to be set if the transfer succeeds. + +This flag requires a [two-phase transfer](#modes), so the flag [`flags.pending`](#flagspending) +must also be set. This ensures that closing transfers are reversible by +[voiding](#flagsvoid_pending_transfer) the pending transfer, and requires that the reversal +operation references the corresponding closing transfer, guarding against unexpected interleaving +of close/unclose operations. + +#### `flags.closing_credit` + +When set, it will cause the [`Account.flags.closed`](account.md#flagsclosed) flag +of the [credit account](#credit_account_id) to be set if the transfer succeeds. + +This flag requires a [two-phase transfer](#modes), so the flag [`flags.pending`](#flagspending) +must also be set. This ensures that closing transfers are reversible by +[voiding](#flagsvoid_pending_transfer) the pending transfer, and requires that the reversal +operation references the corresponding closing transfer, guarding against unexpected interleaving +of close/unclose operations. + +#### `flags.imported` + +When set, allows importing historical `Transfer`s with their original [`timestamp`](#timestamp). + +TigerBeetle will not use the [cluster clock](../coding/time.md) to assign the timestamp, allowing +the user to define it, expressing _when_ the transfer was effectively created by an external +event. + +To maintain system invariants regarding auditability and traceability, some constraints are +necessary: + +- It is not allowed to mix events with the `imported` flag set and _not_ set in the same batch. + The application must submit batches of imported events separately. + +- User-defined timestamps must be **unique** and expressed as nanoseconds since the UNIX epoch. + No two objects can have the same timestamp, even different objects like an `Account` and a `Transfer` cannot share the same timestamp. + +- User-defined timestamps must be a past date, never ahead of the cluster clock at the time the + request arrives. + +- Timestamps must be strictly increasing. + + Even user-defined timestamps that are required to be past dates need to be at least one + nanosecond ahead of the timestamp of the last transfer committed by the cluster. + + Since the timestamp cannot regress, importing past events can be naturally restrictive without + coordination, as the last timestamp can be updated using the cluster clock during regular + cluster activity. Instead, it's recommended to import events only on a fresh cluster or + during a scheduled maintenance window. + + It's recommended to submit the entire batch as a [linked chain](#flagslinked), ensuring that + if any transfer fails, none of them are committed, preserving the last timestamp unchanged. + This approach gives the application a chance to correct failed imported transfers, re-submitting + the batch again with the same user-defined timestamps. + +- Imported transfers cannot have a [`timeout`](#timeout). + + It's possible to import [pending](#flagspending) transfers with a user-defined timestamp, + but since it's not driven by the cluster clock, it cannot define a + [`timeout`](#timeout) for automatic expiration. + In those cases, the [two-phase post or rollback](../coding/two-phase-transfers.md) must be + done manually. + +### `timestamp` + +This is the time the transfer was created, as nanoseconds since UNIX epoch. +You can read more about [Time in TigerBeetle](../coding/time.md). + +Constraints: + +- Type is 64-bit unsigned integer (8 bytes) +- Must be `0` when the `Transfer` is created with [`flags.imported`](#flagsimported) _not_ set + + It is set by TigerBeetle to the moment the transfer arrives at the cluster. + +- Must be greater than `0` and less than `2^63` when the `Transfer` is created with + [`flags.imported`](#flagsimported) set + +## Internals + +If you're curious and want to learn more, you can find the source code for this struct in +[src/tigerbeetle.zig](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/tigerbeetle.zig). +Search for `const Transfer = extern struct {`. + +You can find the source code for creating a transfer in +[src/state_machine.zig](https://github.com/tigerbeetle/tigerbeetle/blob/main/src/state_machine.zig). +Search for `fn create_transfer(`. diff --git a/ocam/docs/start.md b/ocam/docs/start.md new file mode 100644 index 00000000..6e4b85be --- /dev/null +++ b/ocam/docs/start.md @@ -0,0 +1,189 @@ +# Start + +TigerBeetle is a reliable, fast, and highly available database for financial accounting. It tracks +financial transactions or anything else that can be expressed as double-entry bookkeeping, providing +three orders of magnitude more performance and guaranteeing durability even in the face of network, +machine, and storage faults. You will learn more about why this is an important and hard problem to +solve in the [Concepts](./concepts/) section, but first—let's make some real transactions! + +## Install + +TigerBeetle is a single, small, statically linked binary. + +You can download a pre-built binary from `tigerbeetle.com`: + +
+Linux + +```console +curl -Lo tigerbeetle.zip https://linux.tigerbeetle.com && unzip tigerbeetle.zip +./tigerbeetle version +``` +
+ +
+macOS + +```console +curl -Lo tigerbeetle.zip https://mac.tigerbeetle.com && unzip tigerbeetle.zip +./tigerbeetle version +``` +
+ +
+Windows + +```console +powershell -command "curl.exe -Lo tigerbeetle.zip https://windows.tigerbeetle.com; Expand-Archive tigerbeetle.zip ." +.\tigerbeetle version +``` +
+ +See [Installing](./operating/installing.md) for other options. + +## Run a Cluster + +Typically, TigerBeetle is deployed as a cluster of 6 replicas, which is described in the +[Operating](./operating/) section. It is also possible to run a single-replica cluster, which of +course doesn't provide high-availability, but is convenient for experimentation; that's what we'll +do here. + +First, format a data file: + +```console +./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 --development ./0_0.tigerbeetle +``` + +A TigerBeetle replica stores everything in a single file (`./0_0.tigerbeetle` in this case). +The `--cluster`, `--replica`, and `--replica-count` arguments set the topology of the cluster (a +single replica for this tutorial). + +Now, start a replica: + +```console +./tigerbeetle start --addresses=3000 --development ./0_0.tigerbeetle +``` + +It will listen on port 3000 for connections from clients. There's intentionally no way to gracefully +shut down a replica. You can `^C` it freely, and the data will be safe as long as the underlying +storage functions correctly. Note that with a real cluster of 6 replicas, the data is safe even if +the storage misbehaves. + +## Connecting to a Cluster + +Now that the cluster is running, we can connect to it using a client. TigerBeetle has +clients for several popular programming languages, including [Python](https://docs.tigerbeetle.com/coding/clients/python/), [Java](https://docs.tigerbeetle.com/coding/clients/java/), [Node.js](https://docs.tigerbeetle.com/coding/clients/node/), [.Net](https://docs.tigerbeetle.com/coding/clients/dotnet/), and [Go](https://docs.tigerbeetle.com/coding/clients/go/), and more +are coming; see the [Coding](./coding) section for details. For this tutorial, we'll keep it simple +and connect to the cluster using the built-in CLI client. In a separate terminal, start a REPL with: + +```console +./tigerbeetle repl --cluster=0 --addresses=3000 +``` + +The `--addresses` argument is the port the server is listening on. The `--cluster` argument is +required to double-check that the client connects to the correct cluster. While not strictly +necessary, it helps prevent operator errors. + +## Issuing Transactions + +TigerBeetle comes with a pre-defined database schema --- double-entry bookkeeping. The [Concept](./concepts) +section explains why this particular schema, and the [Reference](./reference) documents all the bells and +whistles. For the purposes of this tutorial, it is enough to understand that there are accounts +holding `credits` and `debits` balances, and that each transfer moves value between two accounts by +incrementing `credits` on one side and `debits` on the other. + +In the REPL, let's create two empty accounts: + +```console +> create_accounts id=1 code=10 ledger=700, id=2 code=10 ledger=700; +> lookup_accounts id=1, id=2; +``` + +```json +{ + "id": "1", + "user_data": "0", + "ledger": "700", + "code": "10", + "flags": [], + "debits_pending": "0", + "debits_posted": "0", + "credits_pending": "0", + "credits_posted": "0" +} +{ + "id": "2", + "user_data": "0", + "ledger": "700", + "code": "10", + "flags": "", + "debits_pending": "0", + "debits_posted": "0", + "credits_pending": "0", + "credits_posted": "0" +} +``` + +Now, create our first transfer and inspect the state of accounts afterwards: + +```console +> create_transfers id=1 debit_account_id=1 credit_account_id=2 amount=10 ledger=700 code=10; +> lookup_accounts id=1, id=2; +``` + +```json +{ + "id": "1", + "user_data": "0", + "ledger": "700", + "code": "10", + "flags": [], + "debits_pending": "0", + "debits_posted": "10", + "credits_pending": "0", + "credits_posted": "0" +} +{ + "id": "2", + "user_data": "0", + "ledger": "700", + "code": "10", + "flags": "", + "debits_pending": "0", + "debits_posted": "0", + "credits_pending": "0", + "credits_posted": "10" +} +``` + +Note how the transfer amount is added to both the credits and debits. That the sum of debits +and credits stays equal, no matter what, is a powerful invariant of a double-entry bookkeeping +system. + +## Conclusion + +This is the end of the quick start! You now know how to format a data file, run a single-replica +TigerBeetle cluster, and run transactions through it. Here's where to go from here: + +* [Concepts](./concepts/) explains the "why?" of TigerBeetle; read this to decide if TigerBeetle + matches the shape of your problem. +* [Coding](./coding/) gives guidance on developing applications which store transactions in a + TigerBeetle cluster. +* [Operating](./operating/) explains how to deploy a TigerBeetle cluster in a highly-available + manner, with replication enabled. +* [Reference](./reference/) documents every available feature and flag of the + underlying data model. + +## Community + +If you want to keep up to speed with recent TigerBeetle developments: + +- [Monthly Newsletter](https://tigerbeetle.com/newsletter) covers everything + of importance that happened with TigerBeetle. It is a changelog director's cut! +- [YouTube](https://www.youtube.com/@tigerbeetledb) channel has most of the talks about TigerBeetle, + as well as talks from the Systems Distributed conference. We also stream on + [Twitch](https://www.twitch.tv/tigerbeetle), with recordings duplicated to YouTube. +- [𝕏](https://twitter.com/TigerBeetleDB) is good for smaller updates, and word-of-mouth historical + trivia you won't learn elsewhere! Or [Bluesky](https://bsky.app/profile/tigerbeetle.com), if that's + your preference. +- [GitHub](https://github.com/tigerbeetle/tigerbeetle) to stay close to the source! diff --git a/ocam/src/aof.zig b/ocam/src/aof.zig new file mode 100644 index 00000000..6dd50177 --- /dev/null +++ b/ocam/src/aof.zig @@ -0,0 +1,1005 @@ +//! Reconstruct a cluster from one or more AOF files. +//! +//! Note that a AOF-recovered cluster is *not* physically identical to the original cluster. +//! It should be logically identical though -- the same data (minus the client table), just in +//! different places. +const std = @import("std"); +const assert = std.debug.assert; + +const constants = @import("constants.zig"); +const vsr = @import("vsr.zig"); +const tb = vsr.tigerbeetle; + +const stdx = @import("stdx"); +const MessagePool = vsr.message_pool.MessagePool; +const Message = MessagePool.Message; +const MessageBus = vsr.message_bus.MessageBusType(vsr.io.IO); +const Header = vsr.Header; + +const MiB = stdx.MiB; + +const log = std.log.scoped(.aof); + +pub const std_options: std.Options = .{ + .log_level = .info, + .logFn = stdx.log_with_timestamp, +}; + +const magic_number: u128 = 0xbcd8d3fee406119ed192c4f4c4fc82; + +pub const AOFEntry = extern struct { + /// In case of extreme corruption, start each entry with a fixed random integer, + /// to allow skipping over corrupted entries. + magic_number: u128 = magic_number, + + /// The main Message to log. + /// This is written _without_ O_DIRECT, so sector alignment is not a concern. + message: [constants.message_size_max]u8 align(@sizeOf(u128)), + + comptime { + assert(stdx.no_padding(AOFEntry)); + + // Ensure the message is the last field in the struct. When writing, the struct is truncated + // based on the message length, so any fields after it would be truncated. + assert(std.meta.fieldIndex(AOFEntry, "message").? == std.meta.fields(AOFEntry).len - 1); + } + + /// Calculate the actual length of the AOFEntry that needs to be written to disk. + pub fn size_disk(self: *AOFEntry) u64 { + return @sizeOf(AOFEntry) - self.message.len + self.header().size; + } + + /// The minimum size of an AOFEntry is when `message` is a Header with no body. + pub fn size_minimum(self: *AOFEntry) u64 { + return @sizeOf(AOFEntry) - self.message.len + @sizeOf(Header); + } + + pub fn header(self: *AOFEntry) *Header.Prepare { + return @ptrCast(&self.message); + } + + /// Turn an AOFEntry back into a Message. + pub fn to_message(self: *AOFEntry, target: *Message.Prepare) void { + stdx.copy_disjoint(.inexact, u8, target.buffer, self.message[0..self.header().size]); + } + + pub fn from_message( + self: *AOFEntry, + message: *const Message.Prepare, + last_checksum: *?u128, + ) void { + assert(message.header.size <= self.message.len); + + // When writing, entries can backtrack / duplicate, so we don't necessarily have a valid + // chain. Still, log when that happens. The `aof merge` command can generate a consistent + // file from entries like these. + log.debug("from_message: parent {x:0>32} (should == {x:0>32}) our checksum {x:0>32}", .{ + message.header.parent, + last_checksum.* orelse 0, + message.header.checksum, + }); + if (last_checksum.* == null or last_checksum.*.? != message.header.parent) { + log.info("from_message: parent {x:0>32}, expected {x:0>32} instead", .{ + message.header.parent, + last_checksum.* orelse 0, + }); + } + last_checksum.* = message.header.checksum; + + // The cluster identifier is in the VSR header so we don't need to store it explicitly. + // The replica that this was logged on will be the replica with this file. If uploaded to + // object storage, this must be embedded in the filename or path. + // Whether this replica is the primary can be determined by the view number from the + // relevant op. + comptime { + const fields = std.meta.fieldNames(AOFEntry); + assert(fields.len == 2); + assert(std.mem.eql(u8, fields[0], "magic_number")); + assert(std.mem.eql(u8, fields[1], "message")); + } + + // Using self.* = .{ .message = undefined } notation causes a `constants.message_size_max` + // increase in binary size, since Zig embeds the entire static initialization payload in the + // binary. + self.* = undefined; + self.magic_number = magic_number; + stdx.copy_disjoint( + .exact, + u8, + self.message[0..message.header.size], + message.buffer[0..message.header.size], + ); + @memset(self.message[message.header.size..self.message.len], 0); + } +}; + +/// The AOF itself is simple and deterministic - but it logs data like the client's id +/// which make things trickier. If you want to compare AOFs between runs, the `debug` +/// CLI command does it by hashing together all checksum_body, operation and timestamp +/// fields. +pub fn AOFType(comptime IO: type) type { + return struct { + const AOF = @This(); + + io: *IO, + path: []const u8, + fd: ?IO.fd_t = null, + last_checksum: ?u128 = null, + + state: union(enum) { + /// Store the number of unflushed entries - that is, calls to write() without + /// checkpoint() to ensure we don't ever buffer more than the WAL can hold. + writing: struct { unflushed: u64 }, + + /// Keep an opaque pointer to the replica to workaround AOF being ?*AOF in Replica, and + /// @fieldParentPtr being cumbersome with that. + checkpoint: struct { + replica: *anyopaque, + replica_callback: *const fn (*anyopaque) void, + fsync_completion: IO.Completion, + }, + } = .{ .writing = .{ .unflushed = 0 } }, + size: usize = 0, + + /// Create an AOF in the dir_fd when given a file name. dir_fd must be opened read write + /// (except on Windows). This ensures everything (including the dir) is fsync'd + /// appropriately. Closing dir_fd is the responsibility of the caller, which can be done + /// immediately after .init() finishes. + pub fn init( + io: *IO, + path: []const u8, + ) !AOF { + stdx.maybe(std.fs.path.isAbsolute(path)); + assert(std.mem.endsWith(u8, path, ".aof")); + + return AOF{ + .io = io, + .path = path, + .fd = try io.aof_blocking_open(path), + }; + } + + pub fn close(self: *AOF) void { + assert(self.fd != null); + + self.io.aof_blocking_close(self.fd.?); + self.fd = null; + } + + /// Write a message to disk, with standard blocking IO but using the OS's page cache. The + /// AOF borrows durability from the write ahead log: if the AOF hasn't been flushed, and the + /// machine loses power, the op is guaranteed to still be in the WAL. + pub fn write(self: *AOF, message: *const Message.Prepare) !void { + assert(self.state == .writing); + assert(self.state.writing.unflushed < constants.journal_slot_count); + + var entry: AOFEntry align(constants.sector_size) = undefined; + entry.from_message( + message, + &self.last_checksum, + ); + + const size_disk = entry.size_disk(); + const bytes = std.mem.asBytes(&entry); + + try self.io.aof_blocking_write_all(self.fd.?, bytes[0..size_disk]); + + self.size += size_disk; + self.state.writing.unflushed += 1; + } + + pub fn sync(self: *AOF) void { + assert(self.state == .writing); + assert(self.state.writing.unflushed <= constants.journal_slot_count); + self.state.writing.unflushed = 0; + } + + pub fn checkpoint( + self: *AOF, + replica: *anyopaque, + callback: *const fn (*anyopaque) void, + ) void { + assert(self.state == .writing); + assert(self.state.writing.unflushed <= constants.journal_slot_count); + + self.state = .{ + .checkpoint = .{ + .replica = replica, + .fsync_completion = undefined, + .replica_callback = callback, + }, + }; + + self.io.fsync( + *AOF, + self, + on_fsync, + &self.state.checkpoint.fsync_completion, + self.fd.?, + ); + } + + fn on_fsync(self: *AOF, completion: *IO.Completion, result: IO.FsyncError!void) void { + _ = completion; + _ = result catch @panic("aof fsync failure"); + + assert(self.state == .checkpoint); + const replica = self.state.checkpoint.replica; + const replica_callback = self.state.checkpoint.replica_callback; + self.state = .{ .writing = .{ .unflushed = 0 } }; + + const stat_file = self.io.aof_blocking_stat(self.path) catch |err| switch (err) { + error.FileNotFound => blk: { + log.info("{s} not found; creating", .{self.path}); + self.close(); + assert(self.fd == null); + self.fd = self.io.aof_blocking_open(self.path) catch |e| { + std.debug.panic("failed to reopen {s} after rotate: {}", .{ self.path, e }); + }; + + break :blk self.io.aof_blocking_stat(self.path) catch |e| { + log.warn("failed to stat aof ({s}): {}", .{ self.path, e }); + break :blk null; + }; + }, + else => blk: { + log.warn("failed to stat aof ({s}): {}", .{ self.path, err }); + break :blk null; + }, + }; + + const stat_fd = self.io.aof_blocking_fstat(self.fd.?) catch |err| blk: { + log.warn("failed to fstat aof ({s}): {}", .{ self.path, err }); + break :blk null; + }; + + // AOF change detection relies on detecting the file being removed, and *it* will + // recreate it. It is an error for the operator to try and create file externally + // (eg, touch tigerbeetle.aof). + // + // Warn the operator strongly if this happens. + if (stat_fd != null and stat_file != null and stat_fd.?.inode != stat_file.?.inode) { + log.err("AOF inode mismatch detected - the AOF file path is not the same as " ++ + "the open file descriptor being written to.", .{}); + log.err( + "Move {s} out the way, and let tigerbeetle recreate the AOF.", + .{self.path}, + ); + } + + replica_callback(replica); + } + + pub fn validate(self: *AOF, allocator: std.mem.Allocator, last_checksum: ?u128) !void { + var validation_target: AOFEntry = undefined; + + var validation_checksums = std.AutoHashMap(u128, void).init(allocator); + defer validation_checksums.deinit(); + + var it = Iterator{ + .file_descriptor = self.fd.?, + .io = self.io, + .size = self.size, + }; + + // The iterator only does simple chain validation, but we can have backtracking + // or duplicates, and still have a valid AOF. Handle this by keeping track of + // every checksum we've seen so far, and considering it OK as long as we've seen + // a parent. + it.validate_chain = false; + + var last_entry: ?*AOFEntry = null; + + while (try it.next(&validation_target)) |entry| { + const header = entry.header(); + + if (entry.header().op == 1) { + // For op=1, put its parent in our list of seen checksums too. + // This handles the case where it gets replayed, but we don't record + // op=0 so the assert below would fail. + // It's needed for simulator validation only (aof merge uses a + // different method to walk down AOF entries). + try validation_checksums.put(header.parent, {}); + } else { + // (Null due to state sync skipping commits.) + stdx.maybe(validation_checksums.get(header.parent) == null); + } + + try validation_checksums.put(header.checksum, {}); + + last_entry = entry; + } + + if (last_checksum) |checksum| { + if (last_entry.?.header().checksum != checksum) { + return error.ChecksumMismatch; + } + log.info("validated all aof entries. last entry checksum {x:0>32} matches " ++ + " supplied {x:0>32}", .{ last_entry.?.header().checksum, checksum }); + } else { + log.info("validated present aof entries.", .{}); + } + } + + pub fn reset(self: *AOF) void { + self.state = .{ .writing = .{ .unflushed = 0 } }; + } + + pub const ReplayClient = struct { + const Client = vsr.ClientType(tb.Operation, MessageBus); + + client: *Client, + io: *IO, + message_pool: *MessagePool, + inflight_message: ?*Message.Request = null, + + pub fn init( + io: *IO, + allocator: std.mem.Allocator, + time: vsr.time.Time, + cluster: u128, + addresses: []stdx.SocketAddress, + ) !ReplayClient { + assert(addresses.len > 0); + assert(addresses.len <= constants.replicas_max); + + var message_pool = try allocator.create(MessagePool); + errdefer allocator.destroy(message_pool); + + var client = try allocator.create(Client); + errdefer allocator.destroy(client); + + message_pool.* = try MessagePool.init(allocator, .client); + errdefer message_pool.deinit(allocator); + + client.* = try Client.init( + allocator, + time, + message_pool, + .{ + // Use a deterministic client id, so that replaying the same AOF against + // different new clusters yields physically identical data files. + // (It must be based on release so that clusters which have upgraded at some + // point will need a separate "aof recover" invocation for each release.) + .id = constants.config.process.release.value, + .cluster = cluster, + .replica_count = @intCast(addresses.len), + .aof_recovery = true, + .message_bus_options = .{ + .configuration = addresses, + .io = io, + .trace = null, + .time = time, + }, + }, + ); + errdefer client.deinit(allocator); + + client.register(register_callback, undefined); + while (client.request_inflight != null) { + client.tick(); + try io.run_for_ns(constants.tick_ms * std.time.ns_per_ms); + } + + return .{ + .io = io, + .message_pool = message_pool, + .client = client, + }; + } + + pub fn deinit(self: *ReplayClient, allocator: std.mem.Allocator) void { + self.client.deinit(allocator); + self.message_pool.deinit(allocator); + + allocator.destroy(self.client); + allocator.destroy(self.message_pool); + } + + pub fn replay(self: *ReplayClient, iterator: *Iterator) !void { + var target: AOFEntry = undefined; + + while (try iterator.next(&target)) |entry| { + // Skip replaying reserved messages and messages not marked for playback. + const header = entry.header(); + assert(header.cluster == self.client.cluster); + if (!ReplayClient.replay_message(header)) continue; + + const message = self.client.get_message().build(.request); + errdefer self.client.release_message(message.base()); + + assert(self.inflight_message == null); + self.inflight_message = message; + + entry.to_message(message.base().build(.prepare)); + + message.header.* = .{ + .client = self.client.id, + .cluster = self.client.cluster, + .command = .request, + .operation = header.operation, + .size = header.size, + .timestamp = header.timestamp, + .view = 0, + .parent = 0, + .session = 0, + .request = 0, + .release = header.release, + .previous_request_latency = 0, + }; + + self.client.raw_request( + ReplayClient.replay_callback, + @intFromPtr(self), + message, + ); + + // Process messages one by one for now + while (self.client.request_inflight != null) { + self.client.tick(); + try self.io.run_for_ns(constants.tick_ms * std.time.ns_per_ms); + } + } + } + + /// If a message should be replayed when recovering the AOF. This allows skipping over + /// things like lookup_ and queries, that have no affect on the final state, but take up + /// a lot of time when replaying. + pub fn replay_message(header: *Header.Prepare) bool { + if (header.operation.vsr_reserved()) return false; + const state_machine_operation = header.operation.cast(tb.Operation); + switch (state_machine_operation) { + .create_accounts, + .create_transfers, + .deprecated_create_accounts_sparse, + .deprecated_create_transfers_sparse, + .deprecated_create_accounts_unbatched, + .deprecated_create_transfers_unbatched, + => return true, + + // Pulses are replayed to handle pending transfer expiry. + .pulse => return true, + + else => return false, + } + } + + fn register_callback( + user_data: u128, + result: *const vsr.RegisterResult, + ) void { + _ = user_data; + _ = result; + } + + fn replay_callback( + user_data: u128, + operation: vsr.Operation, + timestamp: u64, + result: []align(constants.cache_line_size) const u8, + ) void { + _ = operation; + _ = timestamp; + _ = result; + + const self: *ReplayClient = @ptrFromInt(@as(usize, @intCast(user_data))); + assert(self.inflight_message != null); + self.inflight_message = null; + } + }; + + /// Return an iterator into an AOF, to read entries one by one. This also validates that + /// both the header and body checksums of the read entry are valid, and that all checksums + /// chain correctly. + pub const Iterator = struct { + io: *IO, + file_descriptor: IO.fd_t, + size: u64, + offset: u64 = 0, + + validate_chain: bool = true, + last_checksum: ?u128 = null, + + pub fn init(io: *IO, path: []const u8) !Iterator { + const file = try std.fs.cwd().openFile(path, .{ .mode = .read_only }); + errdefer file.close(); + + const size = (try file.stat()).size; + + return Iterator{ .io = io, .file_descriptor = file.handle, .size = size }; + } + + pub fn next(it: *Iterator, target: *AOFEntry) !?*AOFEntry { + if (it.offset >= it.size) return null; + + const buf = std.mem.asBytes(target); + const bytes_read = try it.io.aof_blocking_pread_all( + it.file_descriptor, + buf, + it.offset, + ); + + // size_disk relies on information that was stored on disk, so further verify we + // have read at least the minimum permissible. + if (bytes_read < target.size_minimum() or + bytes_read < target.size_disk()) + { + return error.AOFShortRead; + } + + if (target.magic_number != magic_number) { + return error.AOFMagicNumberMismatch; + } + + const header = target.header(); + if (!header.valid_checksum()) { + return error.AOFChecksumMismatch; + } + + if (!header.valid_checksum_body(target.message[@sizeOf(Header)..header.size])) { + return error.AOFBodyChecksumMismatch; + } + + // Ensure this file has a consistent hash chain + if (it.validate_chain) { + if (it.last_checksum != null and it.last_checksum.? != header.parent) { + return error.AOFChecksumChainMismatch; + } + } + + it.last_checksum = header.checksum; + + it.offset += target.size_disk(); + + return target; + } + + pub fn reset(it: *Iterator) !void { + it.offset = 0; + } + + pub fn close(it: *Iterator) void { + it.io.aof_blocking_close(it.file_descriptor); + } + + /// Try skip ahead to the next entry in a potentially corrupted AOF file + /// by searching from our current position for the next magic_number, seeking + /// to it, and setting our internal position correctly. + pub fn skip(it: *Iterator, allocator: std.mem.Allocator, count: usize) !void { + var skip_buffer = try allocator.alloc(u8, 1 * MiB); + defer allocator.free(skip_buffer); + + while (it.offset < it.size) { + const bytes_read = try it.io.aof_blocking_pread_all( + it.file_descriptor, + skip_buffer, + it.offset, + ); + const offset = std.mem.indexOfPos( + u8, + skip_buffer[0..bytes_read], + count, + std.mem.asBytes(&magic_number), + ); + + if (offset) |offset_bytes| { + it.offset += offset_bytes; + break; + } else { + it.offset += skip_buffer.len; + } + } + } + }; + + pub fn merge( + io: *IO, + allocator: std.mem.Allocator, + input_paths: []const []const u8, + output_path: []const u8, + ) !void { + const stdout = std.io.getStdOut().writer(); + + var aofs: [constants.members_max]Iterator = undefined; + var aof_count: usize = 0; + defer for (aofs[0..aof_count]) |*it| it.close(); + + assert(input_paths.len <= aofs.len); + + const EntryInfo = struct { + aof: *Iterator, + index: u64, + size: u64, + checksum: u128, + parent: u128, + }; + + var message_pool = try MessagePool.init_capacity(allocator, 1); + defer message_pool.deinit(allocator); + + var entries_by_parent = std.AutoHashMap(u128, EntryInfo).init(allocator); + defer entries_by_parent.deinit(); + + var target = try allocator.create(AOFEntry); + defer allocator.destroy(target); + + const dir_fd = try IO.open_dir(std.fs.path.dirname(output_path) orelse "."); + defer std.posix.close(dir_fd); + + for (input_paths) |input_path| { + aofs[aof_count] = try Iterator.init(io, input_path); + aof_count += 1; + } + assert(aof_count > 0); + assert(aof_count <= constants.members_max); + + var output_aof = try AOF.init(io, output_path); + + // First, iterate all AOFs and build a mapping between parent checksums and where the + // entry is located. + try stdout.print("Building checksum map...\n", .{}); + var current_parent: ?u128 = null; + for (aofs[0..aof_count], 0..) |*aof, i| { + // While building our checksum map, don't validate our hash chain. We might have a + // file that has a broken chain, but still contains valid data that can be used for + // recovery with other files. + aof.validate_chain = false; + + while (true) { + var entry = aof.next(target) catch |err| { + switch (err) { + // If our magic number is corrupted, skip to the next entry. + error.AOFMagicNumberMismatch => { + try stdout.print( + "{s}: Skipping entry with corrupted magic number.\n", + .{input_paths[i]}, + ); + try aof.skip(allocator, 0); + continue; + }, + + // Otherwise, we need to skip over our valid magic number, to the next + // one (since the pointer is only updated after a successful read, + // calling .skip(0)) will not do anything here. + error.AOFChecksumMismatch, error.AOFBodyChecksumMismatch => { + try stdout.print( + "{s}: Skipping entry with corrupted checksum.\n", + .{input_paths[i]}, + ); + try aof.skip(allocator, 1); + continue; + }, + + error.AOFShortRead => { + try stdout.print( + "{s}: Skipping truncated entry at EOF.\n", + .{input_paths[i]}, + ); + break; + }, + + else => @panic("Unexpected Error"), + } + break; + }; + + if (entry == null) { + break; + } + + const header = entry.?.header(); + const checksum = header.checksum; + const parent = header.parent; + + if (current_parent == null) { + try stdout.print( + "The root checksum will be {x:0>32} from {s}.\n", + .{ parent, input_paths[i] }, + ); + current_parent = parent; + } + + const v = try entries_by_parent.getOrPut(parent); + if (v.found_existing) { + // If the entry already exists in our mapping, and it's identical, that's + // OK. If it's not however, it indicates the log has been forked somehow. + assert(v.value_ptr.checksum == checksum); + } else { + v.value_ptr.* = .{ + .aof = aof, + .index = aof.offset - entry.?.size_disk(), + .size = entry.?.size_disk(), + .checksum = checksum, + .parent = parent, + }; + } + } + try stdout.print( + "Finished processing {s} - extracted {} usable entries.\n", + .{ input_paths[i], entries_by_parent.count() }, + ); + } + + // Next, start from our root checksum, walk down the hash chain until there's nothing + // left. We currently take the root checksum as the first entry in the first AOF. + while (entries_by_parent.count() > 0) { + const message = message_pool.get_message(.prepare); + defer message_pool.unref(message); + + assert(current_parent != null); + const entry = entries_by_parent.getPtr(current_parent.?) orelse unreachable; + + const buf = std.mem.asBytes(target)[0..entry.size]; + const bytes_read = try io.aof_blocking_pread_all( + entry.aof.file_descriptor, + buf, + entry.index, + ); + + // None of these conditions should happen, but double check them to prevent TOCTOUs. + if (bytes_read != target.size_disk()) { + @panic("unexpected short read while reading AOF entry"); + } + + const header = target.header(); + if (!header.valid_checksum()) { + @panic("unexpected checksum error while merging"); + } + + if (!header.valid_checksum_body(target.message[@sizeOf(Header)..header.size])) { + @panic("unexpected body checksum error while merging"); + } + + target.to_message(message); + try output_aof.write( + message, + ); + + current_parent = entry.checksum; + _ = entries_by_parent.remove(entry.parent); + } + + output_aof.close(); + + // Validate the newly created output file + try stdout.print("Validating Output {s}\n", .{output_path}); + + var it = try Iterator.init(io, output_path); + defer it.close(); + + var first_checksum: ?u128 = null; + var last_checksum: ?u128 = null; + + while (try it.next(target)) |entry| { + const header = entry.header(); + if (first_checksum == null) { + first_checksum = header.checksum; + } + + last_checksum = header.checksum; + } + + try stdout.print( + "AOF {s} validated. Starting checksum: {x:0>32} Ending checksum: {x:0>32}\n", + .{ output_path, first_checksum orelse 0, last_checksum orelse 0 }, + ); + } + }; +} + +const testing = std.testing; + +test "aof write / read" { + const IO = @import("io.zig").IO; + const AOF = AOFType(IO); + const AOFIterator = AOF.Iterator; + + const aof_file = "test.aof"; + std.fs.cwd().deleteFile(aof_file) catch {}; + defer std.fs.cwd().deleteFile(aof_file) catch {}; + + const allocator = std.testing.allocator; + + var io = try IO.init(32, 0); + defer io.deinit(); + + const dir_fd = try IO.open_dir("."); + defer std.posix.close(dir_fd); + + var aof = try AOF.init(&io, aof_file); + + var message_pool = try MessagePool.init_capacity(allocator, 2); + defer message_pool.deinit(allocator); + + const demo_message = message_pool.get_message(.prepare); + defer message_pool.unref(demo_message); + + const target = try allocator.create(AOFEntry); + defer allocator.destroy(target); + + const demo_payload = "hello world"; + + // The command / operation used here don't matter - we verify things bitwise. + demo_message.header.* = .{ + .op = 0, + .commit = 0, + .view = 0, + .client = 0, + .request = 0, + .parent = 0, + .request_checksum = 0, + .cluster = 0, + .timestamp = 0, + .checkpoint_id = 0, + .release = vsr.Release.minimum, + .command = .prepare, + .operation = @enumFromInt(4), + .size = @intCast(@sizeOf(Header) + demo_payload.len), + }; + + stdx.copy_disjoint(.exact, u8, demo_message.body_used(), demo_payload); + demo_message.header.set_checksum_body(demo_payload); + demo_message.header.set_checksum(); + + try aof.write(demo_message); + aof.close(); + + var it = try AOFIterator.init(&io, aof_file); + defer it.close(); + + const read_entry = (try it.next(target)).?; + + // Check that to_message also works as expected + const read_message = message_pool.get_message(.prepare); + defer message_pool.unref(read_message); + + read_entry.to_message(read_message); + try testing.expect(std.mem.eql( + u8, + demo_message.buffer[0..demo_message.header.size], + read_message.buffer[0..read_message.header.size], + )); + + try testing.expect(std.mem.eql( + u8, + demo_message.buffer[0..demo_message.header.size], + read_entry.message[0..read_entry.header().size], + )); + + // Ensure our iterator works correctly and stops at EOF. + try testing.expect((try it.next(target)) == null); +} + +test "aof merge" {} + +const CLIArgs = union(enum) { + recover: struct { + cluster: u128, + addresses: []const u8, + @"--": void, + path: []const u8, + }, + debug: struct { + @"--": void, + path: []const u8, + }, + merge: struct { + @"--": void, + paths: []const []const u8, + }, + + pub const help = + \\Usage: + \\ + \\ aof [-h | --help] + \\ + \\ aof recover --cluster= --addresses= + \\ + \\ aof debug + \\ + \\ aof merge -- path.aof ... + \\ + \\ + \\Commands: + \\ + \\ recover Recover a recorded AOF file at to a TigerBeetle cluster running + \\ at . Said cluster must be running with aof_recovery = true + \\ and have the same cluster ID as the source. The AOF must have a consistent + \\ hash chain, which can be ensured using the `merge` subcommand. + \\ + \\ debug Print all entries that have been recorded in the AOF file at + \\ to stdout. Checksums are verified, and aof will panic if an invalid + \\ checksum is encountered, so this can be used to check the validity + \\ of an AOF file. Prints a final hash of all data entries in the AOF. + \\ + \\ merge Walk through multiple AOF files, extracting entries from each one + \\ that pass validation, and build a single valid AOF. The first entry + \\ of the first specified AOF file will be considered the root hash. + \\ Can also be used to merge multiple incomplete AOF files into one, + \\ or re-order a single AOF file. Will output to `merged.aof`. + \\ + \\ NB: Make sure to run merge with at least half of the replicas' AOFs, + \\ otherwise entries might be lost. + \\ + \\Options: + \\ + \\ -h, --help + \\ Print this help message and exit. + \\ + ; +}; + +pub fn main() !void { + var gpa_instance: std.heap.GeneralPurposeAllocator(.{}) = .{}; + const gpa = gpa_instance.allocator(); + + var time_os: vsr.time.TimeOS = .{}; + const time = time_os.time(); + + var flags = stdx.Flags.init(gpa); + defer flags.deinit(gpa); + + const args = flags.parse(CLIArgs); + + const target = try gpa.create(AOFEntry); + defer gpa.destroy(target); + + const IO = @import("io.zig").IO; + var io = try IO.init(32, 0); + defer io.deinit(); + + const AOF = AOFType(IO); + const AOFReplayClient = AOF.ReplayClient; + const AOFIterator = AOF.Iterator; + + switch (args) { + .recover => |command| { + var it = try AOFIterator.init(&io, command.path); + defer it.close(); + + var addresses_buffer: [constants.replicas_max]stdx.SocketAddress = undefined; + const addresses_parsed = try vsr.parse_addresses(command.addresses, &addresses_buffer); + var replay = + try AOFReplayClient.init(&io, gpa, time, command.cluster, addresses_parsed); + defer replay.deinit(gpa); + + try replay.replay(&it); + }, + .debug => |command| { + var it = try AOFIterator.init(&io, command.path); + defer it.close(); + + var data_checksum: [32]u8 = undefined; + var blake3 = std.crypto.hash.Blake3.init(.{}); + + const stdout = std.io.getStdOut().writer(); + while (try it.next(target)) |entry| { + const header = entry.header(); + if (!AOFReplayClient.replay_message(header)) continue; + + try stdout.print("{}\n", .{ + header, + }); + + // The body isn't the only important information, there's also the operation + // and the timestamp which are in the header. Include those in our hash too. + blake3.update(std.mem.asBytes(&header.checksum_body)); + blake3.update(std.mem.asBytes(&header.timestamp)); + blake3.update(std.mem.asBytes(&header.operation)); + } + blake3.final(data_checksum[0..]); + try stdout.print( + "\nData checksum chain: {}\n", + .{@as(u128, @bitCast(data_checksum[0..@sizeOf(u128)].*))}, + ); + }, + .merge => |merge| { + if (merge.paths.len == 0) vsr.fatal(.cli, "missing paths", .{}); + if (merge.paths.len > constants.members_max) vsr.fatal(.cli, "too many paths", .{}); + + assert(merge.paths.len > 0); + assert(merge.paths.len <= constants.members_max); + try AOF.merge(&io, gpa, merge.paths, "prepared.aof"); + }, + } +} diff --git a/ocam/src/build/fetch.zig b/ocam/src/build/fetch.zig new file mode 100644 index 00000000..5e243864 --- /dev/null +++ b/ocam/src/build/fetch.zig @@ -0,0 +1,115 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const stdb = @import("./stdb.zig"); + +const log = std.log; + +pub const std_options: std.Options = .{ + .log_level = .info, +}; + +pub fn main() !void { + var arena_instance = std.heap.ArenaAllocator.init(std.heap.page_allocator); + const arena = arena_instance.allocator(); + + const args = try std.process.argsAlloc(arena); + assert(args.len == 6 or args.len == 7); + + _, const zig, const global_cache, const url, const file_name, const out = args[0..6].*; + const hash_optional = if (args.len == 7) args[6] else null; + assert(args.len <= 7); + + if (hash_optional) |hash| { + // Fast path --- don't touch the Internet if we have the hash locally. + const cached = path_join(arena, &.{ global_cache, "p", hash, file_name }); + if (std.fs.cwd().copyFile(cached, std.fs.cwd(), out, .{})) { + log.debug("download skipped: cache hit", .{}); + return; + } else |_| { // Time to ask for forgiveness! + log.debug("download: cache miss", .{}); + } + } else { + log.debug("download: no hash", .{}); + } + + const hash = try fetch(arena, .{ + .zig = zig, + .tmp = path_join(arena, &.{ global_cache, "tmp" }), + .url = url, + }); + + if (hash_optional) |hash_specified| { + if (!std.mem.eql(u8, hash, hash_specified)) { + log.err( + \\bad hash + \\specified: {s} + \\fetched: {s} + \\ + , .{ hash_specified, hash }); + return error.BadHash; + } + } + + const cached = path_join(arena, &.{ global_cache, "p", hash, file_name }); + errdefer log.err("copying from {s}", .{cached}); + + try std.fs.cwd().copyFile(cached, std.fs.cwd(), out, .{}); +} + +/// If curl is available, use it for robust downloads, and then +/// `zig fetch` a local file to get the hash. Otherwise, fetch +/// the url directly. +fn fetch(arena: Allocator, options: struct { + zig: []const u8, + tmp: []const u8, + url: []const u8, +}) ![]const u8 { + if (stdb.exec_ok(arena, &.{ "curl", "--version" })) { + log.debug("download: curl", .{}); + const url_file_name = options.url[std.mem.lastIndexOf(u8, options.url, "/").?..]; + const tmp_dir = path_join(arena, &.{ + options.tmp, + &std.fmt.bytesToHex(std.mem.asBytes(&std.crypto.random.int(u64)), .lower), + }); + defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + + try std.fs.cwd().makePath(tmp_dir); + + const curl_output = path_join(arena, &.{ tmp_dir, url_file_name }); + // TODO Go back to using stdb.exec once this curl/zip issue is debugged. + const curl_result = std.process.Child.run(.{ + .allocator = arena, + .argv = &(.{ + "curl", "--retry-all-errors", + "--retry", "5", + "--retry-max-time", "120", + "--retry-delay", "30", + "--location", options.url, + "--output", curl_output, + "--verbose", "--fail", + }), + .max_output_bytes = 1024 * 1024, + }) catch |err| { + log.err("curl error: {}", .{err}); + return err; + }; + errdefer log.err("curl stderr: {s}\n\ncurl stderr end", .{curl_result.stderr}); + + if (!(curl_result.term == .Exited and curl_result.term.Exited == 0)) { + log.err("curl error: {}", .{curl_result.term}); + return error.Exec; + } + return try stdb.exec(arena, &.{ options.zig, "fetch", curl_output }); + } + log.debug("download: zig fetch", .{}); + return try stdb.exec(arena, &.{ options.zig, "fetch", options.url }); +} + +fn path_join(arena: Allocator, components: []const []const u8) []const u8 { + return std.fs.path.join(arena, components) catch |err| oom(err); +} + +pub fn oom(_: error{OutOfMemory}) noreturn { + @panic("OOM"); +} diff --git a/ocam/src/build/npm_install.zig b/ocam/src/build/npm_install.zig new file mode 100644 index 00000000..c3f1dbde --- /dev/null +++ b/ocam/src/build/npm_install.zig @@ -0,0 +1,23 @@ +//! npm install routinely fails on Windows on CI. +//! This script just re-runs it multiple times, hoping for the best! +const std = @import("std"); +const stdb = @import("./stdb.zig"); + +pub const std_options: std.Options = .{ + .log_level = .info, +}; + +pub fn main() !void { + var arena_instance = std.heap.ArenaAllocator.init(std.heap.page_allocator); + const arena = arena_instance.allocator(); + + // Run `npm install` first, to avoid any disk writes if everything's already installed. + if (stdb.exec_ok(arena, &.{ "npm", "install" })) return; + + // Failing that, switch to `clean-install`, which nukes node_modules first. + for (0..10) |_| { + if (stdb.exec_ok(arena, &.{ "npm", "clean-install" })) return; + } + + _ = try stdb.exec(arena, &.{ "npm", "clean-install" }); +} diff --git a/ocam/src/build/stdb.zig b/ocam/src/build/stdb.zig new file mode 100644 index 00000000..62bb7b25 --- /dev/null +++ b/ocam/src/build/stdb.zig @@ -0,0 +1,30 @@ +//! Like stdx, but for build helpers. +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const log = std.log; + +pub fn exec_ok(arena: Allocator, argv: []const []const u8) bool { + assert(argv.len > 0); + const result = std.process.Child.run(.{ .allocator = arena, .argv = argv }) catch return false; + return result.term == .Exited and result.term.Exited == 0; +} + +pub fn exec(arena: Allocator, argv: []const []const u8) ![]const u8 { + assert(argv.len > 0); + const result = std.process.Child.run(.{ .allocator = arena, .argv = argv }) catch |err| { + log.err("running {s}: {}", .{ argv, err }); + return err; + }; + if (!(result.term == .Exited and result.term.Exited == 0)) { + log.err("running {s}: {}\n{s}", .{ argv, result.term, result.stderr }); + return error.Exec; + } + if (std.mem.indexOfScalar(u8, result.stdout, '\n')) |first_newline| { + if (first_newline + 1 == result.stdout.len) { + return result.stdout[0 .. result.stdout.len - 1]; + } + } + return result.stdout; +} diff --git a/ocam/src/build_multiversion.zig b/ocam/src/build_multiversion.zig new file mode 100644 index 00000000..0d23c39c --- /dev/null +++ b/ocam/src/build_multiversion.zig @@ -0,0 +1,807 @@ +//! Custom build step to prepare multiversion binaries. + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; + +const multiversion = @import("./multiversion.zig"); +const stdx = @import("stdx"); +const Shell = stdx.Shell; + +const multiversion_binary_size_max = multiversion.multiversion_binary_size_max; +const MultiversionHeader = multiversion.MultiversionHeader; +const section_to_macho_cpu = multiversion.section_to_macho_cpu; + +const Target = union(enum) { + const Arch = enum { x86_64, aarch64 }; + + linux: Arch, + windows: Arch, + macos, // Universal binary packing both x86_64 and aarch64 versions. + + pub fn parse(str: []const u8) !Target { + const targets = [_]struct { []const u8, Target }{ + .{ "x86_64-linux", .{ .linux = .x86_64 } }, + .{ "aarch64-linux", .{ .linux = .aarch64 } }, + .{ "x86_64-windows", .{ .windows = .x86_64 } }, + .{ "aarch64-windows", .{ .windows = .aarch64 } }, + .{ "macos", .macos }, + }; + + inline for (targets) |t| if (std.mem.eql(u8, str, t[0])) return t[1]; + return error.InvalidTarget; + } +}; + +const CLIArgs = struct { + target: []const u8, + debug: bool = false, + llvm_objcopy: []const u8, + tigerbeetle_current: ?[]const u8 = null, + tigerbeetle_current_x86_64: ?[]const u8 = null, // NB: Will be x86-64 on the CLI! + tigerbeetle_current_aarch64: ?[]const u8 = null, + tigerbeetle_past: []const u8, + output: []const u8, + tmp: []const u8, +}; + +// These are the options for cli_args.tigerbeetle_current. Ideally, they should be passed at +// runtime, but passing them at comptime is more convenient. +const vsr_options = @import("vsr_options"); + +pub fn main() !void { + var allocator: std.heap.GeneralPurposeAllocator(.{}) = .{}; + defer { + if (allocator.deinit() != .ok) { + @panic("memory leaked"); + } + } + const gpa = allocator.allocator(); + + const shell = try Shell.create(gpa); + defer shell.destroy(); + + var flags = stdx.Flags.init(gpa); + defer flags.deinit(gpa); + + const cli_args = flags.parse(CLIArgs); + + const tmp_dir_path = try shell.fmt("{s}/{d}", .{ + cli_args.tmp, + std.crypto.random.int(u64), + }); + var tmp_dir = try std.fs.cwd().makeOpenPath(tmp_dir_path, .{}); + defer { + tmp_dir.close(); + std.fs.cwd().deleteTree(tmp_dir_path) catch {}; + } + + const target = try Target.parse(cli_args.target); + + switch (target) { + .windows, .linux => try build_multiversion_single_arch(shell, .{ + .llvm_objcopy = cli_args.llvm_objcopy, + .tmp_path = tmp_dir_path, + .target = target, + .debug = cli_args.debug, + .tigerbeetle_current = cli_args.tigerbeetle_current.?, + .tigerbeetle_past = cli_args.tigerbeetle_past, + .output = cli_args.output, + }), + .macos => try build_multiversion_universal(shell, .{ + .llvm_objcopy = cli_args.llvm_objcopy, + .tmp_path = tmp_dir_path, + .target = target, + .debug = cli_args.debug, + .tigerbeetle_current_x86_64 = cli_args.tigerbeetle_current_x86_64.?, + .tigerbeetle_current_aarch64 = cli_args.tigerbeetle_current_aarch64.?, + .tigerbeetle_past = cli_args.tigerbeetle_past, + .output = cli_args.output, + }), + } + + const stat = try shell.cwd.statFile(cli_args.output); + assert(stat.size <= multiversion_binary_size_max); + assert(stat.size <= multiversion.multiversion_binary_platform_size_max(.{ + .macos = target == .macos, + .debug = cli_args.debug, + })); +} + +fn build_multiversion_single_arch(shell: *Shell, options: struct { + llvm_objcopy: []const u8, + tmp_path: []const u8, + target: Target, + debug: bool, + tigerbeetle_current: []const u8, + tigerbeetle_past: []const u8, + output: []const u8, +}) !void { + assert(options.target != .macos); + + // We will be modifying this binary in-place. + const tigerbeetle_working = try shell.fmt("{s}/tigerbeetle-working", .{options.tmp_path}); + + const current_checksum = try make_deterministic(shell, .{ + .llvm_objcopy = options.llvm_objcopy, + .source = options.tigerbeetle_current, + .output = tigerbeetle_working, + }); + + const sections = .{ + .header_zero = try shell.fmt("{s}/multiversion-zero.header", .{options.tmp_path}), + .header = try shell.fmt("{s}/multiversion.header", .{options.tmp_path}), + .body = try shell.fmt("{s}/multiversion.body", .{options.tmp_path}), + }; + + // Explicitly write out zeros for the header, to compute the checksum. + try shell.cwd.writeFile(.{ + .sub_path = sections.header_zero, + .data = std.mem.asBytes(&std.mem.zeroes(MultiversionHeader)), + .flags = .{ .exclusive = true }, + }); + + const past_versions = try build_multiversion_body(shell, .{ + .llvm_objcopy = options.llvm_objcopy, + .tmp_path = options.tmp_path, + .target = options.target, + .arch = switch (options.target) { + inline .windows, .linux => |arch| arch, + .macos => unreachable, + }, + .tigerbeetle_past = options.tigerbeetle_past, + .output = sections.body, + .debug = options.debug, + }); + + // Use objcopy to add in our new body, as well as its header - even though the + // header is still zero! + try shell.exec( + \\{llvm_objcopy} --enable-deterministic-archives --keep-undefined + \\ + \\ --add-section .tb_mvb={body} + \\ --set-section-flags .tb_mvb=contents,noload,readonly + \\ + \\ --add-section .tb_mvh={header_zero} + \\ --set-section-flags .tb_mvh=contents,noload,readonly + \\ + \\ {working} + , .{ + .llvm_objcopy = options.llvm_objcopy, + .body = sections.body, + .header_zero = sections.header_zero, + .working = tigerbeetle_working, + }); + + const checksum_binary_without_header = try checksum_file( + shell, + tigerbeetle_working, + multiversion.multiversion_binary_size_max, + ); + + var header: MultiversionHeader = .{ + .current_release = (try multiversion.Release.parse(vsr_options.release)).value, + .current_checksum = current_checksum, + .current_flags = .{ + .debug = options.debug, + .visit = true, + }, + .past = past_versions.past_releases, + .checksum_binary_without_header = checksum_binary_without_header, + .current_release_client_min = (try multiversion.Release.parse( + vsr_options.release_client_min, + )).value, + .current_git_commit = try git_sha_to_binary(&vsr_options.git_commit.?), + }; + header.checksum_header = header.calculate_header_checksum(); + try header.verify(); + + try shell.cwd.writeFile(.{ + .sub_path = sections.header, + .data = std.mem.asBytes(&header), + .flags = .{ .exclusive = true }, + }); + + // Replace the header with the final version. + try shell.exec( + \\{llvm_objcopy} --enable-deterministic-archives --keep-undefined + \\ + \\ --remove-section .tb_mvh + \\ --add-section .tb_mvh={header} + \\ --set-section-flags .tb_mvh=contents,noload,readonly + \\ + \\ {working} + , .{ + .header = sections.header, + .llvm_objcopy = options.llvm_objcopy, + .working = tigerbeetle_working, + }); + + try shell.cwd.copyFile(tigerbeetle_working, shell.cwd, options.output, .{}); + + if (self_check_enabled(options.target)) { + try self_check(shell, options.output, past_versions.unpacked); + } +} + +fn build_multiversion_universal(shell: *Shell, options: struct { + llvm_objcopy: []const u8, + tmp_path: []const u8, + target: Target, + debug: bool, + tigerbeetle_current_x86_64: []const u8, + tigerbeetle_current_aarch64: []const u8, + tigerbeetle_past: []const u8, + output: []const u8, +}) !void { + assert(options.target == .macos); + + const tigerbeetle_zero_header = try shell.fmt("{s}/tigerbeetle-zero-header", .{ + options.tmp_path, + }); + + const sections = .{ + .header_zero = try shell.fmt("{s}/multiversion-zero.header", .{options.tmp_path}), + .x86_64 = .{ + .header = try shell.fmt("{s}/multiversion-x86_64.header", .{options.tmp_path}), + .body = try shell.fmt("{s}/multiversion-x86_64.body", .{options.tmp_path}), + }, + .aarch64 = .{ + .header = try shell.fmt("{s}/multiversion-aarch64.header", .{options.tmp_path}), + .body = try shell.fmt("{s}/multiversion-aarch64.body", .{options.tmp_path}), + }, + }; + + // Explicitly write out zeros for the header, to compute the checksum. + try shell.cwd.writeFile(.{ + .sub_path = sections.header_zero, + .data = std.mem.asBytes(&std.mem.zeroes(MultiversionHeader)), + .flags = .{ .exclusive = true }, + }); + + assert(builtin.target.cpu.arch == .x86_64 or builtin.target.cpu.arch == .aarch64); + const past_versions_aarch64 = try build_multiversion_body(shell, .{ + .llvm_objcopy = options.llvm_objcopy, + .tmp_path = options.tmp_path, + .target = .macos, + .arch = .aarch64, + .tigerbeetle_past = options.tigerbeetle_past, + .output = sections.aarch64.body, + .debug = options.debug, + }); + + const past_versions_x86_64 = try build_multiversion_body(shell, .{ + .llvm_objcopy = options.llvm_objcopy, + .tmp_path = options.tmp_path, + .target = .macos, + .arch = .x86_64, + .tigerbeetle_past = options.tigerbeetle_past, + .output = sections.x86_64.body, + .debug = options.debug, + }); + assert(past_versions_aarch64.past_releases.count == past_versions_x86_64.past_releases.count); + + try macos_universal_binary_build( + shell, + tigerbeetle_zero_header, + &.{ + .{ + .cpu_type = std.macho.CPU_TYPE_ARM64, + .cpu_subtype = std.macho.CPU_SUBTYPE_ARM_ALL, + .path = options.tigerbeetle_current_aarch64, + }, + .{ + .cpu_type = std.macho.CPU_TYPE_X86_64, + .cpu_subtype = std.macho.CPU_SUBTYPE_X86_64_ALL, + .path = options.tigerbeetle_current_x86_64, + }, + .{ + .cpu_type = @intFromEnum(section_to_macho_cpu.tb_mvb_aarch64), + .cpu_subtype = 0x00000000, + .path = sections.aarch64.body, + }, + .{ + .cpu_type = @intFromEnum(section_to_macho_cpu.tb_mvh_aarch64), + .cpu_subtype = 0x00000000, + .path = sections.header_zero, + }, + .{ + .cpu_type = @intFromEnum(section_to_macho_cpu.tb_mvb_x86_64), + .cpu_subtype = 0x00000000, + .path = sections.x86_64.body, + }, + .{ + .cpu_type = @intFromEnum(section_to_macho_cpu.tb_mvh_x86_64), + .cpu_subtype = 0x00000000, + .path = sections.header_zero, + }, + }, + ); + const checksum_binary_without_header = try checksum_file( + shell, + tigerbeetle_zero_header, + multiversion_binary_size_max, + ); + + inline for ( + .{ options.tigerbeetle_current_aarch64, options.tigerbeetle_current_x86_64 }, + .{ past_versions_aarch64, past_versions_x86_64 }, + .{ sections.aarch64.header, sections.x86_64.header }, + ) |tigerbeetle_current, past_versions, header_name| { + const current_checksum = try checksum_file( + shell, + tigerbeetle_current, + multiversion_binary_size_max, + ); + + var header = multiversion.MultiversionHeader{ + .current_release = (try multiversion.Release.parse(vsr_options.release)).value, + .current_checksum = current_checksum, + .current_flags = .{ + .debug = options.debug, + .visit = true, + }, + .past = past_versions.past_releases, + .checksum_binary_without_header = checksum_binary_without_header, + .current_release_client_min = (try multiversion.Release.parse( + vsr_options.release_client_min, + )).value, + .current_git_commit = try git_sha_to_binary(&vsr_options.git_commit.?), + }; + header.checksum_header = header.calculate_header_checksum(); + try header.verify(); + + try shell.cwd.writeFile(.{ + .sub_path = header_name, + .data = std.mem.asBytes(&header), + .flags = .{ .exclusive = true }, + }); + } + + try macos_universal_binary_build(shell, options.output, &.{ + .{ + .cpu_type = std.macho.CPU_TYPE_ARM64, + .cpu_subtype = std.macho.CPU_SUBTYPE_ARM_ALL, + .path = options.tigerbeetle_current_aarch64, + }, + .{ + .cpu_type = std.macho.CPU_TYPE_X86_64, + .cpu_subtype = std.macho.CPU_SUBTYPE_X86_64_ALL, + .path = options.tigerbeetle_current_x86_64, + }, + .{ + .cpu_type = @intFromEnum(section_to_macho_cpu.tb_mvb_aarch64), + .cpu_subtype = 0x00000000, + .path = sections.aarch64.body, + }, + .{ + .cpu_type = @intFromEnum(section_to_macho_cpu.tb_mvh_aarch64), + .cpu_subtype = 0x00000000, + .path = sections.aarch64.header, + }, + .{ + .cpu_type = @intFromEnum(section_to_macho_cpu.tb_mvb_x86_64), + .cpu_subtype = 0x00000000, + .path = sections.x86_64.body, + }, + .{ + .cpu_type = @intFromEnum(section_to_macho_cpu.tb_mvh_x86_64), + .cpu_subtype = 0x00000000, + .path = sections.x86_64.header, + }, + }); +} + +fn make_deterministic(shell: *Shell, options: struct { + llvm_objcopy: []const u8, + source: []const u8, + output: []const u8, +}) !u128 { + // Copy the object using llvm-objcopy before taking our hash. This is to ensure we're + // round trip deterministic between adding and removing sections: + // `llvm-objcopy --add-section ... src dst_added` followed by + // `llvm-objcopy --remove-section ... dst_added src_back` means + // checksum(src) == checksum(src_back) + // Note: actually don't think this is needed, we could assert it? + try shell.exec( + \\{llvm_objcopy} --enable-deterministic-archives + \\ {source} {working} + , .{ + .llvm_objcopy = options.llvm_objcopy, + .source = options.source, + .working = options.output, + }); + + return try checksum_file( + shell, + options.output, + multiversion.multiversion_binary_size_max, + ); +} + +fn build_multiversion_body(shell: *Shell, options: struct { + llvm_objcopy: []const u8, + tmp_path: []const u8, + target: Target, + arch: Target.Arch, + tigerbeetle_past: []const u8, + output: []const u8, + debug: bool, +}) !struct { + past_releases: MultiversionHeader.PastReleases, + unpacked: []const []const u8, +} { + const past_binary_contents: []align(8) const u8 = try shell.cwd.readFileAllocOptions( + shell.arena.allocator(), + options.tigerbeetle_past, + multiversion_binary_size_max, + null, + 8, + null, + ); + + const parsed_offsets = switch (options.target) { + .windows => try multiversion.parse_pe(past_binary_contents), + .macos => try multiversion.parse_macho(past_binary_contents), + .linux => try multiversion.parse_elf(past_binary_contents), + }; + const arch_offsets = switch (options.arch) { + .x86_64 => parsed_offsets.x86_64.?, + .aarch64 => parsed_offsets.aarch64.?, + }; + + const header_bytes = + past_binary_contents[arch_offsets.header_offset..][0..@sizeOf(MultiversionHeader)]; + + var header = try MultiversionHeader.init_from_bytes(header_bytes); + if (header.current_release == (try multiversion.Release.parse("0.15.4")).value) { + // current_git_commit and current_release_client_min were added after 0.15.4. These are the + // values for that release. + header.current_git_commit = try git_sha_to_binary( + "14abaeabd09bd7c78a95b6b990748f3612b3e4cc", + ); + header.current_release_client_min = (try multiversion.Release.parse("0.15.3")).value; + } + + var unpacked = std.ArrayList([]const u8).init(shell.arena.allocator()); + var past_releases: MultiversionHeader.PastReleases = .{}; + assert(past_releases.count == 0); + // Extract the old current release - this is the release that was the current release, and not + // embedded in the past pack. + const old_current_release = header.current_release; + const old_current_release_output_name = try shell.fmt("{s}/tigerbeetle-past-{}-{s}", .{ + options.tmp_path, + multiversion.Release{ .value = old_current_release }, + @tagName(options.arch), + }); + + if (options.target == .macos) { + const cpu_type, const cpu_subtype = switch (options.arch) { + .aarch64 => .{ std.macho.CPU_TYPE_ARM64, std.macho.CPU_SUBTYPE_ARM_ALL }, + .x86_64 => .{ std.macho.CPU_TYPE_X86_64, std.macho.CPU_SUBTYPE_X86_64_ALL }, + }; + + try macos_universal_binary_extract( + shell, + options.tigerbeetle_past, + .{ .cpu_type = cpu_type, .cpu_subtype = cpu_subtype }, + old_current_release_output_name, + ); + } else { + try shell.exec( + \\{llvm_objcopy} --enable-deterministic-archives --keep-undefined + \\ --remove-section .tb_mvb --remove-section .tb_mvh + \\ {tigerbeetle_past} {tigerbeetle_old_current} + , .{ + .llvm_objcopy = options.llvm_objcopy, + .tigerbeetle_past = options.tigerbeetle_past, + .tigerbeetle_old_current = old_current_release_output_name, + }); + } + + if (builtin.os.tag != .windows) { + const old_current_release_fd = try shell.cwd.openFile(old_current_release_output_name, .{ + .mode = .write_only, + }); + defer old_current_release_fd.close(); + try old_current_release_fd.chmod(0o755); + } + + // It's important to verify the previous current_release checksum - it can't be verified at + // runtime by multiversion.zig, since it relies on objcopy to extract. + assert(header.current_checksum == try checksum_file( + shell, + old_current_release_output_name, + multiversion_binary_size_max, + )); + + const old_current_release_size: u32 = @intCast( + (try shell.cwd.statFile(old_current_release_output_name)).size, + ); + + // You can have as many releases as you want, as long as it's 5 or less. + // This is made up of: + // * up to 3 releases from the old past pack (extracted from the release downloaded), + // * 1 old current release (extracted from the release downloaded), + // * 1 current release (that was just built). + // This will be improved soon: + // https://github.com/tigerbeetle/tigerbeetle/pull/2165#discussion_r1698114401 + // + // No size limits are explicitly checked here; they're validated later by using the + // `multiversion` subcommand to test the final built binary against all past binaries that are + // included. + // + // For debug builds, due to their size, this is limited to only the old current release and the + // current release. Nothing is taken from the past pack. + const past_count: u32 = if (options.debug) 0 else @min(3, header.past.count); + + const past_starting_index = header.past.count - past_count; + + for ( + header.past.releases[past_starting_index..][0..past_count], + header.past.offsets[past_starting_index..][0..past_count], + header.past.sizes[past_starting_index..][0..past_count], + header.past.checksums[past_starting_index..][0..past_count], + header.past.flags[past_starting_index..][0..past_count], + header.past.git_commits[past_starting_index..][0..past_count], + header.past.release_client_mins[past_starting_index..][0..past_count], + ) | + past_release, + past_offset, + past_size, + past_checksum, + past_flag, + past_commit, + past_release_client_min, + | { + const past_name = try shell.fmt("{s}/tigerbeetle-past-{}-{s}", .{ + options.tmp_path, + multiversion.Release{ .value = past_release }, + @tagName(options.arch), + }); + const mode_exec = if (builtin.os.tag == .windows) 0 else 0o755; + try shell.cwd.writeFile(.{ + .sub_path = past_name, + .data = past_binary_contents[arch_offsets.body_offset..][past_offset..][0..past_size], + .flags = .{ .exclusive = true, .mode = mode_exec }, + }); + + // This is double-checked later when validating at runtime with the binary. + assert(past_checksum == try checksum_file( + shell, + past_name, + multiversion_binary_size_max, + )); + + past_releases.add(.{ + .release = past_release, + .checksum = past_checksum, + .size = past_size, + .flags = past_flag, + .git_commit = past_commit, + .release_client_min = past_release_client_min, + }); + try unpacked.append(past_name); + } + + const old_current_release_flags = blk: { + var old_current_release_flags = header.current_flags; + + // Visit https://github.com/tigerbeetle/tigerbeetle/pull/2181. + old_current_release_flags.visit = true; + + break :blk old_current_release_flags; + }; + + // All of these are in ascending order, so the old current release goes last: + past_releases.add(.{ + .release = old_current_release, + .checksum = header.current_checksum, + .size = old_current_release_size, + .flags = old_current_release_flags, + .git_commit = header.current_git_commit, + .release_client_min = header.current_release_client_min, + }); + try unpacked.append(old_current_release_output_name); + assert(past_releases.count == past_count + 1); // +1 to include the old current release. + try past_releases.verify(); + + const body_file = try shell.cwd.createFile(options.output, .{ .exclusive = true }); + defer body_file.close(); + + for ( + past_releases.releases[0..past_releases.count], + past_releases.offsets[0..past_releases.count], + past_releases.sizes[0..past_releases.count], + ) |release, offset, size| { + const past_name = try shell.fmt("{s}/tigerbeetle-past-{}-{s}", .{ + options.tmp_path, + multiversion.Release{ .value = release }, + @tagName(options.arch), + }); + const contents = try shell.cwd.readFileAlloc(shell.arena.allocator(), past_name, size); + try body_file.pwriteAll(contents, offset); + } + + return .{ + .past_releases = past_releases, + .unpacked = unpacked.items, + }; +} + +/// Does the same thing as llvm-lipo (builds a universal binary) but allows building binaries +/// that have deprecated architectures. This is used by multiversion on macOS, where these +/// deprecated architectures hold the multiversion header and body. +/// It's much easier to embed and read them here, then to do it in the inner MachO binary, like +/// we do with ELF or PE. +fn macos_universal_binary_build( + shell: *Shell, + output_path: []const u8, + binaries: []const struct { + cpu_type: i32, + cpu_subtype: i32, + path: []const u8, + }, +) !void { + // The offset start is relative to the end of the headers, rounded up to the alignment. + const alignment_power = 14; + const alignment = 1 << alignment_power; + + // Ensure alignment of 2^14 == 16384 to match macOS. + comptime assert(alignment == 16384); + + const headers_size = @sizeOf(std.macho.fat_header) + + @sizeOf(std.macho.fat_arch) * binaries.len; + assert(headers_size < alignment); + + const binary_headers = try shell.arena.allocator().alloc(std.macho.fat_arch, binaries.len); + + var current_offset: u32 = alignment; + for (binaries, binary_headers) |binary, *binary_header| { + const binary_size: u32 = @intCast( + (try shell.cwd.statFile(binary.path)).size, + ); + + // The Mach-O header is big-endian... + binary_header.* = std.macho.fat_arch{ + .cputype = @byteSwap(binary.cpu_type), + .cpusubtype = @byteSwap(binary.cpu_subtype), + .offset = @byteSwap(current_offset), + .size = @byteSwap(binary_size), + .@"align" = @byteSwap(@as(u32, alignment_power)), + }; + + current_offset += binary_size; + current_offset = std.mem.alignForward(u32, current_offset, alignment); + } + + var output_file = try shell.project_root.createFile(output_path, .{ + .exclusive = true, + .mode = if (builtin.target.os.tag == .windows) 0 else 0o755, + }); + defer output_file.close(); + + const fat_header = std.macho.fat_header{ + .magic = std.macho.FAT_CIGAM, + .nfat_arch = @byteSwap(@as(u32, @intCast(binaries.len))), + }; + assert(@sizeOf(std.macho.fat_header) == 8); + try output_file.writeAll(std.mem.asBytes(&fat_header)); + + assert(@sizeOf(std.macho.fat_arch) == 20); + try output_file.writeAll(std.mem.sliceAsBytes(binary_headers)); + + try output_file.seekTo(alignment); + + for (binaries, binary_headers) |binary, binary_header| { + const binary_contents = try shell.project_root.readFileAlloc( + shell.arena.allocator(), + binary.path, + multiversion_binary_size_max, + ); + assert(binary_contents.len == @byteSwap(binary_header.size)); + + try output_file.seekTo(@byteSwap(binary_header.offset)); + try output_file.writeAll(binary_contents); + } +} + +/// Does the opposite of macos_universal_binary_build: allows extracting inner binaries from a +/// universal binary. +fn macos_universal_binary_extract( + shell: *Shell, + input_path: []const u8, + filter: struct { cpu_type: i32, cpu_subtype: i32 }, + output_path: []const u8, +) !void { + const binary_contents = try shell.cwd.readFileAlloc( + shell.arena.allocator(), + input_path, + multiversion_binary_size_max, + ); + + const fat_header = std.mem.bytesAsValue( + std.macho.fat_header, + binary_contents[0..@sizeOf(std.macho.fat_header)], + ); + assert(fat_header.magic == std.macho.FAT_CIGAM); + + for (0..@byteSwap(fat_header.nfat_arch)) |i| { + const header_offset = @sizeOf(std.macho.fat_header) + @sizeOf(std.macho.fat_arch) * i; + const fat_arch = std.mem.bytesAsValue( + std.macho.fat_arch, + binary_contents[header_offset..][0..@sizeOf(std.macho.fat_arch)], + ); + assert(@byteSwap(fat_arch.@"align") == 14); + + if (@byteSwap(fat_arch.cputype) == filter.cpu_type and + @byteSwap(fat_arch.cpusubtype) == filter.cpu_subtype) + { + const offset = @byteSwap(fat_arch.offset); + const size = @byteSwap(fat_arch.size); + + try shell.cwd.writeFile(.{ + .sub_path = output_path, + .data = binary_contents[offset..][0..size], + .flags = .{ .exclusive = true }, + }); + + break; + } + } else { + @panic("no matching inner binary found."); + } +} + +fn self_check_enabled(target: Target) bool { + return switch (target) { + .linux => |arch| builtin.target.os.tag == .linux and switch (arch) { + .x86_64 => builtin.target.cpu.arch == .x86_64, + .aarch64 => builtin.target.cpu.arch == .aarch64, + }, + .windows => |arch| builtin.target.os.tag == .windows and switch (arch) { + .x86_64 => builtin.target.cpu.arch == .x86_64, + .aarch64 => builtin.target.cpu.arch == .aarch64, + }, + .macos => builtin.target.os.tag == .macos, + }; +} + +fn self_check(shell: *Shell, tigerbeetle: []const u8, past_releases: []const []const u8) !void { + assert(past_releases.len > 0); + try shell.exec( + "{tigerbeetle} multiversion {tigerbeetle}", + .{ .tigerbeetle = tigerbeetle }, + ); + for (past_releases) |past_release| { + // 0.15.3 didn't have the multiversion subcommand since it was the epoch. + if (std.mem.indexOf(u8, past_release, "0.15.3") != null) continue; + try shell.exec( + "{past_release} multiversion {tigerbeetle}", + .{ .tigerbeetle = tigerbeetle, .past_release = past_release }, + ); + } +} + +fn checksum_file(shell: *Shell, path: []const u8, size_max: u32) !u128 { + const contents = try shell.cwd.readFileAlloc(shell.arena.allocator(), path, size_max); + return multiversion.checksum.checksum(contents); +} + +fn git_sha_to_binary(commit: []const u8) ![20]u8 { + assert(commit.len == 40); + + var commit_bytes: [20]u8 = std.mem.zeroes([20]u8); + const commit_int = + try stdx.parse_int(u160, commit, .{ .base = 16, .allow_leading_zero = true }); + std.mem.writeInt(u160, &commit_bytes, commit_int, .big); + + var commit_roundtrip: [40]u8 = undefined; + assert(std.mem.eql(u8, try std.fmt.bufPrint( + &commit_roundtrip, + "{s}", + .{std.fmt.fmtSliceHexLower(&commit_bytes)}, + ), commit)); + + return commit_bytes; +} diff --git a/ocam/src/cdc/amqp.zig b/ocam/src/cdc/amqp.zig new file mode 100644 index 00000000..6a307757 --- /dev/null +++ b/ocam/src/cdc/amqp.zig @@ -0,0 +1,1450 @@ +const std = @import("std"); + +const stdx = @import("stdx"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const log = std.log.scoped(.amqp); + +const vsr = @import("../vsr.zig"); +const constants = vsr.constants; +const IO = vsr.io.IO; + +const spec = @import("./amqp/spec.zig"); +const protocol = @import("./amqp/protocol.zig"); +const types = @import("./amqp/types.zig"); +const fatal = protocol.fatal; + +const ErrorCodes = protocol.ErrorCodes; +const Channel = protocol.Channel; + +pub const Decoder = protocol.Decoder; +pub const Encoder = protocol.Encoder; + +pub const ConnectOptions = types.ConnectOptions; +pub const ExchangeDeclareOptions = types.ExchangeDeclareOptions; +pub const QueueDeclareOptions = types.QueueDeclareOptions; +pub const BasicPublishOptions = types.BasicPublishOptions; +pub const GetMessagePropertiesResult = types.GetMessagePropertiesResult; +pub const GetMessageOptions = types.GetMessageOptions; +pub const BasicNackOptions = types.BasicNackOptions; + +pub const tcp_port_default = protocol.tcp_port_default; +pub const frame_min_size = protocol.frame_min_size; + +/// AMQP (Advanced Message Queuing Protocol) 0.9.1 client. +/// - Uses TigerBeetle's IO interface. +/// - Single channel only. +/// - Batched publishing with fixed buffers. +/// - Limited consumer capabilities. +/// - Implements only the methods required by TigerBeetle. +/// - No error handling: **CAN PANIC**. +pub const Client = struct { + pub const Callback = *const fn (self: *Client) void; + pub const GetMessagePropertiesCallback = *const fn ( + self: *Client, + result: ?GetMessagePropertiesResult, + ) Decoder.Error!void; + pub const GetMessageBodyCallback = *const fn ( + self: *Client, + body: []const u8, + ) Decoder.Error!void; + + io: *IO, + fd: ?IO.socket_t = null, + reply_timeout_ticks: u64, + + receive_buffer: ReceiveBuffer, + receive_completion: IO.Completion = undefined, + + send_buffer: SendBuffer, + send_completion: IO.Completion = undefined, + + heartbeat: union(enum) { + idle, + sending: IO.Completion, + } = .idle, + + action: union(enum) { + none, + connect: struct { + options: ConnectOptions, + phase: enum { + dial, + handshake, + auth, + connection_open, + channel_open, + confirm_select, + }, + callback: Callback, + }, + close: Callback, + queue_declare: Callback, + exchange_declare: Callback, + get_message: GetMessagePropertiesCallback, + message_body_pending: struct { + body_size: u64, + }, + get_message_body: struct { + body_size: u64, + callback: GetMessageBodyCallback, + }, + nack: Callback, + publish_enqueue: struct { + count: u32 = 0, + }, + publish: struct { + callback: Callback, + phase: enum { sending, awaiting_confirmation } = .sending, + }, + } = .none, + + awaiter: union(enum) { + none, + /// Flushes the current send buffer and invokes the callback upon completion. + /// Invariant: the send buffer must be ready to be flushed. + send_and_forget: *const fn (self: *Client) void, + /// Flushes the current send buffer and invokes the callback when a synchronous + /// AMQP method is received. + /// Invariant: the send buffer must be ready to be flushed. + send_and_await_reply: struct { + channel: Channel, + state: union(enum) { + sending, + awaiting: struct { + duration_ticks: u64 = 0, + }, + }, + callback: *const fn (self: *Client, reply: spec.ClientMethod) Decoder.Error!void, + }, + /// Invokes the callback when an AMQP header frame is received, containing information + /// about the incoming message. + /// Invariant: the send buffer must be empty. + await_content_header: struct { + channel: Channel, + duration_ticks: u64 = 0, + delivery_tag: u64, + message_count: u32, + callback: *const fn ( + self: *Client, + delivery_tag: u64, + message_count: u32, + header: Decoder.Header, + ) Decoder.Error!void, + }, + /// Invokes the callback when an AMQP body frame is received. + /// Invariant: the send buffer must be empty. + await_body: struct { + channel: Channel, + duration_ticks: u64 = 0, + callback: *const fn ( + self: *Client, + body: []const u8, + ) Decoder.Error!void, + }, + } = .none, + + publish_confirms: Confirms, + + pub fn init( + allocator: std.mem.Allocator, + options: struct { + io: *IO, + message_count_max: u32, + message_body_size_max: u32, + reply_timeout_ticks: u64, + }, + ) !Client { + assert(options.message_count_max > 0); + assert(options.message_body_size_max > 0); + assert(options.reply_timeout_ticks > 0); + + // The worst-case size required to write a frame containing the message body. + const body_frame_size = Encoder.FrameHeader.size_total + + options.message_body_size_max + + @sizeOf(protocol.FrameEnd); + + const frame_size = @max(frame_min_size, body_frame_size); + + // Large messages are not expected, but we must be able to receive at least + // the same frame size we send. + const receive_buffer = try allocator.alloc(u8, frame_size); + errdefer allocator.free(receive_buffer); + + // When publishing messages, the method and header frames (including metadata) + // are sent in addition to the body frame. + // TODO: The size could be calculated more efficiently if the variable data had + // a known size, otherwise, it’s constrained only by the maximum frame size. + const send_buffer_size = 3 * frame_size * options.message_count_max; + const send_buffer = try allocator.alloc(u8, send_buffer_size); + errdefer allocator.free(send_buffer); + + var publish_confirms: Confirms = try Confirms.init(allocator, options.message_count_max); + errdefer publish_confirms.deinit(allocator); + + return .{ + .io = options.io, + .receive_buffer = ReceiveBuffer.init(receive_buffer), + .send_buffer = SendBuffer.init(send_buffer), + .reply_timeout_ticks = options.reply_timeout_ticks, + .publish_confirms = publish_confirms, + }; + } + + pub fn deinit( + self: *Client, + allocator: std.mem.Allocator, + ) void { + if (self.fd) |fd| { + self.io.close_socket(fd); + self.fd = null; + } + self.publish_confirms.deinit(allocator); + allocator.free(self.send_buffer.buffer); + allocator.free(self.receive_buffer.buffer); + } + + pub fn connect(self: *Client, callback: Callback, options: ConnectOptions) !void { + assert(self.fd == null); + assert(self.awaiter == .none); + assert(self.send_buffer.state == .idle); + assert(self.action == .none); + self.action = .{ .connect = .{ + .options = options, + .phase = .dial, + .callback = callback, + } }; + + self.fd = try self.io.open_socket_tcp( + options.host.ip.family(), + .{ + // Keeping the default value. + // Large buffers can cause latency issues. + .sndbuf = 0, + .rcvbuf = 0, + .keepalive = if (constants.tcp_keepalive) .{ + .keepidle = constants.tcp_keepidle, + .keepintvl = constants.tcp_keepintvl, + .keepcnt = constants.tcp_keepcnt, + } else null, + .user_timeout_ms = constants.tcp_user_timeout_ms, + .nodelay = constants.tcp_nodelay, + }, + ); + errdefer self.io.close_socket(self.fd); + + self.io.connect( + *Client, + self, + struct { + fn continuation( + context: *Client, + completion: *IO.Completion, + result: IO.ConnectError!void, + ) void { + _ = completion; + _ = result catch fatal("Connection refused.", .{}); + assert(context.action == .connect); + assert(context.action.connect.phase == .dial); + + // Start receiving and send the AMQP protocol header: + context.receive(); + + const encoder = context.send_buffer.encoder(); + encoder.write_bytes(protocol.protocol_header); + context.action.connect.phase = .handshake; + context.send_and_await_reply(.global, &connect_dispatch); + } + }.continuation, + &self.receive_completion, + self.fd.?, + options.host, + ); + } + + fn connect_dispatch(self: *Client, reply: spec.ClientMethod) Decoder.Error!void { + assert(self.awaiter == .none); + assert(self.send_buffer.state == .idle); + assert(self.action == .connect); + const connection_options = self.action.connect.options; + + switch (reply) { + .connection_start => |args| { + assert(self.action.connect.phase == .handshake); + + log.info("Connection start received:", .{}); + log.info("version {}.{}", .{ args.version_major, args.version_minor }); + log.info("locales {s}", .{args.locales}); + log.info("mechanisms {s}", .{args.mechanisms}); + try log_table("server_properties", args.server_properties); + + if (args.version_major != protocol.version.major or + args.version_minor != protocol.version.minor) + { + fatal("Unsuported AMQP server version {}.{}", .{ + args.version_major, + args.version_minor, + }); + } + + if (std.mem.indexOfPosLinear( + u8, + args.mechanisms, + 0, + types.SASLPlainAuth.mechanism, + ) == null) { + fatal( + \\AMQP server does not support {s} authentication. + \\Supported methods: {s}. + , .{ + types.SASLPlainAuth.mechanism, + args.mechanisms, + }); + } + + const plain_auth: types.SASLPlainAuth = .{ + .user_name = connection_options.user_name, + .password = connection_options.password, + }; + const method: spec.ServerMethod = .{ .connection_start_ok = .{ + .client_properties = connection_options.properties.table(), + .mechanism = types.SASLPlainAuth.mechanism, + .response = plain_auth.response(), + .locale = connection_options.locale orelse first: { + var iterator = std.mem.splitScalar(u8, args.locales, ' '); + break :first iterator.next().?; + }, + } }; + + const encoder = self.send_buffer.encoder(); + method.encode(.global, encoder); + self.action.connect.phase = .auth; + self.send_and_await_reply(.global, &connect_dispatch); + }, + .connection_secure => fatal( + "Connection secure not supported.", + .{}, + ), + .connection_tune => |args| { + assert(self.action.connect.phase == .auth); + + log.info("Connection tune received:", .{}); + log.info("channel_max {}", .{args.channel_max}); + log.info("frame_max {}", .{args.frame_max}); + log.info("heartbeat {}", .{args.heartbeat}); + // Zero indicates no specified limit. + assert(args.frame_max == 0 or args.frame_max >= frame_min_size); + maybe(args.channel_max == 0); + maybe(args.heartbeat == 0); + + const encoder = self.send_buffer.encoder(); + + // Since `tune-ok` has no reply (send-and-forget), + // we can flush it together with `open`. + const method_tune_ok: spec.ServerMethod = .{ + .connection_tune_ok = .{ + .channel_max = 1, + // Don't override `frame_max`. RabbitMQ 4.1 requires frame sizes larger + // than those specified in the AMQP spec. + // https://www.rabbitmq.com/blog/2025/04/15/rabbitmq-4.1.0-is-released#initial-amqp-0-9-1-maximum-frame-size + .frame_max = args.frame_max, + .heartbeat = if (args.heartbeat == 0) + // Zero means the server does not want a heartbeat. + 0 + else + connection_options.heartbeat_seconds orelse args.heartbeat, + }, + }; + method_tune_ok.encode(.global, encoder); + + const method_open: spec.ServerMethod = .{ .connection_open = .{ + .virtual_host = connection_options.vhost, + } }; + method_open.encode(.global, encoder); + self.action.connect.phase = .connection_open; + self.send_and_await_reply(.global, &connect_dispatch); + }, + .connection_open_ok => { + assert(self.action.connect.phase == .connection_open); + + const method: spec.ServerMethod = .{ .channel_open = .{} }; + method.encode(.current, self.send_buffer.encoder()); + self.action.connect.phase = .channel_open; + self.send_and_await_reply(.current, &connect_dispatch); + }, + .channel_open_ok => { + assert(self.action.connect.phase == .channel_open); + + // Enabling the `confirm` mode on the channel. + // https://www.rabbitmq.com/docs/confirms#publisher-confirms + const method: spec.ServerMethod = .{ .confirm_select = .{ .nowait = false } }; + method.encode(.current, self.send_buffer.encoder()); + self.action.connect.phase = .confirm_select; + self.send_and_await_reply(.current, &connect_dispatch); + }, + .confirm_select_ok => { + assert(self.action.connect.phase == .confirm_select); + + const callback = self.action.connect.callback; + self.action = .none; + callback(self); + }, + else => fatal( + "Unexpected AMQP method received during connection: {s}", + .{@tagName(reply)}, + ), + } + } + + pub fn exchange_declare( + self: *Client, + callback: Callback, + options: ExchangeDeclareOptions, + ) void { + assert(self.action == .none); + self.action = .{ .exchange_declare = callback }; + + const method: spec.ServerMethod = .{ + .exchange_declare = .{ + .exchange = options.exchange, + .internal = options.internal, + .passive = options.passive, + .durable = options.durable, + .type = options.type, + .auto_delete = options.auto_delete, + .no_wait = false, // Always await the reply. + .arguments = null, + }, + }; + method.encode(.current, self.send_buffer.encoder()); + self.send_and_await_reply( + .current, + &struct { + fn dispatch(context: *Client, reply: spec.ClientMethod) Decoder.Error!void { + assert(reply == .exchange_declare_ok); + assert(context.action == .exchange_declare); + const exchange_declare_callback = context.action.exchange_declare; + context.action = .none; + exchange_declare_callback(context); + } + }.dispatch, + ); + } + + pub fn queue_declare(self: *Client, callback: Callback, options: QueueDeclareOptions) void { + assert(self.action == .none); + self.action = .{ .queue_declare = callback }; + + const method: spec.ServerMethod = .{ + .queue_declare = .{ + .queue = options.queue, + .passive = options.passive, + .durable = options.durable, + .exclusive = options.exclusive, + .auto_delete = options.auto_delete, + .no_wait = false, // Always await the reply. + .arguments = options.arguments.table(), + }, + }; + method.encode(.current, self.send_buffer.encoder()); + self.send_and_await_reply( + .current, + &struct { + fn dispatch(context: *Client, reply: spec.ClientMethod) Decoder.Error!void { + assert(reply == .queue_declare_ok); + assert(context.action == .queue_declare); + + const queue_declare_callback = context.action.queue_declare; + context.action = .none; + queue_declare_callback(context); + } + }.dispatch, + ); + } + + /// Enqueue a message to be sent by `publish_send()`. + pub fn publish_enqueue(self: *Client, options: BasicPublishOptions) void { + assert(self.awaiter == .none); + if (self.action == .none) self.action = .{ .publish_enqueue = .{} }; + + assert(self.action == .publish_enqueue); + self.action.publish_enqueue.count += 1; + + // To send a message with metadata and payload, the following `Frames` must be written: + const encoder = self.send_buffer.encoder(); + + // 1. Method frame — contains the `Basic.Publish` method arguments. + const method: spec.ServerMethod = .{ .basic_publish = .{ + .exchange = options.exchange, + .routing_key = options.routing_key, + .mandatory = options.mandatory, + .immediate = options.immediate, + } }; + method.encode(.current, encoder); + + // 2. Header frame — contains the `Basic` properties and custom headers. + encoder.begin_frame(.{ + .type = .header, + .channel = .current, + }); + encoder.begin_header(.{ + .class = method.method_header().class, + .weight = 0, + }); + options.properties.encode(encoder); + encoder.finish_frame(.header); + + if (options.body) |body| { + // 3. Body frame (optional) — contains the message payload. + // This could be split into N frames, but we only support single-frame bodies. + encoder.begin_frame(.{ + .type = .body, + .channel = .current, + }); + const body_size = body.write(encoder.buffer[encoder.index..]); + encoder.index += body_size; + encoder.finish_header(body_size); + encoder.finish_frame(.body); + } else { + // No body frame. + encoder.finish_header(0); + } + } + + /// Sends all messages enqueued so far by `publish_enqueue()`. + pub fn publish_send( + self: *Client, + callback: Callback, + ) void { + assert(self.awaiter == .none); + assert(self.send_buffer.state == .writing); + assert(self.action == .publish_enqueue); + assert(self.action.publish_enqueue.count > 0); + + self.publish_confirms.wait(self.action.publish_enqueue.count); + self.action = .{ .publish = .{ .callback = callback } }; + self.send_and_forget(&struct { + fn dispatch(context: *Client) void { + assert(context.action == .publish); + assert(context.action.publish.phase == .sending); + context.action.publish.phase = .awaiting_confirmation; + } + }.dispatch); + } + + /// Uses a polling model to retrieve a message (`Basic.Get`). + /// The callback is invoked with either `null` properties if the queue is empty, + /// or with the properties of the first available message. + /// N.B.: The message body is not retrieved. + /// The method `get_message_body` **MUST** be called if `has_body == true`. + pub fn get_message( + self: *Client, + callback: GetMessagePropertiesCallback, + options: GetMessageOptions, + ) void { + assert(self.action == .none); + self.action = .{ .get_message = callback }; + + const method: spec.ServerMethod = .{ .basic_get = .{ + .queue = options.queue, + .no_ack = options.no_ack, + } }; + method.encode(.current, self.send_buffer.encoder()); + self.send_and_await_reply(.current, &get_message_dispatch); + } + + fn get_message_dispatch(self: *Client, reply: spec.ClientMethod) Decoder.Error!void { + assert(self.action == .get_message); + assert(self.awaiter == .none); + switch (reply) { + .basic_get_empty => { + const get_header_callback = self.action.get_message; + self.action = .none; + try get_header_callback(self, null); + }, + .basic_get_ok => |get_ok| self.awaiter = .{ .await_content_header = .{ + .channel = .current, + .delivery_tag = get_ok.delivery_tag, + .message_count = get_ok.message_count, + .callback = &struct { + fn dispatch( + context: *Client, + delivery_tag: u64, + message_count: u32, + header: Decoder.Header, + ) Decoder.Error!void { + assert(context.action == .get_message); + assert(header.body_size <= protocol.frame_min_size); + const properties = try Decoder.BasicProperties.decode( + header.property_flags, + header.properties, + ); + const get_message_callback = context.action.get_message; + const has_body = header.body_size > 0; + context.action = if (has_body) .{ + .message_body_pending = .{ + .body_size = header.body_size, + }, + } else .none; + try get_message_callback(context, .{ + .delivery_tag = delivery_tag, + .message_count = message_count, + .properties = properties, + .has_body = has_body, + }); + } + }.dispatch, + } }, + else => fatal( + "Unexpected AMQP method received during get_message: {s}", + .{@tagName(reply)}, + ), + } + } + + pub fn get_message_body( + self: *Client, + callback: GetMessageBodyCallback, + ) void { + assert(self.action == .message_body_pending); + assert(self.action.message_body_pending.body_size <= protocol.frame_min_size); + const body_size = self.action.message_body_pending.body_size; + self.action = .{ .get_message_body = .{ + .body_size = body_size, + .callback = callback, + } }; + self.awaiter = .{ .await_body = .{ + .channel = .current, + .callback = &struct { + fn dispatch( + context: *Client, + body: []const u8, + ) Decoder.Error!void { + assert(context.action == .get_message_body); + assert(context.action.get_message_body.body_size == body.len); + const get_message_body_callback = context.action.get_message_body.callback; + context.action = .none; + try get_message_body_callback(context, body); + } + }.dispatch, + } }; + } + + /// Rejects a message. + pub fn nack(self: *Client, callback: Callback, options: BasicNackOptions) void { + assert(self.awaiter == .none); + assert(self.action == .none); + self.action = .{ .nack = callback }; + + const method: spec.ServerMethod = .{ .basic_nack = .{ + .delivery_tag = options.delivery_tag, + .requeue = options.requeue, + .multiple = options.multiple, + } }; + method.encode(.current, self.send_buffer.encoder()); + self.send_and_forget(&struct { + fn dispatch(context: *Client) void { + assert(context.action == .nack); + const nack_callback = context.action.nack; + context.action = .none; + nack_callback(context); + } + }.dispatch); + } + + fn send_and_await_reply( + self: *Client, + channel: Channel, + callback: *const fn (self: *Client, reply: spec.ClientMethod) Decoder.Error!void, + ) void { + assert(self.awaiter == .none); + assert(self.send_buffer.state == .writing); + self.awaiter = .{ .send_and_await_reply = .{ + .channel = channel, + .state = .sending, + .callback = callback, + } }; + self.send(); + } + + fn send_and_forget( + self: *Client, + callback: *const fn (self: *Client) void, + ) void { + assert(self.awaiter == .none); + assert(self.send_buffer.state == .writing); + self.awaiter = .{ .send_and_forget = callback }; + self.send(); + } + + fn send(self: *Client) void { + switch (self.awaiter) { + .send_and_forget, + .send_and_await_reply, + => { + self.io.send( + *Client, + self, + send_callback, + &self.send_completion, + self.fd.?, + self.send_buffer.flush(), + ); + }, + .none, .await_content_header, .await_body => unreachable, + } + } + + fn send_callback( + self: *Client, + completion: *IO.Completion, + result: IO.SendError!usize, + ) void { + _ = completion; + assert(self.awaiter == .send_and_forget or self.awaiter == .send_and_await_reply); + const size = result catch |err| fatal( + "Network error: {s}", + .{@errorName(err)}, + ); + if (self.send_buffer.remaining(size)) |remaining| { + return self.io.send( + *Client, + self, + send_callback, + &self.send_completion, + self.fd.?, + remaining, + ); + } + assert(self.send_buffer.state == .idle); + + switch (self.awaiter) { + .send_and_forget => |callback| { + self.awaiter = .none; + callback(self); + }, + .send_and_await_reply => |*awaiter| { + assert(awaiter.state == .sending); + awaiter.state = .{ .awaiting = .{} }; + }, + .none, .await_content_header, .await_body => unreachable, + } + } + + fn receive(self: *Client) void { + assert(self.fd != null); + assert(self.receive_buffer.state == .idle); + self.io.recv( + *Client, + self, + receive_callback, + &self.receive_completion, + self.fd.?, + self.receive_buffer.begin_receive(), + ); + } + + fn receive_callback( + self: *Client, + completion: *IO.Completion, + result: IO.RecvError!usize, + ) void { + _ = completion; + assert(self.receive_buffer.state == .receiving); + + const size: usize = result catch |err| fatal("Network error: {}.", .{err}); + // No bytes received means that the AMQP server closed the connection. + if (size == 0) fatal( + "The server closed the connection unexpectedly.", + .{}, + ); + + var decoder = self.receive_buffer.end_receive(size); + assert(decoder.buffer.len > 0); + var processed_index_last: usize = 0; + while (!decoder.empty()) { + self.process(&decoder) catch |err| switch (err) { + error.BufferExhausted => { + // The buffer ended before the entire frame could be parsed. + break; + }, + error.Unexpected => fatal( + "Invalid command received.", + .{}, + ), + }; + processed_index_last = decoder.index; + } + assert(processed_index_last <= decoder.buffer.len); + const receive_buffer = self.receive_buffer.end_decode(processed_index_last); + + self.io.recv( + *Client, + self, + receive_callback, + &self.receive_completion, + self.fd.?, + receive_buffer, + ); + } + + fn process(self: *Client, decoder: *Decoder) Decoder.Error!void { + const frame_header = try decoder.read_frame_header(); + switch (frame_header.type) { + .method => { + const method_header = try decoder.read_method_header(); + try self.process_method(frame_header, method_header, decoder); + }, + .header => { + const header = try decoder.read_header(frame_header.size); + try self.process_header(frame_header, header); + }, + .body => { + const body = try decoder.read_body(frame_header.size); + try self.process_body(frame_header, body); + }, + .heartbeat => { + try decoder.read_frame_end(); + self.send_heartbeat(); + }, + } + } + + fn process_method( + self: *Client, + frame_header: Decoder.FrameHeader, + method_header: protocol.MethodHeader, + decoder: *Decoder, + ) Decoder.Error!void { + assert(frame_header.type == .method); + const client_method = try spec.ClientMethod.decode(method_header, decoder); + switch (client_method) { + inline .connection_close, .channel_close => |close_reason, tag| { + const error_code: ErrorCodes = @enumFromInt(close_reason.reply_code); + if (std.meta.intToEnum( + spec.ServerMethod.Tag, + @as(u32, @bitCast(protocol.MethodHeader{ + .class = close_reason.class_id, + .method = close_reason.method_id, + })), + ) catch null) |server_method| { + fatal( + "Operation cannot be completed: method={s} {s}={s}", + .{ + @tagName(server_method), + @tagName(error_code), + close_reason.reply_text, + }, + ); + } else { + fatal( + switch (tag) { + .connection_close => "Connection closed: {s}={s}", + .channel_close => "Channel closed: {s}={s}", + else => comptime unreachable, + }, + .{ + @tagName(error_code), + close_reason.reply_text, + }, + ); + } + }, + .basic_return => |basic_return| { + const soft_error: ErrorCodes = @enumFromInt(basic_return.reply_code); + fatal( + "Message cannot be delivered: exchange=\"{s}\" routing_key=\"{s}\" {s}={s}", + .{ + basic_return.exchange, + basic_return.routing_key, + @tagName(soft_error), + basic_return.reply_text, + }, + ); + }, + // Channel flow is not supported, but the command can be ignored. + // It's up to the server to evict clients that do not respect + // the flow control directive. + .channel_flow => |channel_flow| return log.warn( + "Channel flow ignored: active={}", + .{channel_flow.active}, + ), + .connection_blocked, + .connection_unblocked, + .basic_deliver, + => fatal( + "AMQP operation not supported: {s} channel={}", + .{ @tagName(client_method), frame_header.channel }, + ), + .basic_ack => |basic_ack| { + // Processing acks in "publish confirms" mode. + if (self.action == .publish) { + const publish = &self.action.publish; + // Confirmations can be received while sending a batch of messages. + if (publish.phase == .sending) assert(self.awaiter == .send_and_forget); + if (self.publish_confirms.confirm(basic_ack)) { + assert(self.awaiter == .none); + assert(publish.phase == .awaiting_confirmation); + const publish_callback = publish.callback; + self.action = .none; + publish_callback(self); + } + return; + } + }, + .basic_nack => fatal( + "Message was rejected by the AMQP server: {s} channel={}", + .{ @tagName(client_method), frame_header.channel }, + ), + else => {}, + } + + switch (self.awaiter) { + .send_and_await_reply => |awaiter| { + if (awaiter.state == .awaiting and + awaiter.channel == frame_header.channel) + { + self.awaiter = .none; + return try awaiter.callback(self, client_method); + } + }, + else => {}, + } + + fatal( + "Unexpected AMQP method: {s} channel={}", + .{ @tagName(client_method), frame_header.channel }, + ); + } + + fn process_header( + self: *Client, + frame_header: Decoder.FrameHeader, + header: Decoder.Header, + ) Decoder.Error!void { + assert(frame_header.type == .header); + maybe(header.body_size == 0); + if (self.awaiter == .await_content_header) { + const awaiter = self.awaiter.await_content_header; + if (frame_header.channel == awaiter.channel) { + self.awaiter = .none; + return try awaiter.callback( + self, + awaiter.delivery_tag, + awaiter.message_count, + header, + ); + } + } + fatal( + "Unexpected message header: channel={} class={} body_size={}", + .{ + frame_header.channel, + header.class, + header.body_size, + }, + ); + } + + fn process_body( + self: *Client, + frame_header: Decoder.FrameHeader, + body: []const u8, + ) Decoder.Error!void { + assert(frame_header.type == .body); + assert(body.len > 0); + if (self.awaiter == .await_body) { + const awaiter = self.awaiter.await_body; + if (frame_header.channel == awaiter.channel) { + self.awaiter = .none; + return try awaiter.callback( + self, + body, + ); + } + } + fatal( + "Unexpected message body: channel={} body_size={}", + .{ + frame_header.channel, + body.len, + }, + ); + } + + fn send_heartbeat(self: *Client) void { + assert(self.fd != null); + if (self.heartbeat == .sending) return; + + log.info("Heartbeat", .{}); + + const heartbeat_message: [8]u8 = comptime heartbeat: { + var buffer: [8]u8 = undefined; + var encoder = Encoder.init(&buffer); + encoder.begin_frame(.{ + .type = .heartbeat, + .channel = .global, + }); + encoder.finish_frame(.heartbeat); + assert(encoder.index == buffer.len); + break :heartbeat buffer; + }; + + assert(self.heartbeat == .idle); + self.heartbeat = .{ .sending = undefined }; + self.io.send( + *Client, + self, + on_heartbeat_callback, + &self.heartbeat.sending, + self.fd.?, + &heartbeat_message, + ); + } + + fn on_heartbeat_callback( + self: *Client, + completion: *IO.Completion, + result: IO.SendError!usize, + ) void { + assert(self.heartbeat == .sending); + self.heartbeat = .idle; + _ = completion; + _ = result catch |err| fatal("Network error: {}", .{err}); + } + + pub fn tick(self: *Client) void { + const duration_ticks: u64 = switch (self.awaiter) { + .none, .send_and_forget => return, + .send_and_await_reply => |*awaiter| ticks: { + if (awaiter.state == .sending) return; + assert(awaiter.state == .awaiting); + awaiter.state.awaiting.duration_ticks += 1; + break :ticks awaiter.state.awaiting.duration_ticks; + }, + inline .await_content_header, .await_body => |*awaiter| ticks: { + awaiter.duration_ticks += 1; + break :ticks awaiter.duration_ticks; + }, + }; + assert(self.action != .none); + if (duration_ticks > self.reply_timeout_ticks) { + fatal( + "Operation {s} timed out. No reply received from the AMQP server.", + .{@tagName(self.action)}, + ); + } + } +}; + +const ReceiveBuffer = struct { + buffer: []u8, + state: union(enum) { + idle, + receiving: struct { + non_consumed: usize, + }, + decoding: struct { + size: usize, + }, + }, + + fn init(buffer: []u8) ReceiveBuffer { + assert(buffer.len >= frame_min_size); + return .{ + .buffer = buffer, + .state = .idle, + }; + } + + fn begin_receive(self: *ReceiveBuffer) []u8 { + switch (self.state) { + .idle => { + self.state = .{ + .receiving = .{ .non_consumed = 0 }, + }; + return self.buffer; + }, + .decoding, .receiving => unreachable, + } + } + + fn end_receive(self: *ReceiveBuffer, size: usize) Decoder { + assert(size > 0); + switch (self.state) { + .idle, .decoding => unreachable, + .receiving => |receive_state| { + const total_size = size + receive_state.non_consumed; + assert(total_size <= self.buffer.len); + maybe(receive_state.non_consumed == 0); + self.state = .{ .decoding = .{ .size = total_size } }; + return Decoder.init(self.buffer[0..total_size]); + }, + } + } + + fn end_decode(self: *ReceiveBuffer, processed_last_index: usize) []u8 { + maybe(processed_last_index == 0); + assert(self.state == .decoding); + const decoding_state = self.state.decoding; + if (processed_last_index == decoding_state.size) { + self.state = .{ + .receiving = .{ .non_consumed = 0 }, + }; + return self.buffer; + } + + assert(processed_last_index < decoding_state.size); + const remaining = self.buffer[processed_last_index..decoding_state.size]; + assert(remaining.len < self.buffer.len); + if (processed_last_index > 0) { + stdx.copy_left(.inexact, u8, self.buffer, remaining); + } + + self.state = .{ + .receiving = .{ .non_consumed = remaining.len }, + }; + return self.buffer[remaining.len..]; + } +}; + +const SendBuffer = struct { + buffer: []u8, + state: union(enum) { + idle, + writing: Encoder, + sending: struct { + size: usize, + progress: usize, + }, + }, + + fn init(buffer: []u8) SendBuffer { + assert(buffer.len >= frame_min_size); + return .{ + .buffer = buffer, + .state = .idle, + }; + } + + fn encoder(self: *SendBuffer) *Encoder { + switch (self.state) { + .idle => { + self.state = .{ .writing = Encoder.init(self.buffer) }; + return &self.state.writing; + }, + .writing => |*current| return current, + .sending => unreachable, + } + } + + fn flush(self: *SendBuffer) []const u8 { + switch (self.state) { + .idle, .sending => unreachable, + .writing => |*current| { + assert(current.index > 0); + const size = current.index; + self.state = .{ .sending = .{ + .size = size, + .progress = 0, + } }; + + return self.buffer[0..size]; + }, + } + } + + fn remaining(self: *SendBuffer, written_bytes: usize) ?[]const u8 { + switch (self.state) { + .idle, .writing => unreachable, + .sending => |*send_state| { + send_state.progress += written_bytes; + if (send_state.progress == send_state.size) { + self.state = .idle; + return null; + } + + assert(send_state.progress < send_state.size); + return self.buffer[send_state.progress..send_state.size]; + }, + } + } +}; + +fn log_table(name: []const u8, table: Decoder.Table) Decoder.Error!void { + var iterator = table.iterator(); + while (try iterator.next()) |entry| { + switch (entry.value) { + .string => |str| log.info("{s} {s}:{s}", .{ + name, + entry.key, + str, + }), + .field_table => |field_table| try log_table(entry.key, field_table), + inline else => |any| log.info("{s} {s}:{any}", .{ + name, + entry.key, + any, + }), + } + } +} + +/// Implements the RabbitMQ Publisher Confirms acknowledgment logic. +/// Both the broker and the client count messages. +/// Counting starts at 1 on the first `confirm_select`. +/// https://www.rabbitmq.com/docs/confirms#publisher-confirms +/// https://www.rabbitmq.com/blog/2011/02/10/introducing-publisher-confirms +const Confirms = struct { + processed: std.DynamicBitSetUnmanaged, + state: union(enum) { + idle: struct { + sequence: u64, + }, + waiting: struct { + count: u32, + sequence_initial: u64, + }, + }, + + fn init(allocator: std.mem.Allocator, capacity: u32) !Confirms { + assert(capacity > 0); + const processed = try std.DynamicBitSetUnmanaged.initEmpty(allocator, capacity); + return .{ + .state = .{ .idle = .{ .sequence = 1 } }, + .processed = processed, + }; + } + + fn deinit(self: *Confirms, allocator: std.mem.Allocator) void { + assert(self.state == .idle); + assert(self.processed.count() == 0); + self.processed.deinit(allocator); + } + + /// Waits until `count` published messages have been acknowledged by the server. + fn wait(self: *Confirms, count: u32) void { + assert(count > 0); + assert(count <= self.processed.capacity()); + assert(self.processed.count() == 0); + assert(self.state == .idle); + + const sequence = self.state.idle.sequence; + assert(sequence > 0); + self.state = .{ .waiting = .{ + .count = count, + .sequence_initial = sequence, + } }; + } + + /// Confirms that the server has received and fsync'ed a batch of published messages. + /// Returns `true` if there are no more messages pending acknowledgment. + fn confirm(self: *Confirms, ack: std.meta.TagPayload(spec.ClientMethod, .basic_ack)) bool { + assert(self.state == .waiting); + + const state = self.state.waiting; + assert(state.count > 0); + assert(state.sequence_initial > 0); + + // The server must not use a zero value for delivery tags. + // Zero is reserved for client use, meaning "all messages so far received". + // https://www.rabbitmq.com/docs/specification#rules + assert(ack.delivery_tag > 0); + assert(ack.delivery_tag >= state.sequence_initial); + assert(ack.delivery_tag < state.sequence_initial + state.count); + + const range: std.bit_set.Range = range: { + const index = ack.delivery_tag - state.sequence_initial; + // Published messages will be confirmed only once. + assert(!self.processed.isSet(index)); + const start: usize = start: { + if (!ack.multiple) break :start index; // Single message. + + // Finds the first unconfirmed delivery tag to acknowledge + // all pending messages up to `ack.delivery_tag`. + var iterator = self.processed.iterator(.{ + .direction = .forward, + .kind = .unset, + }); + const unconfirmed_index = iterator.next().?; + assert(unconfirmed_index <= index); + break :start unconfirmed_index; + }; + break :range .{ + .start = start, + .end = index + 1, // +1 to be inclusive. + }; + }; + self.processed.setRangeValue(range, true); + + log.debug("basic_ack: delivery_tag={} multiple={} count={} confirmed={}", .{ + ack.delivery_tag, + ack.multiple, + state.count, + self.processed.count(), + }); + + if (self.processed.count() == state.count) { + self.processed.unsetAll(); + self.state = .{ .idle = .{ + .sequence = state.sequence_initial + state.count, + } }; + return true; + } + return false; + } +}; + +const testing = std.testing; + +test "amqp: SendBuffer" { + const buffer = try testing.allocator.alloc(u8, frame_min_size); + defer testing.allocator.free(buffer); + + var prng: stdx.PRNG = stdx.PRNG.from_seed_testing(); + var send_buffer = SendBuffer.init(buffer); + for (0..4096) |_| { + const Element = u64; + const element_count = prng.range_inclusive( + usize, + 1, + @divExact(buffer.len, @sizeOf(Element)), + ); + // Zero the unused memory so we can assert it wasn't modified by the encoder. + @memset(buffer[element_count * @sizeOf(Element) ..], 0); + + try testing.expect(send_buffer.state == .idle); + for (0..element_count) |index| { + var encoder = send_buffer.encoder(); + try testing.expect(send_buffer.state == .writing); + try testing.expectEqual(index * @sizeOf(Element), encoder.index); + + var element: Element = undefined; + prng.fill(std.mem.asBytes(&element)); + encoder.write_int(Element, element); + } + + const flush_slice = send_buffer.flush(); + try testing.expect(send_buffer.state == .sending); + try testing.expectEqual(element_count * @sizeOf(Element), flush_slice.len); + try testing.expectEqualSlices( + u8, + buffer[0 .. element_count * @sizeOf(Element)], + flush_slice, + ); + try testing.expect(stdx.zeroed(buffer[element_count * @sizeOf(Element) ..])); + + var progress: usize = 0; + while (progress < flush_slice.len) { + const remaining_count = flush_slice.len - progress; + const written = prng.range_inclusive(usize, 1, remaining_count); + progress += written; + + if (send_buffer.remaining(written)) |remaining_slice| { + try testing.expectEqual(flush_slice.len - progress, remaining_slice.len); + try testing.expectEqualSlices( + u8, + flush_slice[progress..], + remaining_slice, + ); + } else { + try testing.expect(send_buffer.state == .idle); + try testing.expectEqual(flush_slice.len, progress); + } + } + } +} + +test "amqp: ReceiveBuffer" { + const ratio = stdx.PRNG.ratio; + + const buffer = try testing.allocator.alloc(u8, frame_min_size); + defer testing.allocator.free(buffer); + + var receive_buffer = ReceiveBuffer.init(buffer); + try testing.expect(receive_buffer.state == .idle); + + const receive_slice = receive_buffer.begin_receive(); + try testing.expect(receive_buffer.state == .receiving); + try testing.expectEqual(buffer.len, receive_slice.len); + + var prng = stdx.PRNG.from_seed_testing(); + prng.fill(receive_slice); + + var decoded_remain: usize = 0; + for (0..4096) |_| { + const receive_size: usize = prng.range_inclusive(usize, 1, buffer.len - decoded_remain); + const size = receive_size + decoded_remain; + + const decoder = receive_buffer.end_receive(receive_size); + try testing.expect(receive_buffer.state == .decoding); + try testing.expectEqual(size, decoder.buffer.len); + try testing.expectEqualSlices(u8, buffer[0..size], decoder.buffer); + + const decoded_count = if (prng.chance(ratio(10, 100))) size else prng.range_inclusive( + usize, + 1, + size, + ); + decoded_remain = size - decoded_count; + const receive_slice_next = receive_buffer.end_decode(decoded_count); + try testing.expect(receive_buffer.state == .receiving); + try testing.expectEqual(buffer.len - decoded_remain, receive_slice_next.len); + try testing.expectEqualSlices(u8, buffer[decoded_remain..], receive_slice_next); + } +} + +test "amqp: Confirms" { + // Confirmations can be out of order, for example: + // Pending tags Ack + // [1,2,3,4,5,6,7,8,9,10] -> tag=1 multiple=true + // [2,3,4,5,6,7,8,9,10] -> tag=3 multiple=false + // [2,4,5,6,7,8,9,10] -> tag=2 multiple=false + // [4,5,6,7,8,9,10] -> tag=5 multiple=true + // [6,7,8,9,10] -> tag=7 multiple=false + // [6,8,9,10] -> tag=10 multiple=true + // [] -> finished + var confirms = try Confirms.init(testing.allocator, 10); + defer confirms.deinit(testing.allocator); + + try testing.expect(confirms.state == .idle); + try testing.expect(confirms.state.idle.sequence == 1); + + confirms.wait(10); + try testing.expect(confirms.state == .waiting); + try testing.expectEqual(@as(usize, 0), confirms.processed.count()); + + try testing.expectEqual(false, confirms.confirm(.{ .delivery_tag = 1, .multiple = true })); + try testing.expect(confirms.state == .waiting); + try testing.expectEqual(@as(usize, 1), confirms.processed.count()); + + try testing.expectEqual(false, confirms.confirm(.{ .delivery_tag = 3, .multiple = false })); + try testing.expect(confirms.state == .waiting); + try testing.expectEqual(@as(usize, 2), confirms.processed.count()); + + try testing.expectEqual(false, confirms.confirm(.{ .delivery_tag = 2, .multiple = false })); + try testing.expect(confirms.state == .waiting); + try testing.expectEqual(@as(usize, 3), confirms.processed.count()); + + try testing.expectEqual(false, confirms.confirm(.{ .delivery_tag = 5, .multiple = true })); + try testing.expect(confirms.state == .waiting); + try testing.expectEqual(@as(usize, 5), confirms.processed.count()); + + try testing.expectEqual(false, confirms.confirm(.{ .delivery_tag = 7, .multiple = false })); + try testing.expect(confirms.state == .waiting); + try testing.expectEqual(@as(usize, 6), confirms.processed.count()); + + try testing.expectEqual(true, confirms.confirm(.{ .delivery_tag = 10, .multiple = true })); + try testing.expect(confirms.state == .idle); + try testing.expectEqual(@as(usize, 0), confirms.processed.count()); + try testing.expect(confirms.state.idle.sequence == 11); +} + +test "amqp: spec" { + // Sanity check to ensure the spec hasn't been manually modified. + // Checking the hash to avoid downloading the XML from external sources during CI. + try testing.expectEqual( + 329829848237589433604725248571833881494, + vsr.checksum(@embedFile("amqp/spec.zig")), + ); +} diff --git a/ocam/src/cdc/amqp/protocol.zig b/ocam/src/cdc/amqp/protocol.zig new file mode 100644 index 00000000..dd83dd95 --- /dev/null +++ b/ocam/src/cdc/amqp/protocol.zig @@ -0,0 +1,1284 @@ +///! Implements the AMQP (Advanced Message Queuing Protocol) 0.9.1 wire protocol. +///! https://www.amqp.org/sites/amqp.org/files/amqp0-9-1.zip +///! +///! The `Frame` is the basic unit of the AMQP protocol. Its minimum size is +///! 8 bytes, and the maximum size can be negotiated between the client and server. +///! +///! # Frame layout: +///! ┌────────┬──────────┬────────┐ ┌───────────────┐ ┌───────┐ +///! │ type │ channel │ size │ │ payload │ │ 0xCE │ +///! │ u8 │ u16 │ u32 │ │ variable size │ │ u8 │ +///! └────────┴──────────┴────────┘ └───────────────┘ └───────┘ +///! There are four frame types: "method", "header", "body", and "heartbeat". +///! Each frame type (except by heartbeat) has different types of payloads. +///! +///! # Method payload: +///! ┌──────────┬───────────┬─────────────────┐ +///! │ class_id │ method_id │ arguments │ +///! │ u16 │ u16 │ variable size │ +///! └──────────┴───────────┴─────────────────┘ +///! The `spec.zig` file contains declarations for all methods defined by the +///! specification and their expected arguments. +///! +///! # Header payload: +///! ┌──────────┬────────┬────────────┬────────────────┬─────────────────┐ +///! │ class_id │ weight │ body_size │ property_flags │ properties │ +///! │ u16 │ u16 │ u64 │ u16 │ variable size │ +///! └──────────┴────────┴────────────┴────────────────┴─────────────────┘ +///! Certain "method" frames are followed by a "header" frame. For example, in the `basic-publish` +///! method, the content header contains metadata about the message being published. The frame with +///! `type == header` always follows its corresponding `type == method` frame. +///! See `BasicProperties` for parsing the `property_flags` and `properties`. +///! +///! # Body payload: +///! ┌───────────────┐ +///! │ content │ +///! │ variable size │ +///! └───────────────┘ +///! The body frame contains the application-specific content of the message. +///! The body can be split across multiple frames if `body_size` exceeds the frame size, however we +///! only support single-frame bodies. +///! +///! # Endianness: +///! Integers are encoded in network byte order (big endian). +///! +const std = @import("std"); +const stdx = @import("stdx"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const KiB = stdx.KiB; + +const spec = @import("spec.zig"); + +pub const frame_min_size = spec.FRAME_MIN_SIZE; +pub const tcp_port_default = 5672; + +/// The major, minor, and revision numbers can take any value from 0 to 99 for official +/// specifications. +/// Major, minor, and revision numbers of 100 and above are reserved for internal testing +/// and development purposes. +pub const version = .{ + .major = 0, + .minor = 9, + .revision = 1, +}; + +/// The protocol header consists of the upper case letters "AMQP" +/// followed by the constant 0 and the AMQP version number. +pub const protocol_header: *const [8]u8 = "AMQP" ++ [_]u8{ + 0, + version.major, + version.minor, + version.revision, +}; + +pub const DeliveryMode = enum(u8) { + transient = 1, + persistent = 2, +}; + +pub const FrameEnd = enum(u8) { + value = spec.FRAME_END, +}; + +pub const FrameType = enum(u8) { + method = spec.FRAME_METHOD, + header = spec.FRAME_HEADER, + body = spec.FRAME_BODY, + heartbeat = spec.FRAME_HEARTBEAT, +}; + +pub const MethodHeader = packed struct(u32) { + class: u16, + method: u16, +}; + +pub const Channel = enum(u16) { + /// The channel number is 0 for all frames which are global to the connection. + global = 0, + /// Id of the current channel. + /// Supporting multiple channels is unnecessary, as messages are submitted in batches + /// through io_uring without concurrency. + current = 1, +}; + +pub const ErrorCodes = enum(u16) { + /// The client attempted to transfer content larger than the server could accept + /// at the present time. The client may retry at a later time. + ContentTooLarge = spec.SOFT_ERROR_CONTENT_TOO_LARGE, + /// Returned when RabbitMQ sends back with 'basic.return' when a + /// 'mandatory' message cannot be delivered to any queue. + NoRoute = spec.SOFT_ERROR_NO_ROUTE, + /// When the exchange cannot deliver to a consumer when the immediate flag is + /// set. As a result of pending data on the queue or the absence of any + /// consumers of the queue. + NoConsumers = spec.SOFT_ERROR_NO_CONSUMERS, + /// The client attempted to work with a server entity to which it has no + /// access due to security settings. + AccessRefused = spec.SOFT_ERROR_ACCESS_REFUSED, + /// The client attempted to work with a server entity that does not exist. + NotFound = spec.SOFT_ERROR_NOT_FOUND, + /// The client attempted to work with a server entity to which it has no + /// access because another client is working with it. + ResourceLocked = spec.SOFT_ERROR_RESOURCE_LOCKED, + /// The client requested a method that was not allowed because some precondition + /// failed. + PreconditionFailed = spec.SOFT_ERROR_PRECONDITION_FAILED, + /// An operator intervened to close the connection for some reason. The client + /// may retry at some later date. + ConnectionForced = spec.HARD_ERROR_CONNECTION_FORCED, + /// The client tried to work with an unknown virtual host. + InvalidPath = spec.HARD_ERROR_INVALID_PATH, + /// The sender sent a malformed frame that the recipient could not decode. + /// This strongly implies a programming error in the sending peer. + FrameError = spec.HARD_ERROR_FRAME_ERROR, + /// The sender sent a frame that contained illegal values for one or more + /// fields. This strongly implies a programming error in the sending peer. + SyntaxError = spec.HARD_ERROR_SYNTAX_ERROR, + /// The client sent an invalid sequence of frames, attempting to perform an + /// operation that was considered invalid by the server. This usually implies + /// a programming error in the client. + CommandInvalid = spec.HARD_ERROR_COMMAND_INVALID, + /// The client attempted to work with a channel that had not been correctly + /// opened. This most likely indicates a fault in the client layer. + ChannelError = spec.HARD_ERROR_CHANNEL_ERROR, + /// The peer sent a frame that was not expected, usually in the context of + /// a content header and body. This strongly indicates a fault in the peer's + /// content processing. + UnexpectedFrame = spec.HARD_ERROR_UNEXPECTED_FRAME, + /// The server could not complete the method because it lacked sufficient + /// resources. This may be due to the client creating too many of some type + /// of entity. + ResourceError = spec.HARD_ERROR_RESOURCE_ERROR, + /// The client tried to work with some entity in a manner that is prohibited + /// by the server, due to security settings or by some other criteria. + NotAllowed = spec.HARD_ERROR_NOT_ALLOWED, + /// The client tried to use functionality that is not implemented in the + /// server. + NotImplemented = spec.HARD_ERROR_NOT_IMPLEMENTED, + /// The server could not complete the method because of an internal error. + /// The server may require intervention by an operator in order to resume + /// normal operations. + InternalError = spec.HARD_ERROR_INTERNAL_ERROR, + + _, +}; + +pub const FieldValueTag = enum(u8) { + boolean = 't', + uint8 = 'B', + int8 = 'b', + uint16 = 'u', + int16 = 's', + uint32 = 'i', + int32 = 'I', + // Both `l` and `L` are decoded as signed integers by RabbitMQ: + // https://www.rabbitmq.com/amqp-0-9-1-errata#section_3 + // https://github.com/rabbitmq/rabbitmq-server/issues/1093#issuecomment-276351183 + int64 = 'l', + string = 'S', + timestamp = 'T', + field_table = 'F', + void = 'V', + + // We don't send or expect to receive these types from the AMQP server. + // Only user-defined tables would use them. + not_implemented_uint64 = 'L', + not_implemented_field_array = 'A', + not_implemented_float = 'f', + not_implemented_double = 'd', + not_implemented_decimal = 'D', + not_implemented_byte_array = 'x', +}; + +pub const Decoder = struct { + pub const Error = error{ + BufferExhausted, + Unexpected, + }; + + pub const FrameHeader = extern struct { + type: FrameType, + channel: Channel, + size: u32, + }; + + pub const Header = struct { + class: u16, + weight: u16, + body_size: u64, + property_flags: u16, + properties: []const u8, + }; + + pub const BasicProperties = BasicPropertiesType(.decode); + + /// `FieldValue` represents a `tag` + `value` pair as specified by the AMQP spec. + pub const FieldValue = FieldValueType(.decode); + + /// Allows iteration over the contents of an AMQP table read from the receive buffer. + pub const Table = struct { + pub const Iterator = struct { + decoder: Decoder, + + pub fn reset(self: *Iterator) void { + self.decoder.reset(); + } + + pub fn next(self: *Iterator) Decoder.Error!?struct { + key: []const u8, + value: FieldValue, + } { + if (self.decoder.empty()) return null; + return .{ + .key = try self.decoder.read_short_string(), + .value = try self.decoder.read_field(), + }; + } + }; + + length: u32, + pointer: [*]const u8, + + pub fn init(value: []const u8) Table { + assert(value.len <= std.math.maxInt(u32)); + return .{ + .length = @intCast(value.len), + .pointer = value.ptr, + }; + } + + pub fn slice(self: Table) []const u8 { + return self.pointer[0..self.length]; + } + + pub fn iterator(self: Table) Iterator { + return .{ + .decoder = Decoder.init(self.slice()), + }; + } + }; + + buffer: []const u8, + /// Invariants: index <= buffer.len + index: usize, + + pub fn init(buffer: []const u8) Decoder { + return .{ + .buffer = buffer, + .index = 0, + }; + } + + pub fn empty(self: *const Decoder) bool { + return self.index == self.buffer.len; + } + + pub fn read_int(self: *Decoder, comptime T: type) Error!T { + comptime assert(@typeInfo(T) == .int); + comptime assert(@typeInfo(T).int.signedness == .unsigned); + comptime assert(@sizeOf(T) == 1 or @sizeOf(T) == 2 or @sizeOf(T) == 4 or @sizeOf(T) == 8); + if (self.index + @sizeOf(T) > self.buffer.len) return error.BufferExhausted; + defer { + self.index += @sizeOf(T); + assert(self.index <= self.buffer.len); + } + + return std.mem.readInt(T, self.buffer[self.index..][0..@sizeOf(T)], .big); + } + + pub fn read_enum(self: *Decoder, comptime Enum: type) Error!Enum { + comptime assert(@typeInfo(Enum) == .@"enum"); + const Int = std.meta.Tag(Enum); + const value = try self.read_int(Int); + return std.meta.intToEnum( + Enum, + value, + ) catch |err| switch (err) { + error.InvalidEnumTag => return error.Unexpected, + }; + } + + pub fn read_bool(self: *Decoder) Error!bool { + const value = try self.read_int(u8); + return value != 0; + } + + pub fn read_short_string(self: *Decoder) Error![]const u8 { + const length: u8 = try self.read_int(u8); + return try self.read_bytes(length); + } + + pub fn read_long_string(self: *Decoder) Error![]const u8 { + const length: u32 = try self.read_int(u32); + return try self.read_bytes(length); + } + + pub fn read_table(self: *Decoder) Error!Table { + const length: u32 = try self.read_int(u32); + const bytes = try self.read_bytes(length); + return Table.init(bytes); + } + + fn read_bytes(self: *Decoder, length: u32) Error![]const u8 { + assert(self.index <= self.buffer.len); + if (self.index + length > self.buffer.len) return error.BufferExhausted; + defer { + self.index += length; + assert(self.index <= self.buffer.len); + } + + return self.buffer[self.index..][0..length]; + } + + pub fn read_field(self: *Decoder) Error!FieldValue { + const tag = try self.read_enum(FieldValueTag); + const value: FieldValue = switch (tag) { + .boolean => .{ .boolean = try self.read_bool() }, + .uint8 => .{ .uint8 = try self.read_int(u8) }, + .int8 => .{ .int8 = @bitCast(try self.read_int(u8)) }, + .uint16 => .{ .uint16 = try self.read_int(u16) }, + .int16 => .{ .int16 = @bitCast(try self.read_int(u16)) }, + .uint32 => .{ .uint32 = try self.read_int(u32) }, + .int32 => .{ .int32 = @bitCast(try self.read_int(u32)) }, + .int64 => .{ .int64 = @bitCast(try self.read_int(u64)) }, + .string => .{ .string = try self.read_long_string() }, + .timestamp => .{ .timestamp = try self.read_int(u64) }, + .field_table => .{ .field_table = try self.read_table() }, + .void => .void, + + .not_implemented_uint64, + .not_implemented_field_array, + .not_implemented_float, + .not_implemented_double, + .not_implemented_decimal, + .not_implemented_byte_array, + => fatal("AMQP type '{c}' not supported.", .{@intFromEnum(tag)}), + }; + assert(value == tag); + return value; + } + + pub fn read_frame_header(self: *Decoder) Error!FrameHeader { + return .{ + .type = try self.read_enum(FrameType), + .channel = try self.read_enum(Channel), + .size = try self.read_int(u32), + }; + } + + pub fn read_frame_end(self: *Decoder) Error!void { + _ = try self.read_enum(FrameEnd); + } + + pub fn read_method_header(self: *Decoder) Error!MethodHeader { + return .{ + .class = try self.read_int(u16), + .method = try self.read_int(u16), + }; + } + + pub fn read_header(self: *Decoder, frame_size: usize) Error!Header { + const initial_index = self.index; + + const class = try self.read_int(u16); + const weight = try self.read_int(u16); + const body_size = try self.read_int(u64); + const property_flags = try self.read_int(u16); + + if (initial_index + frame_size > self.buffer.len) return error.BufferExhausted; + if (initial_index + frame_size < self.index) return error.Unexpected; + const properties = self.buffer[self.index .. initial_index + frame_size]; + self.index += properties.len; + + try self.read_frame_end(); + + return .{ + .class = class, + .weight = weight, + .body_size = body_size, + .property_flags = @bitCast(property_flags), + .properties = properties, + }; + } + + pub fn read_body(self: *Decoder, frame_size: usize) Error![]const u8 { + if (self.index + frame_size > self.buffer.len) return error.BufferExhausted; + const body = self.buffer[self.index..][0..frame_size]; + self.index += frame_size; + assert(self.index <= self.buffer.len); + try self.read_frame_end(); + return body; + } +}; + +pub const Encoder = struct { + pub const FrameHeader = struct { + /// Total size in bytes including the `size` field. + pub const size_total = @sizeOf(@FieldType(Decoder.FrameHeader, "type")) + + @sizeOf(@FieldType(Decoder.FrameHeader, "channel")) + + @sizeOf(@FieldType(Decoder.FrameHeader, "size")); + + type: FrameType, + channel: Channel, + }; + + pub const Header = struct { + /// Total size in bytes including the `body_size` field. + pub const size_total = @sizeOf(@FieldType(Decoder.Header, "class")) + + @sizeOf(@FieldType(Decoder.Header, "weight")) + + @sizeOf(@FieldType(Decoder.Header, "body_size")); + + class: u16, + weight: u16, + }; + + pub const BasicProperties = BasicPropertiesType(.encode); + + /// `FieldValue` represents a `tag` + `value` pair as specified by the AMQP spec. + pub const FieldValue = FieldValueType(.encode); + + /// Interface for a user-defined set of values to be encoded as an AMQP table + /// directly into the send buffer without copying. + pub const Table = struct { + pub const VTable = struct { + write: *const fn (*const anyopaque, *TableEncoder) void, + }; + + context: *const anyopaque, + vtable: *const VTable, + + pub fn write(self: Table, encoder: *TableEncoder) void { + self.vtable.write(self.context, encoder); + } + }; + + /// Interface for user-defined content to be written directly + /// into the send buffer without copying. + pub const Body = struct { + pub const VTable = struct { + write: *const fn (*const anyopaque, []u8) usize, + }; + + context: *const anyopaque, + vtable: *const VTable, + + pub fn write(self: Body, buffer: []u8) usize { + return self.vtable.write(self.context, buffer); + } + }; + + pub const TableEncoder = struct { + encoder: *Encoder, + + pub fn put(self: *TableEncoder, key: []const u8, value: FieldValue) void { + self.encoder.write_short_string(key); + self.encoder.write_field(value); + } + }; + + buffer: []u8, + index: usize, + + frame_reference: ?struct { + index: usize, + frame_header: FrameHeader, + }, + header_reference: ?struct { + index: usize, + header: Header, + }, + + pub fn init(buffer: []u8) Encoder { + return .{ + .buffer = buffer, + .index = 0, + .frame_reference = null, + .header_reference = null, + }; + } + + pub fn write_int(self: *Encoder, comptime T: type, value: T) void { + comptime assert(@typeInfo(T) == .int); + comptime assert(@sizeOf(T) == 1 or @sizeOf(T) == 2 or @sizeOf(T) == 4 or @sizeOf(T) == 8); + assert(self.index + @sizeOf(T) <= self.buffer.len); + std.mem.writeInt(T, self.buffer[self.index..][0..@sizeOf(T)], value, .big); + self.index += @sizeOf(T); + assert(self.index <= self.buffer.len); + } + + pub fn write_bool(self: *Encoder, value: bool) void { + self.write_int(u8, @intFromBool(value)); + } + + pub fn write_short_string(self: *Encoder, value: []const u8) void { + assert(value.len <= std.math.maxInt(u8)); + self.write_int(u8, @intCast(value.len)); + assert(self.index + value.len <= self.buffer.len); + stdx.copy_left(.inexact, u8, self.buffer[self.index..], value); + self.index += value.len; + } + + pub fn write_long_string(self: *Encoder, value: []const u8) void { + assert(value.len <= std.math.maxInt(u32)); + self.write_int(u32, @intCast(value.len)); + assert(self.index + value.len <= self.buffer.len); + stdx.copy_left(.inexact, u8, self.buffer[self.index..], value); + self.index += value.len; + } + + pub fn write_long_string_body(self: *Encoder, body: ?Body) void { + if (body == null) { + self.write_int(u32, 0); // Zero sized string. + return; + } + + const start_index = self.index; + self.index += @sizeOf(u32); + assert(self.index <= self.buffer.len); + + self.index += body.?.write(self.buffer[self.index..]); + assert(self.index <= self.buffer.len); + const end_index = self.index; + + const size: u32 = @intCast(end_index - start_index - @sizeOf(u32)); + self.index = start_index; + self.write_int(u32, size); + self.index = end_index; + } + + pub fn write_table(self: *Encoder, table: ?Table) void { + if (table == null) { + self.write_int(u32, 0); // Zero sized table. + return; + } + + const start_index = self.index; + self.index += @sizeOf(u32); + assert(self.index <= self.buffer.len); + + var table_encoder: TableEncoder = .{ .encoder = self }; + table.?.write(&table_encoder); + const end_index = self.index; + + const size: u32 = @intCast(end_index - start_index - @sizeOf(u32)); + self.index = start_index; + self.write_int(u32, size); + self.index = end_index; + } + + pub fn write_field(self: *Encoder, field: FieldValue) void { + const tag: FieldValueTag = field; + self.write_int(u8, @intFromEnum(tag)); + switch (field) { + .boolean => |value| self.write_bool(value), + .uint8 => |value| self.write_int(u8, value), + .int8 => |value| self.write_int(u8, @bitCast(value)), + .uint16 => |value| self.write_int(u16, value), + .int16 => |value| self.write_int(u16, @bitCast(value)), + .uint32 => |value| self.write_int(u32, value), + .int32 => |value| self.write_int(u32, @bitCast(value)), + .int64 => |value| self.write_int(u64, @bitCast(value)), + .string => |value| self.write_long_string(value), + .timestamp => |value| self.write_int(u64, value), + .field_table => |value| self.write_table(value), + .void => {}, + + .not_implemented_uint64, + .not_implemented_field_array, + .not_implemented_float, + .not_implemented_double, + .not_implemented_decimal, + .not_implemented_byte_array, + => fatal("AMQP type '{c}' not supported.", .{@intFromEnum(tag)}), + } + } + + pub fn write_bytes(self: *Encoder, bytes: []const u8) void { + assert(bytes.len > 0); + assert(self.index + bytes.len <= self.buffer.len); + stdx.copy_disjoint(.inexact, u8, self.buffer[self.index..], bytes); + self.index += bytes.len; + assert(self.index <= self.buffer.len); + } + + pub fn begin_frame(self: *Encoder, frame_header: FrameHeader) void { + assert(self.frame_reference == null); + assert(self.header_reference == null or frame_header.type == .body); + // Reserve the frame header bytes to be updated by `finish_frame()`. + assert(self.index + FrameHeader.size_total <= self.buffer.len); + const frame_header_index = self.index; + self.index += FrameHeader.size_total; + self.frame_reference = .{ + .index = frame_header_index, + .frame_header = frame_header, + }; + } + + pub fn finish_frame(self: *Encoder, frame_type: FrameType) void { + assert(self.frame_reference != null); + assert(self.frame_reference.?.frame_header.type == frame_type); + maybe(self.header_reference == null); + + const reference = self.frame_reference.?; + self.frame_reference = null; + assert(reference.index + FrameHeader.size_total <= self.index); + const restore_index = self.index; + // The frame size field in the FrameHeader must be updated. + // It represents the payload size, excluding the FrameHeader + // and the frame end byte. + const size: u32 = @intCast(restore_index - reference.index - FrameHeader.size_total); + self.index = reference.index; + self.write_int(u8, @intFromEnum(reference.frame_header.type)); + self.write_int(u16, @intFromEnum(reference.frame_header.channel)); + self.write_int(u32, size); + + self.index = restore_index; + self.write_int(u8, spec.FRAME_END); + } + + pub fn begin_header(self: *Encoder, header: Header) void { + // Reserve the frame header bytes to be updated by `finish_header()`. + assert(self.frame_reference != null); + assert(self.frame_reference.?.frame_header.type == .header); + assert(self.header_reference == null); + const header_index = self.index; + self.index += Header.size_total; + self.header_reference = .{ + .header = header, + .index = header_index, + }; + } + + pub fn finish_header(self: *Encoder, body_size: u64) void { + assert((body_size == 0) == (self.frame_reference == null)); + assert(body_size == 0 or self.frame_reference.?.frame_header.type == .body); + assert(self.header_reference != null); + + const reference = self.header_reference.?; + self.header_reference = null; + assert(reference.index + Header.size_total <= self.index); + const restore_index = self.index; + self.index = reference.index; + self.write_int(u16, reference.header.class); + self.write_int(u16, reference.header.weight); + self.write_int(u64, body_size); + self.index = restore_index; + } + + pub fn write_method_header(self: *Encoder, method_header: MethodHeader) void { + assert(self.frame_reference != null); + assert(self.frame_reference.?.frame_header.type == .method); + assert(self.header_reference == null); + self.write_int(u16, method_header.class); + self.write_int(u16, method_header.method); + } +}; + +fn FieldValueType(comptime target: enum { encode, decode }) type { + return union(FieldValueTag) { + boolean: bool, + uint8: u8, + int8: i8, + uint16: u16, + int16: i16, + uint32: u32, + int32: i32, + int64: i64, + string: []const u8, + timestamp: u64, + field_table: switch (target) { + .encode => Encoder.Table, + .decode => Decoder.Table, + }, + void, + + not_implemented_uint64, + not_implemented_field_array, + not_implemented_float, + not_implemented_double, + not_implemented_decimal, + not_implemented_byte_array, + }; +} + +fn BasicPropertiesType(comptime target: enum { encode, decode }) type { + return struct { + const BasicProperties = @This(); + + /// MIME content type of the message payload. + content_type: ?[]const u8 = null, + /// MIME content encoding of the message payload. + content_encoding: ?[]const u8 = null, + /// Application-defined custom headers. + headers: ?switch (target) { + .encode => Encoder.Table, + .decode => Decoder.Table, + } = null, + /// For queues that implement persistence, + /// whether the message will be logged to disk and survive a broker restart. + delivery_mode: ?DeliveryMode = null, + /// Message priority, 0 to 9. + priority: ?u8 = null, + /// Application-defined correlation identifier. + correlation_id: ?[]const u8 = null, + /// Address to reply to. + reply_to: ?[]const u8 = null, + /// Message expiration specification. + expiration: ?[]const u8 = null, + /// Application-defined message identifier. + message_id: ?[]const u8 = null, + /// Message timestamp (UNIX epoch in seconds). + timestamp: ?u64 = null, + /// Application-defined message type name. + type: ?[]const u8 = null, + /// Application-defined creating user id + user_id: ?[]const u8 = null, + /// Application-defined creating application id. + app_id: ?[]const u8 = null, + cluster_id: ?[]const u8 = null, + + fn property_flags(self: *const BasicProperties) u16 { + var bitset: stdx.BitSetType(16) = .{}; + inline for (std.meta.fields(BasicProperties), 0..) |field, index| { + bitset.set_value(index, @field(self, field.name) != null); + } + return @bitReverse(bitset.bits); + } + + pub fn decode(flags: u16, content: []const u8) Decoder.Error!BasicProperties { + comptime assert(target == .decode); + + var reader = Decoder.init(content); + var bitset: stdx.BitSetType(16) = .{ .bits = @bitReverse(flags) }; + var properties: BasicProperties = .{}; + inline for (std.meta.fields(BasicProperties), 0..) |field, index| { + if (bitset.is_set(index)) { + const FieldType = std.meta.Child(field.type); + @field(properties, field.name) = try switch (FieldType) { + []const u8 => reader.read_short_string(), + Decoder.Table => reader.read_table(), + DeliveryMode => reader.read_enum(DeliveryMode), + u64 => reader.read_int(u64), + u8 => reader.read_int(u8), + else => comptime unreachable, + }; + } + } + assert(reader.index == content.len); + return properties; + } + + pub fn encode(self: *const BasicProperties, encoder: *Encoder) void { + comptime assert(target == .encode); + + encoder.write_int(u16, self.property_flags()); + inline for (std.meta.fields(BasicProperties)) |field| { + if (@field(self, field.name)) |value| { + switch (@TypeOf(value)) { + []const u8 => encoder.write_short_string(value), + Encoder.Table => encoder.write_table(value), + DeliveryMode => encoder.write_int(u8, @intFromEnum(value)), + u64 => encoder.write_int(u64, value), + u8 => encoder.write_int(u8, value), + else => unreachable, + } + } + } + } + }; +} + +/// Terminates the process with non-zero exit code. +/// Use fatal when encountering an environmental error. +/// Similar to `vsr.fatal`, but not logged in the `vsr` scope. +pub fn fatal(comptime format: []const u8, args: anytype) noreturn { + const log = std.log.scoped(.amqp); + log.err(format, args); + + const vsr = @import("../../vsr.zig"); + const status = vsr.FatalReason.cli.exit_status(); + assert(status != 0); + std.process.exit(status); +} + +const testing = std.testing; + +test "amqp: Encoder/Decoder primitives" { + var buffer = try testing.allocator.alloc(u8, frame_min_size); + defer testing.allocator.free(buffer); + + const Primitives = enum { + bool, + uint64, + uint32, + uint16, + uint8, + short_string, + long_string, + }; + + var prng = stdx.PRNG.from_seed_testing(); + for (0..4096) |_| { + var encoder = Encoder.init(buffer); + + switch (prng.enum_uniform(Primitives)) { + .bool => { + const value = prng.boolean(); + encoder.write_bool(value); + + var decoder = Decoder.init(buffer[0..encoder.index]); + try testing.expectEqual(value, try decoder.read_bool()); + }, + inline .uint64, .uint32, .uint16, .uint8 => |tag| { + const Int = switch (tag) { + .uint64 => u64, + .uint32 => u32, + .uint16 => u16, + .uint8 => u8, + else => comptime unreachable, + }; + const value = prng.int(Int); + encoder.write_int(Int, value); + + var decoder = Decoder.init(buffer[0..encoder.index]); + try testing.expectEqual(value, try decoder.read_int(Int)); + }, + .short_string => { + const size = prng.range_inclusive(u32, 0, 255); + const value = try testing.allocator.alloc(u8, size); + defer testing.allocator.free(value); + + prng.fill(value); + encoder.write_short_string(value); + + var decoder = Decoder.init(buffer[0..encoder.index]); + try testing.expectEqualStrings(value, try decoder.read_short_string()); + }, + .long_string => { + const size = prng.range_inclusive(u32, 256, frame_min_size - @sizeOf(u32)); + const value = try testing.allocator.alloc(u8, size); + defer testing.allocator.free(value); + + prng.fill(value); + encoder.write_long_string(value); + + var decoder = Decoder.init(buffer[0..encoder.index]); + try testing.expectEqualStrings(value, try decoder.read_long_string()); + }, + } + } +} + +test "amqp: Encoder/Decoder enums" { + var buffer = try testing.allocator.alloc(u8, frame_min_size); + defer testing.allocator.free(buffer); + + const Enum = enum(u8) { + a = 1, + b = 2, + c = 3, + }; + + for (std.enums.values(Enum)) |value| { + var encoder: Encoder = Encoder.init(buffer); + encoder.write_int(u8, @intFromEnum(value)); + + var decoder: Decoder = Decoder.init(buffer[0..buffer.len]); + try testing.expectEqual(value, try decoder.read_enum(Enum)); + } + + // Invalid enum: + var encoder: Encoder = Encoder.init(buffer); + encoder.write_int(u8, 0); + + var decoder: Decoder = Decoder.init(buffer[0..buffer.len]); + try testing.expectError(error.Unexpected, decoder.read_enum(Enum)); +} + +test "amqp: BasicProperties property_flags" { + // Sets the field with any value, just to compute the `property_flags`. + const BasicProperties = BasicPropertiesType(.decode); + const set_flag = struct { + fn set_flag(set_field: std.meta.FieldEnum(BasicProperties)) u16 { + var properties: BasicProperties = .{}; + switch (set_field) { + inline else => |field| { + const Field = std.meta.Child(@FieldType(BasicProperties, @tagName(field))); + @field(properties, @tagName(field)) = switch (Field) { + []const u8 => "", + DeliveryMode => .persistent, + u8, u64 => 0, + Decoder.Table => Decoder.Table.init(&.{}), + else => comptime unreachable, + }; + }, + } + return properties.property_flags(); + } + }.set_flag; + + const empty: BasicProperties = .{}; + try testing.expectEqual(@as(u16, 0x0000), empty.property_flags()); + + // The last bit corresponding to the first property (it's big endian). + try testing.expectEqual(@as(u16, 0x8000), set_flag(.content_type)); + try testing.expectEqual(@as(u16, 0x4000), set_flag(.content_encoding)); + try testing.expectEqual(@as(u16, 0x2000), set_flag(.headers)); + try testing.expectEqual(@as(u16, 0x1000), set_flag(.delivery_mode)); + try testing.expectEqual(@as(u16, 0x0800), set_flag(.priority)); + try testing.expectEqual(@as(u16, 0x0400), set_flag(.correlation_id)); + try testing.expectEqual(@as(u16, 0x0200), set_flag(.reply_to)); + try testing.expectEqual(@as(u16, 0x0100), set_flag(.expiration)); + try testing.expectEqual(@as(u16, 0x0080), set_flag(.message_id)); + try testing.expectEqual(@as(u16, 0x0040), set_flag(.timestamp)); + try testing.expectEqual(@as(u16, 0x0020), set_flag(.type)); + try testing.expectEqual(@as(u16, 0x0010), set_flag(.user_id)); + try testing.expectEqual(@as(u16, 0x0008), set_flag(.app_id)); + try testing.expectEqual(@as(u16, 0x0004), set_flag(.cluster_id)); +} + +test "amqp: BasicProperties encode/decode" { + var buffer = try testing.allocator.alloc(u8, frame_min_size); + defer testing.allocator.free(buffer); + + var prng = stdx.PRNG.from_seed_testing(); + for (0..4096) |_| { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const properties = try TestingBasicProperties.random(.{ + .arena = arena.allocator(), + .prng = &prng, + }); + + var encoder = Encoder.init(buffer); + properties.encode(&encoder); + + // Decoding: + var decoder = Decoder.init(buffer[0..encoder.index]); + const flags = try decoder.read_int(u16); + const properties_decoded = try Decoder.BasicProperties.decode( + flags, + decoder.buffer[decoder.index..], + ); + try testing.expect(try TestingBasicProperties.eql( + arena.allocator(), + properties, + properties_decoded, + )); + } +} + +test "amqp: Table encode/decode" { + // 64k ought to be enough for any random! + var buffer = try testing.allocator.alloc(u8, 64 * KiB); + defer testing.allocator.free(buffer); + + var prng = stdx.PRNG.from_seed_testing(); + for (0..4096) |_| { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const object = try TestingTable.random(.{ + .arena = arena.allocator(), + .prng = &prng, + .recursive = true, + }); + + // Encoding the complex object: + var encoder = Encoder.init(buffer); + encoder.write_table(object.table()); + + // Decoding: + var decoder = Decoder.init(buffer[0..encoder.index]); + const object_decoded = try TestingTable.from_table( + arena.allocator(), + try decoder.read_table(), + ); + try testing.expect(TestingTable.eql(object, object_decoded)); + } +} + +test "amqp: frame and header" { + const Snap = stdx.Snap; + const snap = Snap.snap_fn("src"); + + var buffer = try testing.allocator.alloc(u8, frame_min_size); + defer testing.allocator.free(buffer); + + { + // Method frame. + var encoder = Encoder.init(buffer); + encoder.begin_frame(.{ .type = .method, .channel = .global }); + encoder.write_method_header(.{ .class = 1, .method = 10 }); + encoder.finish_frame(.method); + try snap(@src(), + \\01 00 00 00 00 00 04 00 01 00 0a ce + ).diff_hex(buffer[0..encoder.index]); + } + + { + // Method + header. + var encoder = Encoder.init(buffer); + encoder.begin_frame(.{ .type = .method, .channel = .global }); + encoder.write_method_header(.{ .class = 10, .method = 100 }); + encoder.finish_frame(.method); + + encoder.begin_frame(.{ .type = .header, .channel = .current }); + encoder.begin_header(.{ .class = 10, .weight = 0 }); + encoder.finish_frame(.header); + encoder.finish_header(0); + + try snap(@src(), + \\01 00 00 00 00 00 04 00 0a 00 64 ce 02 00 01 00 + \\00 00 0c 00 0a 00 00 00 00 00 00 00 00 00 00 ce + ).diff_hex(buffer[0..encoder.index]); + } + + { + // Method + header + body. + var encoder = Encoder.init(buffer); + encoder.begin_frame(.{ .type = .method, .channel = .global }); + encoder.write_method_header(.{ .class = 100, .method = 1000 }); + encoder.finish_frame(.method); + + encoder.begin_frame(.{ .type = .header, .channel = .current }); + encoder.begin_header(.{ .class = 100, .weight = 0 }); + encoder.finish_frame(.header); + + encoder.begin_frame(.{ .type = .body, .channel = .current }); + encoder.write_bytes("body"); + encoder.finish_header("body".len); + encoder.finish_frame(.body); + + try snap(@src(), + \\01 00 00 00 00 00 04 00 64 03 e8 ce 02 00 01 00 + \\00 00 0c 00 64 00 00 00 00 00 00 00 00 00 04 ce + \\03 00 01 00 00 00 04 62 6f 64 79 ce + ).diff_hex(buffer[0..encoder.index]); + } +} + +const TestingTable = struct { + const Timestamp = u63; + + boolean: ?bool = null, + string: ?[]const u8 = null, + int64: ?i64 = null, + uint32: ?u32 = null, + int32: ?i32 = null, + uint16: ?u16 = null, + int16: ?i16 = null, + uint8: ?u8 = null, + int8: ?i8 = null, + field_table: ?*const TestingTable = null, + timestamp: ?Timestamp = null, + + const empty: TestingTable = .{}; + + fn table(self: *const TestingTable) Encoder.Table { + const vtable: Encoder.Table.VTable = comptime .{ + .write = &struct { + fn write(context: *const anyopaque, encoder: *Encoder.TableEncoder) void { + const object: *const TestingTable = @ptrCast(@alignCast(context)); + inline for (std.meta.fields(TestingTable)) |field| { + if (@field(object, field.name)) |value| { + encoder.put(field.name, switch (std.meta.Child(field.type)) { + bool => .{ .boolean = value }, + []const u8 => .{ .string = value }, + i64 => .{ .int64 = value }, + u32 => .{ .uint32 = value }, + i32 => .{ .int32 = value }, + u16 => .{ .uint16 = value }, + i16 => .{ .int16 = value }, + u8 => .{ .uint8 = value }, + i8 => .{ .int8 = value }, + *const TestingTable => .{ .field_table = value.table() }, + Timestamp => .{ .timestamp = value }, + else => comptime unreachable, + }); + } + } + } + }.write, + }; + return .{ .context = self, .vtable = &vtable }; + } + + fn from_table(arena: std.mem.Allocator, decoder: Decoder.Table) !*const TestingTable { + var object = try arena.create(TestingTable); + object.* = TestingTable.empty; + + var iterator = decoder.iterator(); + while (try iterator.next()) |entry| { + const FieldEnum = std.meta.FieldEnum(TestingTable); + const entry_field = std.meta.stringToEnum(FieldEnum, entry.key).?; + switch (entry_field) { + inline else => |field| { + const Field = @FieldType(TestingTable, @tagName(field)); + @field(object, @tagName(field)) = switch (std.meta.Child(Field)) { + bool => entry.value.boolean, + []const u8 => entry.value.string, + i64 => entry.value.int64, + u32 => entry.value.uint32, + i32 => entry.value.int32, + u16 => entry.value.uint16, + i16 => entry.value.int16, + u8 => entry.value.uint8, + i8 => entry.value.int8, + *const TestingTable => try from_table(arena, entry.value.field_table), + Timestamp => @intCast(entry.value.timestamp), + else => comptime unreachable, + }; + }, + } + } + return object; + } + + fn eql(table1: *const TestingTable, table2: *const TestingTable) bool { + inline for (std.meta.fields(TestingTable)) |field| { + const both_null = @field(table1, field.name) == null and + @field(table2, field.name) == null; + if (!both_null) { + const value1 = @field(table1, field.name) orelse return false; + const value2 = @field(table2, field.name) orelse return false; + + const equals = switch (std.meta.Child(field.type)) { + bool => value1 == value2, + []const u8 => std.mem.eql(u8, value1, value2), + i64, u32, i32, u16, i16, u8, i8 => value1 == value2, + *const TestingTable => eql(value1, value2), + Timestamp => value1 == value2, + else => comptime unreachable, + }; + if (!equals) return false; + } + } + + return true; + } + + fn random(options: struct { + arena: std.mem.Allocator, + prng: *stdx.PRNG, + recursive: bool, + }) !*const TestingTable { + const ratio = stdx.PRNG.ratio; + + const is_empty = options.prng.chance(ratio(5, 100)); + if (is_empty) return &TestingTable.empty; + + var object = try options.arena.create(TestingTable); + inline for (std.meta.fields(TestingTable)) |field| { + const is_null = options.prng.chance(ratio(5, 100)); + if (is_null) { + @field(object, field.name) = null; + } else switch (std.meta.Child(field.type)) { + bool => { + @field(object, field.name) = options.prng.boolean(); + }, + []const u8 => { + const size = options.prng.range_inclusive(u32, 0, 255); + const str = try options.arena.alloc(u8, size); + options.prng.fill(str); + @field(object, field.name) = str; + }, + u32, u16, u8 => |Int| { + @field(object, field.name) = options.prng.int(Int); + }, + i64, i32, i16, i8 => |Int| { + const Unsigned = std.meta.Int(.unsigned, @bitSizeOf(Int)); + @field(object, field.name) = @bitCast(options.prng.int(Unsigned)); + }, + *const TestingTable => { + @field(object, field.name) = if (options.recursive) + try random(options) + else + null; + }, + Timestamp => { + @field(object, field.name) = options.prng.int(Timestamp); + }, + else => comptime unreachable, + } + } + return object; + } +}; + +pub const TestingBasicProperties = struct { + pub fn random(options: struct { + arena: std.mem.Allocator, + prng: *stdx.PRNG, + default: Encoder.BasicProperties = .{}, + }) !Encoder.BasicProperties { + const is_null = stdx.PRNG.ratio(5, 100); + var properties: Encoder.BasicProperties = .{}; + inline for (std.meta.fields(Encoder.BasicProperties)) |field| { + if (@field(options.default, field.name)) |default| { + @field(properties, field.name) = default; + } else if (options.prng.chance(is_null)) { + @field(properties, field.name) = null; + } else switch (std.meta.Child(field.type)) { + []const u8 => { + const size = options.prng.range_inclusive(u32, 0, 255); + const str = try options.arena.alloc(u8, size); + options.prng.fill(str); + @field(properties, field.name) = str; + }, + u64, u8 => |Int| { + @field(properties, field.name) = options.prng.int(Int); + }, + DeliveryMode => { + @field(properties, field.name) = options.prng.enum_uniform(DeliveryMode); + }, + Encoder.Table => { + const object = try TestingTable.random(.{ + .arena = options.arena, + .prng = options.prng, + .recursive = false, + }); + @field(properties, field.name) = object.table(); + }, + else => comptime unreachable, + } + } + return properties; + } + + pub fn eql( + arena: std.mem.Allocator, + properties1: Encoder.BasicProperties, + properties2: Decoder.BasicProperties, + ) !bool { + inline for (std.meta.fields(Encoder.BasicProperties)) |field| { + const both_null = @field(properties1, field.name) == null and + @field(properties2, field.name) == null; + if (!both_null) { + const value1 = @field(properties1, field.name) orelse return false; + const value2 = @field(properties2, field.name) orelse return false; + + const equals = switch (std.meta.Child(field.type)) { + []const u8 => std.mem.eql(u8, value1, value2), + u64, u8 => value1 == value2, + DeliveryMode => value1 == value2, + Encoder.Table => eql: { + const encoded_object: *const TestingTable = @ptrCast(@alignCast( + value1.context, + )); + const decoded_object: *const TestingTable = try TestingTable.from_table( + arena, + value2, + ); + break :eql TestingTable.eql(encoded_object, decoded_object); + }, + else => comptime unreachable, + }; + if (!equals) return false; + } + } + return true; + } +}; diff --git a/ocam/src/cdc/amqp/spec.zig b/ocam/src/cdc/amqp/spec.zig new file mode 100644 index 00000000..aa7d6e6f --- /dev/null +++ b/ocam/src/cdc/amqp/spec.zig @@ -0,0 +1,1706 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by spec_parser.py // +// Do not manually modify. // +////////////////////////////////////////////////////////// + +const std = @import("std"); +const stdx = @import("stdx"); +const protocol = @import("protocol.zig"); +const Decoder = protocol.Decoder; +const Encoder = protocol.Encoder; +const MethodHeader = protocol.MethodHeader; +const Channel = protocol.Channel; + +pub const FRAME_METHOD = 1; +pub const FRAME_HEADER = 2; +pub const FRAME_BODY = 3; +pub const FRAME_HEARTBEAT = 8; +pub const FRAME_MIN_SIZE = 4096; +pub const FRAME_END = 206; +/// Indicates that the method completed successfully. This reply code is +/// reserved for future use - the current protocol design does not use positive +/// confirmation and reply codes are sent only in case of an error. +pub const REPLY_SUCCESS = 200; +/// The client attempted to transfer content larger than the server could accept +/// at the present time. The client may retry at a later time. +pub const SOFT_ERROR_CONTENT_TOO_LARGE = 311; +/// Returned when RabbitMQ sends back with 'basic.return' when a +/// 'mandatory' message cannot be delivered to any queue. +pub const SOFT_ERROR_NO_ROUTE = 312; +/// When the exchange cannot deliver to a consumer when the immediate flag is +/// set. As a result of pending data on the queue or the absence of any +/// consumers of the queue. +pub const SOFT_ERROR_NO_CONSUMERS = 313; +/// An operator intervened to close the connection for some reason. The client +/// may retry at some later date. +pub const HARD_ERROR_CONNECTION_FORCED = 320; +/// The client tried to work with an unknown virtual host. +pub const HARD_ERROR_INVALID_PATH = 402; +/// The client attempted to work with a server entity to which it has no +/// access due to security settings. +pub const SOFT_ERROR_ACCESS_REFUSED = 403; +/// The client attempted to work with a server entity that does not exist. +pub const SOFT_ERROR_NOT_FOUND = 404; +/// The client attempted to work with a server entity to which it has no +/// access because another client is working with it. +pub const SOFT_ERROR_RESOURCE_LOCKED = 405; +/// The client requested a method that was not allowed because some precondition +/// failed. +pub const SOFT_ERROR_PRECONDITION_FAILED = 406; +/// The sender sent a malformed frame that the recipient could not decode. +/// This strongly implies a programming error in the sending peer. +pub const HARD_ERROR_FRAME_ERROR = 501; +/// The sender sent a frame that contained illegal values for one or more +/// fields. This strongly implies a programming error in the sending peer. +pub const HARD_ERROR_SYNTAX_ERROR = 502; +/// The client sent an invalid sequence of frames, attempting to perform an +/// operation that was considered invalid by the server. This usually implies +/// a programming error in the client. +pub const HARD_ERROR_COMMAND_INVALID = 503; +/// The client attempted to work with a channel that had not been correctly +/// opened. This most likely indicates a fault in the client layer. +pub const HARD_ERROR_CHANNEL_ERROR = 504; +/// The peer sent a frame that was not expected, usually in the context of +/// a content header and body. This strongly indicates a fault in the peer's +/// content processing. +pub const HARD_ERROR_UNEXPECTED_FRAME = 505; +/// The server could not complete the method because it lacked sufficient +/// resources. This may be due to the client creating too many of some type +/// of entity. +pub const HARD_ERROR_RESOURCE_ERROR = 506; +/// The client tried to work with some entity in a manner that is prohibited +/// by the server, due to security settings or by some other criteria. +pub const HARD_ERROR_NOT_ALLOWED = 530; +/// The client tried to use functionality that is not implemented in the +/// server. +pub const HARD_ERROR_NOT_IMPLEMENTED = 540; +/// The server could not complete the method because of an internal error. +/// The server may require intervention by an operator in order to resume +/// normal operations. +pub const HARD_ERROR_INTERNAL_ERROR = 541; + +/// Methods sent by the AMQP server that must be handled by the client side, +/// marked by the spec with ``. +pub const ClientMethod = union(ClientMethod.Tag) { + pub const Tag = enum(u32) { + connection_start = @bitCast(MethodHeader{ .class = 10, .method = 10 }), + connection_secure = @bitCast(MethodHeader{ .class = 10, .method = 20 }), + connection_tune = @bitCast(MethodHeader{ .class = 10, .method = 30 }), + connection_open_ok = @bitCast(MethodHeader{ .class = 10, .method = 41 }), + connection_close = @bitCast(MethodHeader{ .class = 10, .method = 50 }), + connection_close_ok = @bitCast(MethodHeader{ .class = 10, .method = 51 }), + connection_blocked = @bitCast(MethodHeader{ .class = 10, .method = 60 }), + connection_unblocked = @bitCast(MethodHeader{ .class = 10, .method = 61 }), + connection_update_secret = @bitCast(MethodHeader{ .class = 10, .method = 70 }), + channel_open_ok = @bitCast(MethodHeader{ .class = 20, .method = 11 }), + channel_flow = @bitCast(MethodHeader{ .class = 20, .method = 20 }), + channel_flow_ok = @bitCast(MethodHeader{ .class = 20, .method = 21 }), + channel_close = @bitCast(MethodHeader{ .class = 20, .method = 40 }), + channel_close_ok = @bitCast(MethodHeader{ .class = 20, .method = 41 }), + exchange_declare_ok = @bitCast(MethodHeader{ .class = 40, .method = 11 }), + exchange_delete_ok = @bitCast(MethodHeader{ .class = 40, .method = 21 }), + exchange_bind_ok = @bitCast(MethodHeader{ .class = 40, .method = 31 }), + exchange_unbind_ok = @bitCast(MethodHeader{ .class = 40, .method = 51 }), + queue_declare_ok = @bitCast(MethodHeader{ .class = 50, .method = 11 }), + queue_bind_ok = @bitCast(MethodHeader{ .class = 50, .method = 21 }), + queue_unbind_ok = @bitCast(MethodHeader{ .class = 50, .method = 51 }), + queue_purge_ok = @bitCast(MethodHeader{ .class = 50, .method = 31 }), + queue_delete_ok = @bitCast(MethodHeader{ .class = 50, .method = 41 }), + basic_qos_ok = @bitCast(MethodHeader{ .class = 60, .method = 11 }), + basic_consume_ok = @bitCast(MethodHeader{ .class = 60, .method = 21 }), + basic_cancel = @bitCast(MethodHeader{ .class = 60, .method = 30 }), + basic_cancel_ok = @bitCast(MethodHeader{ .class = 60, .method = 31 }), + basic_return = @bitCast(MethodHeader{ .class = 60, .method = 50 }), + basic_deliver = @bitCast(MethodHeader{ .class = 60, .method = 60 }), + basic_get_ok = @bitCast(MethodHeader{ .class = 60, .method = 71 }), + basic_get_empty = @bitCast(MethodHeader{ .class = 60, .method = 72 }), + basic_ack = @bitCast(MethodHeader{ .class = 60, .method = 80 }), + basic_recover_ok = @bitCast(MethodHeader{ .class = 60, .method = 111 }), + basic_nack = @bitCast(MethodHeader{ .class = 60, .method = 120 }), + tx_select_ok = @bitCast(MethodHeader{ .class = 90, .method = 11 }), + tx_commit_ok = @bitCast(MethodHeader{ .class = 90, .method = 21 }), + tx_rollback_ok = @bitCast(MethodHeader{ .class = 90, .method = 31 }), + confirm_select_ok = @bitCast(MethodHeader{ .class = 85, .method = 11 }), + }; + + /// Start connection negotiation. + /// This method starts the connection negotiation process by telling the client the + /// protocol version that the server proposes, along with a list of security mechanisms + /// which the client can use for authentication. + connection_start: struct { + /// Protocol major version. + /// The major version number can take any value from 0 to 99 as defined in the + /// AMQP specification. + version_major: u8, + /// Protocol minor version. + /// The minor version number can take any value from 0 to 99 as defined in the + /// AMQP specification. + version_minor: u8, + /// Server properties. + server_properties: Decoder.Table, + /// Available security mechanisms. + /// A list of the security mechanisms that the server supports, delimited by spaces. + mechanisms: []const u8, + /// Available message locales. + /// A list of the message locales that the server supports, delimited by spaces. The + /// locale defines the language in which the server will send reply texts. + locales: []const u8, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const version_major = try decoder.read_int(u8); + const version_minor = try decoder.read_int(u8); + const server_properties = try decoder.read_table(); + const mechanisms = try decoder.read_long_string(); + const locales = try decoder.read_long_string(); + + return .{ + .version_major = version_major, + .version_minor = version_minor, + .server_properties = server_properties, + .mechanisms = mechanisms, + .locales = locales, + }; + } + }, + /// Security mechanism challenge. + /// The SASL protocol works by exchanging challenges and responses until both peers have + /// received sufficient information to authenticate each other. This method challenges + /// the client to provide more information. + connection_secure: struct { + /// Security challenge data. + /// Challenge information, a block of opaque binary data passed to the security + /// mechanism. + challenge: []const u8, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const challenge = try decoder.read_long_string(); + + return .{ + .challenge = challenge, + }; + } + }, + /// Propose connection tuning parameters. + /// This method proposes a set of connection configuration values to the client. The + /// client can accept and/or adjust these. + connection_tune: struct { + /// Proposed maximum channels. + /// Specifies highest channel number that the server permits. Usable channel numbers + /// are in the range 1..channel-max. Zero indicates no specified limit. + channel_max: u16, + /// Proposed maximum frame size. + /// The largest frame size that the server proposes for the connection, including + /// frame header and end-byte. The client can negotiate a lower value. Zero means + /// that the server does not impose any specific limit but may reject very large + /// frames if it cannot allocate resources for them. + frame_max: u32, + /// Desired heartbeat delay. + /// The delay, in seconds, of the connection heartbeat that the server wants. + /// Zero means the server does not want a heartbeat. + heartbeat: u16, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const channel_max = try decoder.read_int(u16); + const frame_max = try decoder.read_int(u32); + const heartbeat = try decoder.read_int(u16); + + return .{ + .channel_max = channel_max, + .frame_max = frame_max, + .heartbeat = heartbeat, + }; + } + }, + /// Signal that connection is ready. + /// This method signals to the client that the connection is ready for use. + connection_open_ok: struct { + reserved_1: []const u8, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const reserved_1 = try decoder.read_short_string(); + + return .{ + .reserved_1 = reserved_1, + }; + } + }, + /// Request a connection close. + /// This method indicates that the sender wants to close the connection. This may be + /// due to internal conditions (e.g. a forced shut-down) or due to an error handling + /// a specific method, i.e. an exception. When a close is due to an exception, the + /// sender provides the class and method id of the method which caused the exception. + connection_close: struct { + reply_code: u16, + reply_text: []const u8, + /// Failing method class. + /// When the close is provoked by a method exception, this is the class of the + /// method. + class_id: u16, + /// Failing method id. + /// When the close is provoked by a method exception, this is the ID of the method. + method_id: u16, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const reply_code = try decoder.read_int(u16); + const reply_text = try decoder.read_short_string(); + const class_id = try decoder.read_int(u16); + const method_id = try decoder.read_int(u16); + + return .{ + .reply_code = reply_code, + .reply_text = reply_text, + .class_id = class_id, + .method_id = method_id, + }; + } + }, + /// Confirm a connection close. + /// This method confirms a Connection.Close method and tells the recipient that it is + /// safe to release resources for the connection and close the socket. + connection_close_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Indicate that connection is blocked. + /// This method indicates that a connection has been blocked + /// and does not accept new publishes. + connection_blocked: struct { + /// Block reason. + /// The reason the connection was blocked. + reason: []const u8, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const reason = try decoder.read_short_string(); + + return .{ + .reason = reason, + }; + } + }, + /// Indicate that connection is unblocked. + /// This method indicates that a connection has been unblocked + /// and now accepts publishes. + connection_unblocked: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Update secret. + /// This method updates the secret used to authenticate this connection. It is used + /// when secrets have an expiration date and need to be renewed, like OAuth 2 tokens. + connection_update_secret: struct { + /// New secret. + /// The new secret. + new_secret: []const u8, + /// Reason. + /// The reason for the secret update. + reason: []const u8, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const new_secret = try decoder.read_long_string(); + const reason = try decoder.read_short_string(); + + return .{ + .new_secret = new_secret, + .reason = reason, + }; + } + }, + /// Signal that the channel is ready. + /// This method signals to the client that the channel is ready for use. + channel_open_ok: struct { + reserved_1: []const u8, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const reserved_1 = try decoder.read_long_string(); + + return .{ + .reserved_1 = reserved_1, + }; + } + }, + /// Enable/disable flow from peer. + /// This method asks the peer to pause or restart the flow of content data sent by + /// a consumer. This is a simple flow-control mechanism that a peer can use to avoid + /// overflowing its queues or otherwise finding itself receiving more messages than + /// it can process. Note that this method is not intended for window control. It does + /// not affect contents returned by Basic.Get-Ok methods. + channel_flow: struct { + /// Start/stop content frames. + /// If 1, the peer starts sending content frames. If 0, the peer stops sending + /// content frames. + active: bool, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const bitset_1: stdx.BitSetType(8) = .{ .bits = try decoder.read_int(u8) }; + const active = bitset_1.is_set(0); + + return .{ + .active = active, + }; + } + }, + /// Confirm a flow method. + /// Confirms to the peer that a flow command was received and processed. + channel_flow_ok: struct { + /// Current flow setting. + /// Confirms the setting of the processed flow method: 1 means the peer will start + /// sending or continue to send content frames; 0 means it will not. + active: bool, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const bitset_1: stdx.BitSetType(8) = .{ .bits = try decoder.read_int(u8) }; + const active = bitset_1.is_set(0); + + return .{ + .active = active, + }; + } + }, + /// Request a channel close. + /// This method indicates that the sender wants to close the channel. This may be due to + /// internal conditions (e.g. a forced shut-down) or due to an error handling a specific + /// method, i.e. an exception. When a close is due to an exception, the sender provides + /// the class and method id of the method which caused the exception. + channel_close: struct { + reply_code: u16, + reply_text: []const u8, + /// Failing method class. + /// When the close is provoked by a method exception, this is the class of the + /// method. + class_id: u16, + /// Failing method id. + /// When the close is provoked by a method exception, this is the ID of the method. + method_id: u16, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const reply_code = try decoder.read_int(u16); + const reply_text = try decoder.read_short_string(); + const class_id = try decoder.read_int(u16); + const method_id = try decoder.read_int(u16); + + return .{ + .reply_code = reply_code, + .reply_text = reply_text, + .class_id = class_id, + .method_id = method_id, + }; + } + }, + /// Confirm a channel close. + /// This method confirms a Channel.Close method and tells the recipient that it is safe + /// to release resources for the channel. + channel_close_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Confirm exchange declaration. + /// This method confirms a Declare method and confirms the name of the exchange, + /// essential for automatically-named exchanges. + exchange_declare_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Confirm deletion of an exchange. + /// This method confirms the deletion of an exchange. + exchange_delete_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Confirm bind successful. + /// This method confirms that the bind was successful. + exchange_bind_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Confirm unbind successful. + /// This method confirms that the unbind was successful. + exchange_unbind_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Confirms a queue definition. + /// This method confirms a Declare method and confirms the name of the queue, essential + /// for automatically-named queues. + queue_declare_ok: struct { + /// Reports the name of the queue. If the server generated a queue name, this field + /// contains that name. + queue: []const u8, + message_count: u32, + /// Number of consumers. + /// Reports the number of active consumers for the queue. Note that consumers can + /// suspend activity (Channel.Flow) in which case they do not appear in this count. + consumer_count: u32, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const queue = try decoder.read_short_string(); + const message_count = try decoder.read_int(u32); + const consumer_count = try decoder.read_int(u32); + + return .{ + .queue = queue, + .message_count = message_count, + .consumer_count = consumer_count, + }; + } + }, + /// Confirm bind successful. + /// This method confirms that the bind was successful. + queue_bind_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Confirm unbind successful. + /// This method confirms that the unbind was successful. + queue_unbind_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Confirms a queue purge. + /// This method confirms the purge of a queue. + queue_purge_ok: struct { + /// Reports the number of messages purged. + message_count: u32, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const message_count = try decoder.read_int(u32); + + return .{ + .message_count = message_count, + }; + } + }, + /// Confirm deletion of a queue. + /// This method confirms the deletion of a queue. + queue_delete_ok: struct { + /// Reports the number of messages deleted. + message_count: u32, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const message_count = try decoder.read_int(u32); + + return .{ + .message_count = message_count, + }; + } + }, + /// Confirm the requested qos. + /// This method tells the client that the requested QoS levels could be handled by the + /// server. The requested QoS applies to all active consumers until a new QoS is + /// defined. + basic_qos_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Confirm a new consumer. + /// The server provides the client with a consumer tag, which is used by the client + /// for methods called on the consumer at a later stage. + basic_consume_ok: struct { + /// Holds the consumer tag specified by the client or provided by the server. + consumer_tag: []const u8, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const consumer_tag = try decoder.read_short_string(); + + return .{ + .consumer_tag = consumer_tag, + }; + } + }, + /// End a queue consumer. + /// This method cancels a consumer. This does not affect already delivered + /// messages, but it does mean the server will not send any more messages for + /// that consumer. The client may receive an arbitrary number of messages in + /// between sending the cancel method and receiving the cancel-ok reply. + /// It may also be sent from the server to the client in the event + /// of the consumer being unexpectedly cancelled (i.e. cancelled + /// for any reason other than the server receiving the + /// corresponding basic.cancel from the client). This allows + /// clients to be notified of the loss of consumers due to events + /// such as queue deletion. Note that as it is not a MUST for + /// clients to accept this method from the server, it is advisable + /// for the broker to be able to identify those clients that are + /// capable of accepting the method, through some means of + /// capability negotiation. + basic_cancel: struct { + consumer_tag: []const u8, + no_wait: bool, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const consumer_tag = try decoder.read_short_string(); + const bitset_1: stdx.BitSetType(8) = .{ .bits = try decoder.read_int(u8) }; + const no_wait = bitset_1.is_set(0); + + return .{ + .consumer_tag = consumer_tag, + .no_wait = no_wait, + }; + } + }, + /// Confirm a cancelled consumer. + /// This method confirms that the cancellation was completed. + basic_cancel_ok: struct { + consumer_tag: []const u8, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const consumer_tag = try decoder.read_short_string(); + + return .{ + .consumer_tag = consumer_tag, + }; + } + }, + /// Return a failed message. + /// This method returns an undeliverable message that was published with the "immediate" + /// flag set, or an unroutable message published with the "mandatory" flag set. The + /// reply code and text provide information about the reason that the message was + /// undeliverable. + basic_return: struct { + reply_code: u16, + reply_text: []const u8, + /// Specifies the name of the exchange that the message was originally published + /// to. May be empty, meaning the default exchange. + exchange: []const u8, + /// Message routing key. + /// Specifies the routing key name specified when the message was published. + routing_key: []const u8, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const reply_code = try decoder.read_int(u16); + const reply_text = try decoder.read_short_string(); + const exchange = try decoder.read_short_string(); + const routing_key = try decoder.read_short_string(); + + return .{ + .reply_code = reply_code, + .reply_text = reply_text, + .exchange = exchange, + .routing_key = routing_key, + }; + } + }, + /// Notify the client of a consumer message. + /// This method delivers a message to the client, via a consumer. In the asynchronous + /// message delivery model, the client starts a consumer using the Consume method, then + /// the server responds with Deliver methods as and when messages arrive for that + /// consumer. + basic_deliver: struct { + consumer_tag: []const u8, + delivery_tag: u64, + redelivered: bool, + /// Specifies the name of the exchange that the message was originally published to. + /// May be empty, indicating the default exchange. + exchange: []const u8, + /// Message routing key. + /// Specifies the routing key name specified when the message was published. + routing_key: []const u8, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const consumer_tag = try decoder.read_short_string(); + const delivery_tag = try decoder.read_int(u64); + const bitset_1: stdx.BitSetType(8) = .{ .bits = try decoder.read_int(u8) }; + const redelivered = bitset_1.is_set(0); + const exchange = try decoder.read_short_string(); + const routing_key = try decoder.read_short_string(); + + return .{ + .consumer_tag = consumer_tag, + .delivery_tag = delivery_tag, + .redelivered = redelivered, + .exchange = exchange, + .routing_key = routing_key, + }; + } + }, + /// Provide client with a message. + /// This method delivers a message to the client following a get method. A message + /// delivered by 'get-ok' must be acknowledged unless the no-ack option was set in the + /// get method. + basic_get_ok: struct { + delivery_tag: u64, + redelivered: bool, + /// Specifies the name of the exchange that the message was originally published to. + /// If empty, the message was published to the default exchange. + exchange: []const u8, + /// Message routing key. + /// Specifies the routing key name specified when the message was published. + routing_key: []const u8, + message_count: u32, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const delivery_tag = try decoder.read_int(u64); + const bitset_1: stdx.BitSetType(8) = .{ .bits = try decoder.read_int(u8) }; + const redelivered = bitset_1.is_set(0); + const exchange = try decoder.read_short_string(); + const routing_key = try decoder.read_short_string(); + const message_count = try decoder.read_int(u32); + + return .{ + .delivery_tag = delivery_tag, + .redelivered = redelivered, + .exchange = exchange, + .routing_key = routing_key, + .message_count = message_count, + }; + } + }, + /// Indicate no messages available. + /// This method tells the client that the queue has no messages available for the + /// client. + basic_get_empty: struct { + reserved_1: []const u8, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const reserved_1 = try decoder.read_short_string(); + + return .{ + .reserved_1 = reserved_1, + }; + } + }, + /// Acknowledge one or more messages. + /// When sent by the client, this method acknowledges one or more + /// messages delivered via the Deliver or Get-Ok methods. + /// When sent by server, this method acknowledges one or more + /// messages published with the Publish method on a channel in + /// confirm mode. + /// The acknowledgement can be for a single message or a set of + /// messages up to and including a specific message. + basic_ack: struct { + delivery_tag: u64, + /// Acknowledge multiple messages. + /// If set to 1, the delivery tag is treated as "up to and + /// including", so that multiple messages can be acknowledged + /// with a single method. If set to zero, the delivery tag + /// refers to a single message. If the multiple field is 1, and + /// the delivery tag is zero, this indicates acknowledgement of + /// all outstanding messages. + multiple: bool, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const delivery_tag = try decoder.read_int(u64); + const bitset_1: stdx.BitSetType(8) = .{ .bits = try decoder.read_int(u8) }; + const multiple = bitset_1.is_set(0); + + return .{ + .delivery_tag = delivery_tag, + .multiple = multiple, + }; + } + }, + /// Confirm recovery. + /// This method acknowledges a Basic.Recover method. + basic_recover_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Reject one or more incoming messages. + /// This method allows a client to reject one or more incoming messages. It can be + /// used to interrupt and cancel large incoming messages, or return untreatable + /// messages to their original queue. + /// This method is also used by the server to inform publishers on channels in + /// confirm mode of unhandled messages. If a publisher receives this method, it + /// probably needs to republish the offending messages. + basic_nack: struct { + delivery_tag: u64, + /// Reject multiple messages. + /// If set to 1, the delivery tag is treated as "up to and + /// including", so that multiple messages can be rejected + /// with a single method. If set to zero, the delivery tag + /// refers to a single message. If the multiple field is 1, and + /// the delivery tag is zero, this indicates rejection of + /// all outstanding messages. + multiple: bool, + /// Requeue the message. + /// If requeue is true, the server will attempt to requeue the message. If requeue + /// is false or the requeue attempt fails the messages are discarded or dead-lettered. + /// Clients receiving the Nack methods should ignore this flag. + requeue: bool, + + fn decode(decoder: *Decoder) Decoder.Error!@This() { + const delivery_tag = try decoder.read_int(u64); + const bitset_1: stdx.BitSetType(8) = .{ .bits = try decoder.read_int(u8) }; + const multiple = bitset_1.is_set(0); + const requeue = bitset_1.is_set(1); + + return .{ + .delivery_tag = delivery_tag, + .multiple = multiple, + .requeue = requeue, + }; + } + }, + /// Confirm transaction mode. + /// This method confirms to the client that the channel was successfully set to use + /// standard transactions. + tx_select_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Confirm a successful commit. + /// This method confirms to the client that the commit succeeded. Note that if a commit + /// fails, the server raises a channel exception. + tx_commit_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// Confirm successful rollback. + /// This method confirms to the client that the rollback succeeded. Note that if an + /// rollback fails, the server raises a channel exception. + tx_rollback_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + /// This method confirms to the client that the channel was successfully + /// set to use publisher acknowledgements. + confirm_select_ok: struct { + fn decode(decoder: *Decoder) Decoder.Error!@This() { + _ = decoder; + return .{}; + } + }, + + pub fn method_header(self: ClientMethod) MethodHeader { + return @bitCast(@as(u32, @intFromEnum(self))); + } + + pub fn decode(header: MethodHeader, decoder: *Decoder) Decoder.Error!ClientMethod { + @setEvalBranchQuota(10_000); + const tag = std.meta.intToEnum(Tag, @as(u32, @bitCast(header))) catch { + return error.Unexpected; + }; + const value: ClientMethod = switch (tag) { + inline else => |tag_comptime| value: { + const Method = std.meta.TagPayload(ClientMethod, tag_comptime); + break :value @unionInit( + ClientMethod, + @tagName(tag_comptime), + try Method.decode(decoder), + ); + }, + }; + try decoder.read_frame_end(); + return value; + } +}; + +/// Methods sent by the client, marked by the spec +/// with ``. +pub const ServerMethod = union(ServerMethod.Tag) { + pub const Tag = enum(u32) { + connection_start_ok = @bitCast(MethodHeader{ .class = 10, .method = 11 }), + connection_secure_ok = @bitCast(MethodHeader{ .class = 10, .method = 21 }), + connection_tune_ok = @bitCast(MethodHeader{ .class = 10, .method = 31 }), + connection_open = @bitCast(MethodHeader{ .class = 10, .method = 40 }), + connection_close = @bitCast(MethodHeader{ .class = 10, .method = 50 }), + connection_close_ok = @bitCast(MethodHeader{ .class = 10, .method = 51 }), + connection_blocked = @bitCast(MethodHeader{ .class = 10, .method = 60 }), + connection_unblocked = @bitCast(MethodHeader{ .class = 10, .method = 61 }), + connection_update_secret_ok = @bitCast(MethodHeader{ .class = 10, .method = 71 }), + channel_open = @bitCast(MethodHeader{ .class = 20, .method = 10 }), + channel_flow = @bitCast(MethodHeader{ .class = 20, .method = 20 }), + channel_flow_ok = @bitCast(MethodHeader{ .class = 20, .method = 21 }), + channel_close = @bitCast(MethodHeader{ .class = 20, .method = 40 }), + channel_close_ok = @bitCast(MethodHeader{ .class = 20, .method = 41 }), + exchange_declare = @bitCast(MethodHeader{ .class = 40, .method = 10 }), + exchange_delete = @bitCast(MethodHeader{ .class = 40, .method = 20 }), + exchange_bind = @bitCast(MethodHeader{ .class = 40, .method = 30 }), + exchange_unbind = @bitCast(MethodHeader{ .class = 40, .method = 40 }), + queue_declare = @bitCast(MethodHeader{ .class = 50, .method = 10 }), + queue_bind = @bitCast(MethodHeader{ .class = 50, .method = 20 }), + queue_unbind = @bitCast(MethodHeader{ .class = 50, .method = 50 }), + queue_purge = @bitCast(MethodHeader{ .class = 50, .method = 30 }), + queue_delete = @bitCast(MethodHeader{ .class = 50, .method = 40 }), + basic_qos = @bitCast(MethodHeader{ .class = 60, .method = 10 }), + basic_consume = @bitCast(MethodHeader{ .class = 60, .method = 20 }), + basic_cancel = @bitCast(MethodHeader{ .class = 60, .method = 30 }), + basic_cancel_ok = @bitCast(MethodHeader{ .class = 60, .method = 31 }), + basic_publish = @bitCast(MethodHeader{ .class = 60, .method = 40 }), + basic_get = @bitCast(MethodHeader{ .class = 60, .method = 70 }), + basic_ack = @bitCast(MethodHeader{ .class = 60, .method = 80 }), + basic_reject = @bitCast(MethodHeader{ .class = 60, .method = 90 }), + basic_recover_async = @bitCast(MethodHeader{ .class = 60, .method = 100 }), + basic_recover = @bitCast(MethodHeader{ .class = 60, .method = 110 }), + basic_nack = @bitCast(MethodHeader{ .class = 60, .method = 120 }), + tx_select = @bitCast(MethodHeader{ .class = 90, .method = 10 }), + tx_commit = @bitCast(MethodHeader{ .class = 90, .method = 20 }), + tx_rollback = @bitCast(MethodHeader{ .class = 90, .method = 30 }), + confirm_select = @bitCast(MethodHeader{ .class = 85, .method = 10 }), + }; + + /// Select security mechanism and locale. + /// This method selects a SASL security mechanism. + connection_start_ok: struct { + /// Client properties. + client_properties: ?Encoder.Table, + /// Selected security mechanism. + /// A single security mechanisms selected by the client, which must be one of those + /// specified by the server. + mechanism: []const u8, + /// Security response data. + /// A block of opaque data passed to the security mechanism. The contents of this + /// data are defined by the SASL security mechanism. + response: ?Encoder.Body, + /// Selected message locale. + /// A single message locale selected by the client, which must be one of those + /// specified by the server. + locale: []const u8, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_table(self.client_properties); + encoder.write_short_string(self.mechanism); + encoder.write_long_string_body(self.response); + encoder.write_short_string(self.locale); + } + }, + /// Security mechanism response. + /// This method attempts to authenticate, passing a block of SASL data for the security + /// mechanism at the server side. + connection_secure_ok: struct { + /// Security response data. + /// A block of opaque data passed to the security mechanism. The contents of this + /// data are defined by the SASL security mechanism. + response: ?Encoder.Body, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_long_string_body(self.response); + } + }, + /// Negotiate connection tuning parameters. + /// This method sends the client's connection tuning parameters to the server. + /// Certain fields are negotiated, others provide capability information. + connection_tune_ok: struct { + /// Negotiated maximum channels. + /// The maximum total number of channels that the client will use per connection. + channel_max: u16, + /// Negotiated maximum frame size. + /// The largest frame size that the client and server will use for the connection. + /// Zero means that the client does not impose any specific limit but may reject + /// very large frames if it cannot allocate resources for them. Note that the + /// frame-max limit applies principally to content frames, where large contents can + /// be broken into frames of arbitrary size. + frame_max: u32, + /// Desired heartbeat delay. + /// The delay, in seconds, of the connection heartbeat that the client wants. Zero + /// means the client does not want a heartbeat. + heartbeat: u16, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.channel_max); + encoder.write_int(u32, self.frame_max); + encoder.write_int(u16, self.heartbeat); + } + }, + /// Open connection to virtual host. + /// This method opens a connection to a virtual host, which is a collection of + /// resources, and acts to separate multiple application domains within a server. + /// The server may apply arbitrary limits per virtual host, such as the number + /// of each type of entity that may be used, per connection and/or in total. + connection_open: struct { + /// Virtual host name. + /// The name of the virtual host to work with. + virtual_host: []const u8, + reserved_1: []const u8 = "", + reserved_2: bool = false, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_short_string(self.virtual_host); + encoder.write_short_string(self.reserved_1); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.reserved_2); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Request a connection close. + /// This method indicates that the sender wants to close the connection. This may be + /// due to internal conditions (e.g. a forced shut-down) or due to an error handling + /// a specific method, i.e. an exception. When a close is due to an exception, the + /// sender provides the class and method id of the method which caused the exception. + connection_close: struct { + reply_code: u16, + reply_text: []const u8, + /// Failing method class. + /// When the close is provoked by a method exception, this is the class of the + /// method. + class_id: u16, + /// Failing method id. + /// When the close is provoked by a method exception, this is the ID of the method. + method_id: u16, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reply_code); + encoder.write_short_string(self.reply_text); + encoder.write_int(u16, self.class_id); + encoder.write_int(u16, self.method_id); + } + }, + /// Confirm a connection close. + /// This method confirms a Connection.Close method and tells the recipient that it is + /// safe to release resources for the connection and close the socket. + connection_close_ok: struct { + fn encode(self: *const @This(), encoder: *Encoder) void { + _ = self; + _ = encoder; + } + }, + /// Indicate that connection is blocked. + /// This method indicates that a connection has been blocked + /// and does not accept new publishes. + connection_blocked: struct { + /// Block reason. + /// The reason the connection was blocked. + reason: []const u8, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_short_string(self.reason); + } + }, + /// Indicate that connection is unblocked. + /// This method indicates that a connection has been unblocked + /// and now accepts publishes. + connection_unblocked: struct { + fn encode(self: *const @This(), encoder: *Encoder) void { + _ = self; + _ = encoder; + } + }, + /// Update secret response. + /// This method confirms the updated secret is valid. + connection_update_secret_ok: struct { + fn encode(self: *const @This(), encoder: *Encoder) void { + _ = self; + _ = encoder; + } + }, + /// Open a channel for use. + /// This method opens a channel to the server. + channel_open: struct { + reserved_1: []const u8 = "", + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_short_string(self.reserved_1); + } + }, + /// Enable/disable flow from peer. + /// This method asks the peer to pause or restart the flow of content data sent by + /// a consumer. This is a simple flow-control mechanism that a peer can use to avoid + /// overflowing its queues or otherwise finding itself receiving more messages than + /// it can process. Note that this method is not intended for window control. It does + /// not affect contents returned by Basic.Get-Ok methods. + channel_flow: struct { + /// Start/stop content frames. + /// If 1, the peer starts sending content frames. If 0, the peer stops sending + /// content frames. + active: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.active); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Confirm a flow method. + /// Confirms to the peer that a flow command was received and processed. + channel_flow_ok: struct { + /// Current flow setting. + /// Confirms the setting of the processed flow method: 1 means the peer will start + /// sending or continue to send content frames; 0 means it will not. + active: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.active); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Request a channel close. + /// This method indicates that the sender wants to close the channel. This may be due to + /// internal conditions (e.g. a forced shut-down) or due to an error handling a specific + /// method, i.e. an exception. When a close is due to an exception, the sender provides + /// the class and method id of the method which caused the exception. + channel_close: struct { + reply_code: u16, + reply_text: []const u8, + /// Failing method class. + /// When the close is provoked by a method exception, this is the class of the + /// method. + class_id: u16, + /// Failing method id. + /// When the close is provoked by a method exception, this is the ID of the method. + method_id: u16, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reply_code); + encoder.write_short_string(self.reply_text); + encoder.write_int(u16, self.class_id); + encoder.write_int(u16, self.method_id); + } + }, + /// Confirm a channel close. + /// This method confirms a Channel.Close method and tells the recipient that it is safe + /// to release resources for the channel. + channel_close_ok: struct { + fn encode(self: *const @This(), encoder: *Encoder) void { + _ = self; + _ = encoder; + } + }, + /// Verify exchange exists, create if needed. + /// This method creates an exchange if it does not already exist, and if the exchange + /// exists, verifies that it is of the correct and expected class. + exchange_declare: struct { + reserved_1: u16 = 0, + exchange: []const u8, + /// Exchange type. + /// Each exchange belongs to one of a set of exchange types implemented by the + /// server. The exchange types define the functionality of the exchange - i.e. how + /// messages are routed through it. It is not valid or meaningful to attempt to + /// change the type of an existing exchange. + type: []const u8, + /// Do not create exchange. + /// If set, the server will reply with Declare-Ok if the exchange already + /// exists with the same name, and raise an error if not. The client can + /// use this to check whether an exchange exists without modifying the + /// server state. When set, all other method fields except name and no-wait + /// are ignored. A declare with both passive and no-wait has no effect. + /// Arguments are compared for semantic equivalence. + passive: bool, + /// Request a durable exchange. + /// If set when creating a new exchange, the exchange will be marked as durable. + /// Durable exchanges remain active when a server restarts. Non-durable exchanges + /// (transient exchanges) are purged if/when a server restarts. + durable: bool, + /// Auto-delete when unused. + /// If set, the exchange is deleted when all queues have + /// finished using it. + auto_delete: bool, + /// Create internal exchange. + /// If set, the exchange may not be used directly by publishers, + /// but only when bound to other exchanges. Internal exchanges + /// are used to construct wiring that is not visible to + /// applications. + internal: bool, + no_wait: bool, + /// Arguments for declaration. + /// A set of arguments for the declaration. The syntax and semantics of these + /// arguments depends on the server implementation. + arguments: ?Encoder.Table, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reserved_1); + encoder.write_short_string(self.exchange); + encoder.write_short_string(self.type); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.passive); + bitset_1.set_value(1, self.durable); + bitset_1.set_value(2, self.auto_delete); + bitset_1.set_value(3, self.internal); + bitset_1.set_value(4, self.no_wait); + encoder.write_int(u8, bitset_1.bits); + encoder.write_table(self.arguments); + } + }, + /// Delete an exchange. + /// This method deletes an exchange. When an exchange is deleted all queue bindings on + /// the exchange are cancelled. + exchange_delete: struct { + reserved_1: u16 = 0, + exchange: []const u8, + /// Delete only if unused. + /// If set, the server will only delete the exchange if it has no queue bindings. If + /// the exchange has queue bindings the server does not delete it but raises a + /// channel exception instead. + if_unused: bool, + no_wait: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reserved_1); + encoder.write_short_string(self.exchange); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.if_unused); + bitset_1.set_value(1, self.no_wait); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Bind exchange to an exchange. + /// This method binds an exchange to an exchange. + exchange_bind: struct { + reserved_1: u16 = 0, + /// Name of the destination exchange to bind to. + /// Specifies the name of the destination exchange to bind. + destination: []const u8, + /// Name of the source exchange to bind to. + /// Specifies the name of the source exchange to bind. + source: []const u8, + /// Message routing key. + /// Specifies the routing key for the binding. The routing key + /// is used for routing messages depending on the exchange + /// configuration. Not all exchanges use a routing key - refer + /// to the specific exchange documentation. + routing_key: []const u8, + no_wait: bool, + /// Arguments for binding. + /// A set of arguments for the binding. The syntax and semantics + /// of these arguments depends on the exchange class. + arguments: ?Encoder.Table, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reserved_1); + encoder.write_short_string(self.destination); + encoder.write_short_string(self.source); + encoder.write_short_string(self.routing_key); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.no_wait); + encoder.write_int(u8, bitset_1.bits); + encoder.write_table(self.arguments); + } + }, + /// Unbind an exchange from an exchange. + /// This method unbinds an exchange from an exchange. + exchange_unbind: struct { + reserved_1: u16 = 0, + /// Specifies the name of the destination exchange to unbind. + destination: []const u8, + /// Specifies the name of the source exchange to unbind. + source: []const u8, + /// Routing key of binding. + /// Specifies the routing key of the binding to unbind. + routing_key: []const u8, + no_wait: bool, + /// Arguments of binding. + /// Specifies the arguments of the binding to unbind. + arguments: ?Encoder.Table, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reserved_1); + encoder.write_short_string(self.destination); + encoder.write_short_string(self.source); + encoder.write_short_string(self.routing_key); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.no_wait); + encoder.write_int(u8, bitset_1.bits); + encoder.write_table(self.arguments); + } + }, + /// Declare queue, create if needed. + /// This method creates or checks a queue. When creating a new queue the client can + /// specify various properties that control the durability of the queue and its + /// contents, and the level of sharing for the queue. + queue_declare: struct { + reserved_1: u16 = 0, + queue: []const u8, + /// Do not create queue. + /// If set, the server will reply with Declare-Ok if the queue already + /// exists with the same name, and raise an error if not. The client can + /// use this to check whether a queue exists without modifying the + /// server state. When set, all other method fields except name and no-wait + /// are ignored. A declare with both passive and no-wait has no effect. + /// Arguments are compared for semantic equivalence. + passive: bool, + /// Request a durable queue. + /// If set when creating a new queue, the queue will be marked as durable. Durable + /// queues remain active when a server restarts. Non-durable queues (transient + /// queues) are purged if/when a server restarts. Note that durable queues do not + /// necessarily hold persistent messages, although it does not make sense to send + /// persistent messages to a transient queue. + durable: bool, + /// Request an exclusive queue. + /// Exclusive queues may only be accessed by the current connection, and are + /// deleted when that connection closes. Passive declaration of an exclusive + /// queue by other connections are not allowed. + exclusive: bool, + /// Auto-delete queue when unused. + /// If set, the queue is deleted when all consumers have finished using it. The last + /// consumer can be cancelled either explicitly or because its channel is closed. If + /// there was no consumer ever on the queue, it won't be deleted. Applications can + /// explicitly delete auto-delete queues using the Delete method as normal. + auto_delete: bool, + no_wait: bool, + /// Arguments for declaration. + /// A set of arguments for the declaration. The syntax and semantics of these + /// arguments depends on the server implementation. + arguments: ?Encoder.Table, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reserved_1); + encoder.write_short_string(self.queue); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.passive); + bitset_1.set_value(1, self.durable); + bitset_1.set_value(2, self.exclusive); + bitset_1.set_value(3, self.auto_delete); + bitset_1.set_value(4, self.no_wait); + encoder.write_int(u8, bitset_1.bits); + encoder.write_table(self.arguments); + } + }, + /// Bind queue to an exchange. + /// This method binds a queue to an exchange. Until a queue is bound it will not + /// receive any messages. In a classic messaging model, store-and-forward queues + /// are bound to a direct exchange and subscription queues are bound to a topic + /// exchange. + queue_bind: struct { + reserved_1: u16 = 0, + /// Specifies the name of the queue to bind. + queue: []const u8, + /// Name of the exchange to bind to. + exchange: []const u8, + /// Message routing key. + /// Specifies the routing key for the binding. The routing key is used for routing + /// messages depending on the exchange configuration. Not all exchanges use a + /// routing key - refer to the specific exchange documentation. If the queue name + /// is empty, the server uses the last queue declared on the channel. If the + /// routing key is also empty, the server uses this queue name for the routing + /// key as well. If the queue name is provided but the routing key is empty, the + /// server does the binding with that empty routing key. The meaning of empty + /// routing keys depends on the exchange implementation. + routing_key: []const u8, + no_wait: bool, + /// Arguments for binding. + /// A set of arguments for the binding. The syntax and semantics of these arguments + /// depends on the exchange class. + arguments: ?Encoder.Table, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reserved_1); + encoder.write_short_string(self.queue); + encoder.write_short_string(self.exchange); + encoder.write_short_string(self.routing_key); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.no_wait); + encoder.write_int(u8, bitset_1.bits); + encoder.write_table(self.arguments); + } + }, + /// Unbind a queue from an exchange. + /// This method unbinds a queue from an exchange. + queue_unbind: struct { + reserved_1: u16 = 0, + /// Specifies the name of the queue to unbind. + queue: []const u8, + /// The name of the exchange to unbind from. + exchange: []const u8, + /// Routing key of binding. + /// Specifies the routing key of the binding to unbind. + routing_key: []const u8, + /// Arguments of binding. + /// Specifies the arguments of the binding to unbind. + arguments: ?Encoder.Table, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reserved_1); + encoder.write_short_string(self.queue); + encoder.write_short_string(self.exchange); + encoder.write_short_string(self.routing_key); + encoder.write_table(self.arguments); + } + }, + /// Purge a queue. + /// This method removes all messages from a queue which are not awaiting + /// acknowledgment. + queue_purge: struct { + reserved_1: u16 = 0, + /// Specifies the name of the queue to purge. + queue: []const u8, + no_wait: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reserved_1); + encoder.write_short_string(self.queue); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.no_wait); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Delete a queue. + /// This method deletes a queue. When a queue is deleted any pending messages are sent + /// to a dead-letter queue if this is defined in the server configuration, and all + /// consumers on the queue are cancelled. + queue_delete: struct { + reserved_1: u16 = 0, + /// Specifies the name of the queue to delete. + queue: []const u8, + /// Delete only if unused. + /// If set, the server will only delete the queue if it has no consumers. If the + /// queue has consumers the server does does not delete it but raises a channel + /// exception instead. + if_unused: bool, + /// Delete only if empty. + /// If set, the server will only delete the queue if it has no messages. + if_empty: bool, + no_wait: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reserved_1); + encoder.write_short_string(self.queue); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.if_unused); + bitset_1.set_value(1, self.if_empty); + bitset_1.set_value(2, self.no_wait); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Specify quality of service. + /// This method requests a specific quality of service. The QoS can be specified for the + /// current channel or for all channels on the connection. The particular properties and + /// semantics of a qos method always depend on the content class semantics. Though the + /// qos method could in principle apply to both peers, it is currently meaningful only + /// for the server. + basic_qos: struct { + /// Prefetch window in octets. + /// The client can request that messages be sent in advance so that when the client + /// finishes processing a message, the following message is already held locally, + /// rather than needing to be sent down the channel. Prefetching gives a performance + /// improvement. This field specifies the prefetch window size in octets. The server + /// will send a message in advance if it is equal to or smaller in size than the + /// available prefetch size (and also falls into other prefetch limits). May be set + /// to zero, meaning "no specific limit", although other prefetch limits may still + /// apply. The prefetch-size is ignored if the no-ack option is set. + prefetch_size: u32, + /// Prefetch window in messages. + /// Specifies a prefetch window in terms of whole messages. This field may be used + /// in combination with the prefetch-size field; a message will only be sent in + /// advance if both prefetch windows (and those at the channel and connection level) + /// allow it. The prefetch-count is ignored if the no-ack option is set. + prefetch_count: u16, + /// Apply to entire connection. + /// RabbitMQ has reinterpreted this field. The original + /// specification said: "By default the QoS settings apply to + /// the current channel only. If this field is set, they are + /// applied to the entire connection." Instead, RabbitMQ takes + /// global=false to mean that the QoS settings should apply + /// per-consumer (for new consumers on the channel; existing + /// ones being unaffected) and global=true to mean that the QoS + /// settings should apply per-channel. + global: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u32, self.prefetch_size); + encoder.write_int(u16, self.prefetch_count); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.global); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Start a queue consumer. + /// This method asks the server to start a "consumer", which is a transient request for + /// messages from a specific queue. Consumers last as long as the channel they were + /// declared on, or until the client cancels them. + basic_consume: struct { + reserved_1: u16 = 0, + /// Specifies the name of the queue to consume from. + queue: []const u8, + /// Specifies the identifier for the consumer. The consumer tag is local to a + /// channel, so two clients can use the same consumer tags. If this field is + /// empty the server will generate a unique tag. + consumer_tag: []const u8, + no_local: bool, + no_ack: bool, + /// Request exclusive access. + /// Request exclusive consumer access, meaning only this consumer can access the + /// queue. + exclusive: bool, + no_wait: bool, + /// Arguments for declaration. + /// A set of arguments for the consume. The syntax and semantics of these + /// arguments depends on the server implementation. + arguments: ?Encoder.Table, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reserved_1); + encoder.write_short_string(self.queue); + encoder.write_short_string(self.consumer_tag); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.no_local); + bitset_1.set_value(1, self.no_ack); + bitset_1.set_value(2, self.exclusive); + bitset_1.set_value(3, self.no_wait); + encoder.write_int(u8, bitset_1.bits); + encoder.write_table(self.arguments); + } + }, + /// End a queue consumer. + /// This method cancels a consumer. This does not affect already delivered + /// messages, but it does mean the server will not send any more messages for + /// that consumer. The client may receive an arbitrary number of messages in + /// between sending the cancel method and receiving the cancel-ok reply. + /// It may also be sent from the server to the client in the event + /// of the consumer being unexpectedly cancelled (i.e. cancelled + /// for any reason other than the server receiving the + /// corresponding basic.cancel from the client). This allows + /// clients to be notified of the loss of consumers due to events + /// such as queue deletion. Note that as it is not a MUST for + /// clients to accept this method from the server, it is advisable + /// for the broker to be able to identify those clients that are + /// capable of accepting the method, through some means of + /// capability negotiation. + basic_cancel: struct { + consumer_tag: []const u8, + no_wait: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_short_string(self.consumer_tag); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.no_wait); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Confirm a cancelled consumer. + /// This method confirms that the cancellation was completed. + basic_cancel_ok: struct { + consumer_tag: []const u8, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_short_string(self.consumer_tag); + } + }, + /// Publish a message. + /// This method publishes a message to a specific exchange. The message will be routed + /// to queues as defined by the exchange configuration and distributed to any active + /// consumers when the transaction, if any, is committed. + basic_publish: struct { + reserved_1: u16 = 0, + /// Specifies the name of the exchange to publish to. The exchange name can be + /// empty, meaning the default exchange. If the exchange name is specified, and that + /// exchange does not exist, the server will raise a channel exception. + exchange: []const u8, + /// Message routing key. + /// Specifies the routing key for the message. The routing key is used for routing + /// messages depending on the exchange configuration. + routing_key: []const u8, + /// Indicate mandatory routing. + /// This flag tells the server how to react if the message cannot be routed to a + /// queue. If this flag is set, the server will return an unroutable message with a + /// Return method. If this flag is zero, the server silently drops the message. + mandatory: bool, + /// Request immediate delivery. + /// This flag tells the server how to react if the message cannot be routed to a + /// queue consumer immediately. If this flag is set, the server will return an + /// undeliverable message with a Return method. If this flag is zero, the server + /// will queue the message, but with no guarantee that it will ever be consumed. + immediate: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reserved_1); + encoder.write_short_string(self.exchange); + encoder.write_short_string(self.routing_key); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.mandatory); + bitset_1.set_value(1, self.immediate); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Direct access to a queue. + /// This method provides a direct access to the messages in a queue using a synchronous + /// dialogue that is designed for specific types of application where synchronous + /// functionality is more important than performance. + basic_get: struct { + reserved_1: u16 = 0, + /// Specifies the name of the queue to get a message from. + queue: []const u8, + no_ack: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u16, self.reserved_1); + encoder.write_short_string(self.queue); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.no_ack); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Acknowledge one or more messages. + /// When sent by the client, this method acknowledges one or more + /// messages delivered via the Deliver or Get-Ok methods. + /// When sent by server, this method acknowledges one or more + /// messages published with the Publish method on a channel in + /// confirm mode. + /// The acknowledgement can be for a single message or a set of + /// messages up to and including a specific message. + basic_ack: struct { + delivery_tag: u64, + /// Acknowledge multiple messages. + /// If set to 1, the delivery tag is treated as "up to and + /// including", so that multiple messages can be acknowledged + /// with a single method. If set to zero, the delivery tag + /// refers to a single message. If the multiple field is 1, and + /// the delivery tag is zero, this indicates acknowledgement of + /// all outstanding messages. + multiple: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u64, self.delivery_tag); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.multiple); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Reject an incoming message. + /// This method allows a client to reject a message. It can be used to interrupt and + /// cancel large incoming messages, or return untreatable messages to their original + /// queue. + basic_reject: struct { + delivery_tag: u64, + /// Requeue the message. + /// If requeue is true, the server will attempt to requeue the message. If requeue + /// is false or the requeue attempt fails the messages are discarded or dead-lettered. + requeue: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u64, self.delivery_tag); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.requeue); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Redeliver unacknowledged messages. + /// This method asks the server to redeliver all unacknowledged messages on a + /// specified channel. Zero or more messages may be redelivered. This method + /// is deprecated in favour of the synchronous Recover/Recover-Ok. + basic_recover_async: struct { + /// Requeue the message. + /// If this field is zero, the message will be redelivered to the original + /// recipient. If this bit is 1, the server will attempt to requeue the message, + /// potentially then delivering it to an alternative subscriber. + requeue: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.requeue); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Redeliver unacknowledged messages. + /// This method asks the server to redeliver all unacknowledged messages on a + /// specified channel. Zero or more messages may be redelivered. This method + /// replaces the asynchronous Recover. + basic_recover: struct { + /// Requeue the message. + /// If this field is zero, the message will be redelivered to the original + /// recipient. If this bit is 1, the server will attempt to requeue the message, + /// potentially then delivering it to an alternative subscriber. + requeue: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.requeue); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Reject one or more incoming messages. + /// This method allows a client to reject one or more incoming messages. It can be + /// used to interrupt and cancel large incoming messages, or return untreatable + /// messages to their original queue. + /// This method is also used by the server to inform publishers on channels in + /// confirm mode of unhandled messages. If a publisher receives this method, it + /// probably needs to republish the offending messages. + basic_nack: struct { + delivery_tag: u64, + /// Reject multiple messages. + /// If set to 1, the delivery tag is treated as "up to and + /// including", so that multiple messages can be rejected + /// with a single method. If set to zero, the delivery tag + /// refers to a single message. If the multiple field is 1, and + /// the delivery tag is zero, this indicates rejection of + /// all outstanding messages. + multiple: bool, + /// Requeue the message. + /// If requeue is true, the server will attempt to requeue the message. If requeue + /// is false or the requeue attempt fails the messages are discarded or dead-lettered. + /// Clients receiving the Nack methods should ignore this flag. + requeue: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + encoder.write_int(u64, self.delivery_tag); + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.multiple); + bitset_1.set_value(1, self.requeue); + encoder.write_int(u8, bitset_1.bits); + } + }, + /// Select standard transaction mode. + /// This method sets the channel to use standard transactions. The client must use this + /// method at least once on a channel before using the Commit or Rollback methods. + tx_select: struct { + fn encode(self: *const @This(), encoder: *Encoder) void { + _ = self; + _ = encoder; + } + }, + /// Commit the current transaction. + /// This method commits all message publications and acknowledgments performed in + /// the current transaction. A new transaction starts immediately after a commit. + tx_commit: struct { + fn encode(self: *const @This(), encoder: *Encoder) void { + _ = self; + _ = encoder; + } + }, + /// Abandon the current transaction. + /// This method abandons all message publications and acknowledgments performed in + /// the current transaction. A new transaction starts immediately after a rollback. + /// Note that unacked messages will not be automatically redelivered by rollback; + /// if that is required an explicit recover call should be issued. + tx_rollback: struct { + fn encode(self: *const @This(), encoder: *Encoder) void { + _ = self; + _ = encoder; + } + }, + /// This method sets the channel to use publisher acknowledgements. + /// The client can only use this method on a non-transactional + /// channel. + confirm_select: struct { + nowait: bool, + + fn encode(self: *const @This(), encoder: *Encoder) void { + var bitset_1: stdx.BitSetType(8) = .{}; + bitset_1.set_value(0, self.nowait); + encoder.write_int(u8, bitset_1.bits); + } + }, + + pub fn method_header(self: ServerMethod) MethodHeader { + return @bitCast(@as(u32, @intFromEnum(self))); + } + + pub fn encode(self: ServerMethod, channel: Channel, encoder: *Encoder) void { + encoder.begin_frame(.{ + .type = .method, + .channel = channel, + }); + switch (self) { + inline else => |method| { + encoder.write_method_header(self.method_header()); + method.encode(encoder); + }, + } + encoder.finish_frame(.method); + } +}; diff --git a/ocam/src/cdc/amqp/spec_parser.py b/ocam/src/cdc/amqp/spec_parser.py new file mode 100644 index 00000000..600f23d4 --- /dev/null +++ b/ocam/src/cdc/amqp/spec_parser.py @@ -0,0 +1,317 @@ +## Converts the Advanced Message Queuing Protocol (AMQP) specification into Zig declarations. +## Usage: `python spec_parser.py amqp0-9-1.xml > spec.zig` +## +## Please refer to https://www.amqp.org/sites/amqp.org/files/amqp0-9-1.zip to download the +## official specification XML and additional documentation. +## +## Alternatively, refer to the RabbitMQ extended AMQP 0.9.1 specification: +## https://github.com/rabbitmq/amqp-0.9.1-spec/blob/b8e975a762b8677263ebbbba1e70654b5263af81/xml/amqp0-9-1.extended.xml + +import xml.etree.ElementTree as Tree +import sys + + +read_types = { + 'shortstr': "[]const u8", + 'longstr': "[]const u8", + 'bit': "bool", + 'octet': "u8", + 'short': "u16", + 'long': "u32", + 'longlong': "u64", + 'timestamp': "u64", + 'table': "Decoder.Table", + } + +reader = { + 'octet': "read_int(u8)", + 'short': "read_int(u16)", + 'long': "read_int(u32)", + 'longlong': "read_int(u64)", + 'bit': "read_int(u8)", + 'shortstr': 'read_short_string()', + 'longstr': 'read_long_string()', + 'table': "read_table()", + 'timestamp': 'read_int(u64)', + } + +write_types = { + 'shortstr': "[]const u8", + 'longstr': "?Encoder.Body", + 'bit': "bool", + 'octet': "u8", + 'short': "u16", + 'long': "u32", + 'longlong': "u64", + 'timestamp': "u64", + 'table': "?Encoder.Table", + } + +writer = { + 'octet': "write_int(u8, ", + 'short': "write_int(u16, ", + 'long': "write_int(u32, ", + 'longlong': "write_int(u64, ", + 'bit': "write_int(u8, ", + 'shortstr': 'write_short_string(', + 'longstr': 'write_long_string_body(', + 'table': "write_table(", + 'timestamp': 'write_int(u64, ', + } + +write_defaults = { + '[]const u8': "\"\"", + 'bool': "false", + 'u8': "0", + 'u16': "0", + 'u32': "0", + 'u64': "0", + } + +class Source: + client = 1 + server = 2 + +def main(file): + xml = Tree.parse(file) + root = xml.getroot() + assert root.tag == 'amqp', "Invalid AMQP spec" + + for element in root.findall('domain'): + domain = element.get('name') + type = element.get('type') + read_types[domain] = read_types[type] + reader[domain] = reader[type] + write_types[domain] = write_types[type] + writer[domain] = writer[type] + + print(f"//////////////////////////////////////////////////////////") + print(f"// This file was auto-generated by spec_parser.py //") + print(f"// Do not manually modify. //") + print(f"//////////////////////////////////////////////////////////") + print(f"") + print(f"const std = @import(\"std\");") + print(f"const stdx = @import(\"stdx\");") + print(f"const protocol = @import(\"protocol.zig\");") + print(f"const Decoder = protocol.Decoder;") + print(f"const Encoder = protocol.Encoder;") + print(f"const MethodHeader = protocol.MethodHeader;") + print(f"const Channel = protocol.Channel;") + print(f"") + constants(root) + client_methods(root) + server_methods(root) + +def constants(root): + constants = root.findall('constant') + for element in constants: + docs(element) + name = element.get('name') + class_attribute = element.get('class') + name = f"{class_attribute}_{name}" if class_attribute else name + print(f"pub const {to_upper_case(name)} = {element.get('value')};") + +def client_methods(root): + print(f"") + print(f"/// Methods sent by the AMQP server that must be handled by the client side,") + print(f"/// marked by the spec with ``.") + print(f"pub const ClientMethod = union(ClientMethod.Tag) {{") + print(f" pub const Tag = enum(u32) {{") + classes = root.findall('class') + for class_ in classes: + class_index = class_.get('index') + methods = class_.findall('method') + for method in methods: + if method.find("chassis[@name='client']") is None: continue + method_index = method.get('index') + enum_name = to_lower_case(class_.get('name') + '_' + method.get('name')) + print(f" {enum_name} = @bitCast(MethodHeader{{ .class = {class_index}, .method = {method_index} }}),") + print(f" }};") + print(f"") + for class_ in classes: + methods = class_.findall('method') + for method in methods: + if method.find("chassis[@name='client']") is None: continue + enum_name = to_lower_case(class_.get('name') + '_' + method.get('name')) + indent = " " + docs(method, indent) + print(f"{indent}{enum_name}: struct {{") + class_method(Source.client, method, indent) + print(f"{indent}}},") + print(f"") + print(f" pub fn method_header(self: ClientMethod) MethodHeader {{") + print(f" return @bitCast(@as(u32, @intFromEnum(self)));") + print(f" }}") + print(f"") + print(f" pub fn decode(header: MethodHeader, decoder: *Decoder) Decoder.Error!ClientMethod {{") + print(f" @setEvalBranchQuota(10_000);") + print(f" const tag = std.meta.intToEnum(Tag, @as(u32, @bitCast(header))) catch {{") + print(f" return error.Unexpected;") + print(f" }};") + print(f" const value: ClientMethod = switch (tag) {{") + print(f" inline else => |tag_comptime| value: {{") + print(f" const Method = std.meta.TagPayload(ClientMethod, tag_comptime);") + print(f" break :value @unionInit(") + print(f" ClientMethod,") + print(f" @tagName(tag_comptime),") + print(f" try Method.decode(decoder),") + print(f" );") + print(f" }},") + print(f" }};") + print(f" try decoder.read_frame_end();") + print(f" return value;") + print(f" }}") + print(f"}};") + +def server_methods(root): + print(f"") + print(f"/// Methods sent by the client, marked by the spec") + print(f"/// with ``.") + print(f"pub const ServerMethod = union(ServerMethod.Tag) {{") + print(f" pub const Tag = enum(u32) {{") + classes = root.findall('class') + for class_ in classes: + methods = class_.findall('method') + for method in methods: + if method.find("chassis[@name='server']") is None: continue + class_index = class_.get('index') + method_index = method.get('index') + enum_name = to_lower_case(class_.get('name') + '_' + method.get('name')) + print(f" {enum_name} = @bitCast(MethodHeader{{ .class = {class_index}, .method = {method_index} }}),") + print(f" }};") + print(f"") + for class_ in classes: + methods = class_.findall('method') + for method in methods: + if method.find("chassis[@name='server']") is None: continue + enum_name = to_lower_case(class_.get('name') + '_' + method.get('name')) + indent = " " + docs(method, indent) + print(f"{indent}{enum_name}: struct {{") + class_method(Source.server, method, indent) + print(f"{indent}}},") + print(f"") + print(f" pub fn method_header(self: ServerMethod) MethodHeader {{") + print(f" return @bitCast(@as(u32, @intFromEnum(self)));") + print(f" }}") + print(f"") + print(f" pub fn encode(self: ServerMethod, channel: Channel, encoder: *Encoder) void {{") + print(f" encoder.begin_frame(.{{") + print(f" .type = .method,") + print(f" .channel = channel,") + print(f" }});") + print(f" switch (self) {{") + print(f" inline else => |method| {{") + print(f" encoder.write_method_header(self.method_header());") + print(f" method.encode(encoder);") + print(f" }},") + print(f" }}") + print(f" encoder.finish_frame(.method);") + print(f" }}") + print(f"}};") + +def class_method(source, method, indent=""): + method_fields(source, method, indent) + if source == Source.client: decode(method, indent) + if source == Source.server: encode(method, indent) + +def method_fields(source, method, indent=""): + indent += " " + fields = method.findall('field') + if fields: + for element in fields: + field = to_lower_case(element.get('name')) + types = read_types if source == Source.client else write_types + type = types[element.get('domain', element.get('type'))] + has_default = source == Source.server and field.startswith('reserved') + default = f" = {write_defaults[type]}" if has_default else "" + docs(element, indent) + print(f"{indent}{field}: {type}{default},") + print(f"") + +def decode(method, indent=""): + if method.find("chassis[@name='client']") is None: return + indent += " " + print(f"{indent}fn decode(decoder: *Decoder) Decoder.Error!@This() {{") + fields = method.findall('field') + if fields: + bitset_octets = 0 + bitset_index = 0 + for element in fields: + field = to_lower_case(element.get('name')) + type = read_types[element.get('domain', element.get('type'))] + read_function = reader[element.get('domain', element.get('type'))] + if type == 'bool': + if bitset_index == 0: + bitset_octets += 1 + print(f"{indent} const bitset_{bitset_octets}: stdx.BitSetType(8) = .{{ .bits = try decoder.{read_function} }};") + print(f"{indent} const {field} = bitset_{bitset_octets}.is_set({bitset_index});") + bitset_index += 1 + else: + print(f"{indent} const {field} = try decoder.{read_function};") + bitset_index = 0 + print(f""); + print(f"{indent} return .{{") + for element in fields: + field = to_lower_case(element.get('name')) + print(f"{indent} .{field} = {field},") + print(f"{indent} }};") + else: + print(f"{indent} _ = decoder;") + print(f"{indent} return .{{}};") + print(f"{indent}}}") + +def encode(method, indent = ""): + if method.find("chassis[@name='server']") is None: return + indent += " " + print(f"{indent}fn encode(self: *const @This(), encoder: *Encoder) void {{") + fields = method.findall('field') + if fields: + bitset_octets = 0 + bitset_index = 0 + for element in fields: + field = to_lower_case(element.get('name')) + type = write_types[element.get('domain', element.get('type'))] + write_function = writer[element.get('domain', element.get('type'))] + if type == 'bool': + if bitset_index == 0: + bitset_octets += 1 + print(f"{indent} var bitset_{bitset_octets}: stdx.BitSetType(8) = .{{}};") + print(f"{indent} bitset_{bitset_octets}.set_value({bitset_index}, self.{field});") + bitset_index += 1 + else: + if bitset_index > 0: + print(f"{indent} encoder.{writer['bit']}bitset_{bitset_octets}.bits);") + print(f"{indent} encoder.{write_function}self.{field});") + bitset_index = 0 + if bitset_index > 0: + print(f"{indent} encoder.{writer['bit']}bitset_{bitset_octets}.bits);") + else: + print(f"{indent} _ = self;") + print(f"{indent} _ = encoder;") + print(f"{indent}}}") + +def docs(element, indent=""): + label = element.get('label') + if label: print(f"{indent}/// {label.capitalize()}.") + for doc in element.findall('doc'): + if (doc.get('type') == 'grammar'): continue + lines = doc.text.split("\n") + for line in lines: + line = line.strip() + if line: + print(f"{indent}/// {line}") + +def to_upper_case(name): + name = name.replace('-', '_').upper() + return name + +def to_lower_case(name): + name = name.replace('-', '_').lower() + return name + +def to_pascal_case(name): + name = ''.join([x.capitalize() for x in name.split('-')]) + return name + +main(sys.argv[1]) diff --git a/ocam/src/cdc/amqp/types.zig b/ocam/src/cdc/amqp/types.zig new file mode 100644 index 00000000..3771832e --- /dev/null +++ b/ocam/src/cdc/amqp/types.zig @@ -0,0 +1,337 @@ +const std = @import("std"); +const stdx = @import("stdx"); +const builtin = @import("builtin"); +const vsr = @import("../../vsr.zig"); +const protocol = @import("protocol.zig"); +const Encoder = protocol.Encoder; +const Decoder = protocol.Decoder; + +pub const ConnectOptions = struct { + host: stdx.SocketAddress, + user_name: []const u8, + password: []const u8, + vhost: []const u8, + locale: ?[]const u8 = null, + heartbeat_seconds: ?u16 = null, + properties: ConnectionProperties = ConnectionProperties.default, +}; + +pub const ConnectionProperties = struct { + product: []const u8, + version: []const u8, + platform: []const u8, + capabilities: *const ClientCapabilities, + + pub const default: ConnectionProperties = .{ + .product = "TigerBeetle", + .version = std.fmt.comptimePrint( + "{}", + .{vsr.constants.config.process.release}, + ), + // By convention, "platform" refers to the programming language. + // e.g., Erlang, Java, Go, etc. + .platform = "Zig " ++ builtin.zig_version_string, + .capabilities = &ClientCapabilities.default, + }; + + pub fn table(self: *const ConnectionProperties) Encoder.Table { + const vtable: Encoder.Table.VTable = comptime .{ + .write = &struct { + fn write(context: *const anyopaque, encoder: *Encoder.TableEncoder) void { + const properties: *const ConnectionProperties = @ptrCast(@alignCast(context)); + inline for (std.meta.fields(ConnectionProperties)) |field| { + const value = @field(properties, field.name); + encoder.put(field.name, switch (field.type) { + []const u8 => .{ .string = value }, + *const ClientCapabilities => .{ .field_table = value.table() }, + else => comptime unreachable, + }); + } + } + }.write, + }; + return .{ .context = self, .vtable = &vtable }; + } +}; + +pub const ClientCapabilities = struct { + publisher_confirms: bool, + exchange_exchange_bindings: bool, + basic_nack: bool, + consumer_cancel_notify: bool, + connection_blocked: bool, + consumer_priorities: bool, + authentication_failure_close: bool, + per_consumer_qos: bool, + direct_reply_to: bool, + + pub const default: ClientCapabilities = .{ + .publisher_confirms = true, + .exchange_exchange_bindings = false, + .basic_nack = true, + .consumer_cancel_notify = false, + .connection_blocked = false, + .consumer_priorities = false, + .authentication_failure_close = true, + .per_consumer_qos = false, + .direct_reply_to = false, + }; + + pub fn table(self: *const ClientCapabilities) Encoder.Table { + const vtable: Encoder.Table.VTable = comptime .{ + .write = &struct { + fn write(context: *const anyopaque, encoder: *Encoder.TableEncoder) void { + const capabilities: *const ClientCapabilities = @ptrCast(@alignCast(context)); + encoder.put("publisher_confirms", .{ + .boolean = capabilities.publisher_confirms, + }); + encoder.put("exchange_exchange_bindings", .{ + .boolean = capabilities.exchange_exchange_bindings, + }); + encoder.put("basic.nack", .{ + .boolean = capabilities.basic_nack, + }); + encoder.put("consumer_cancel_notify", .{ + .boolean = capabilities.consumer_cancel_notify, + }); + encoder.put("connection.blocked", .{ + .boolean = capabilities.connection_blocked, + }); + encoder.put("consumer_priorities", .{ + .boolean = capabilities.consumer_priorities, + }); + encoder.put("authentication_failure_close", .{ + .boolean = capabilities.authentication_failure_close, + }); + encoder.put("per_consumer_qos", .{ + .boolean = capabilities.per_consumer_qos, + }); + encoder.put("direct_reply_to", .{ + .boolean = capabilities.direct_reply_to, + }); + } + }.write, + }; + return .{ .context = self, .vtable = &vtable }; + } +}; + +/// "SASL" means "Simple Authentication and Security Layer" +pub const SASLPlainAuth = struct { + pub const mechanism = "PLAIN"; + + user_name: []const u8, + password: []const u8, + + /// Response returns the SASL PLAIN mechanism encoding, delimited by null characters. + pub fn response(self: *const SASLPlainAuth) Encoder.Body { + const vtable: Encoder.Body.VTable = comptime .{ + .write = &struct { + fn write(context: *const anyopaque, buffer: []u8) usize { + const auth: *const SASLPlainAuth = @ptrCast(@alignCast(context)); + var fbs = std.io.fixedBufferStream(buffer); + fbs.writer().print("\x00{s}\x00{s}", .{ + auth.user_name, + auth.password, + }) catch unreachable; + return fbs.pos; + } + }.write, + }; + return .{ .context = self, .vtable = &vtable }; + } +}; + +pub const QueueOverflow = enum { + drop_head, + reject_publish, + reject_publish_dlx, +}; + +/// A set of implementation-defined arguments for the declaration. +/// The syntax and semantics of these arguments are specific to RabbitMQ. +pub const QueueDeclareArguments = struct { + /// How long a queue can be unused for before it is automatically deleted (milliseconds). + /// (Sets the "x-expires" argument.) + expires: ?u32 = null, + /// How long a message published to a queue can live before it is discarded (milliseconds). + /// (Sets the "x-message-ttl" argument.) + message_ttl: ?u32 = null, + /// Sets the queue overflow behaviour. + /// This determines what happens to messages when the maximum length of a queue is reached. + overflow: ?QueueOverflow = null, + /// If set, makes sure only one consumer at a time consumes from the queue and fails over + /// to another registered consumer in case the active one is cancelled or dies. + /// (Sets the "x-single-active-consumer" argument.) + single_active_consumer: ?bool = false, + /// Optional name of an exchange to which messages will be republished + /// if they are rejected or expire. + /// (Sets the "x-dead-letter-exchange" argument.) + dead_letter_exchange: ?[]const u8 = null, + /// Optional replacement routing key to use when a message is dead-lettered. + /// If this is not set, the message's original routing key will be used. + /// (Sets the "x-dead-letter-routing-key" argument.) + dead_letter_routing_key: ?[]const u8 = null, + /// How many (ready) messages a queue can contain before it starts to drop them + /// from its head. + /// (Sets the "x-max-length" argument.) + max_length: ?u32 = null, + /// Total body size for ready messages a queue can contain before it starts to drop + /// them from its head. + /// (Sets the "x-max-length-bytes" argument.) + max_length_bytes: ?u32 = null, + + pub fn table(self: *const QueueDeclareArguments) Encoder.Table { + const vtable: Encoder.Table.VTable = comptime .{ + .write = &struct { + fn write(context: *const anyopaque, encoder: *Encoder.TableEncoder) void { + const arguments: *const QueueDeclareArguments = @ptrCast(@alignCast(context)); + inline for (std.meta.fields(QueueDeclareArguments)) |field| { + if (@field(arguments, field.name)) |value| { + const FieldType = @TypeOf(value); + // Keys are follow the pattern "x-max-length": + const key = comptime "x-" ++ + vsr.stdx.to_case(field.name, .@"kebab-case"); + switch (FieldType) { + []const u8 => encoder.put(key, .{ .string = value }), + QueueOverflow => encoder.put(key, .{ .string = switch (value) { + inline else => |tag| comptime "" ++ + vsr.stdx.to_case(@tagName(tag), .@"kebab-case"), + } }), + bool => encoder.put(key, .{ .boolean = value }), + u32 => encoder.put(key, .{ .uint32 = value }), + else => comptime unreachable, + } + } + } + } + }.write, + }; + return .{ .context = self, .vtable = &vtable }; + } +}; + +pub const BasicPublishOptions = struct { + /// Specifies the name of the exchange to publish to. The exchange name can be + /// empty, meaning the default exchange. If the exchange name is specified, and that + /// exchange does not exist, the server will raise a channel exception. + exchange: []const u8, + /// Message routing key. + /// Specifies the routing key for the message. The routing key is used for routing + /// messages depending on the exchange configuration. + routing_key: []const u8, + /// Indicate mandatory routing. + /// This flag tells the server how to react if the message cannot be routed to a + /// queue. If this flag is set, the server will return an unroutable message with a + /// Return method. If this flag is zero, the server silently drops the message. + mandatory: bool, + /// Request immediate delivery. + /// This flag tells the server how to react if the message cannot be routed to a + /// queue consumer immediately. If this flag is set, the server will return an + /// undeliverable message with a Return method. If this flag is zero, the server + /// will queue the message, but with no guarantee that it will ever be consumed. + immediate: bool, + /// Metadata associated with the message. + properties: Encoder.BasicProperties, + /// The message payload. + body: ?Encoder.Body, +}; + +pub const QueueDeclareOptions = struct { + queue: []const u8, + /// Do not create queue. + /// If set, the server will reply with Declare-Ok if the queue already + /// exists with the same name, and raise an error if not. The client can + /// use this to check whether a queue exists without modifying the + /// server state. When set, all other method fields except name and no-wait + /// are ignored. A declare with both passive and no-wait has no effect. + /// Arguments are compared for semantic equivalence. + passive: bool, + /// Request a durable queue. + /// If set when creating a new queue, the queue will be marked as durable. Durable + /// queues remain active when a server restarts. Non-durable queues (transient + /// queues) are purged if/when a server restarts. Note that durable queues do not + /// necessarily hold persistent messages, although it does not make sense to send + /// persistent messages to a transient queue. + durable: bool, + /// Request an exclusive queue. + /// Exclusive queues may only be accessed by the current connection, and are + /// deleted when that connection closes. Passive declaration of an exclusive + /// queue by other connections are not allowed. + exclusive: bool, + /// Auto-delete queue when unused. + /// If set, the queue is deleted when all consumers have finished using it. The last + /// consumer can be cancelled either explicitly or because its channel is closed. If + /// there was no consumer ever on the queue, it won't be deleted. Applications can + /// explicitly delete auto-delete queues using the Delete method as normal. + auto_delete: bool, + /// A set of implementation-defined arguments for the declaration. + /// The syntax and semantics of these arguments are specific to RabbitMQ. + arguments: QueueDeclareArguments, +}; + +pub const ExchangeDeclareOptions = struct { + exchange: []const u8, + /// Exchange type. + /// Each exchange belongs to one of a set of exchange types implemented by the + /// server. The exchange types define the functionality of the exchange - i.e. how + /// messages are routed through it. It is not valid or meaningful to attempt to + /// change the type of an existing exchange. + type: []const u8, + /// Do not create exchange. + /// If set, the server will reply with Declare-Ok if the exchange already + /// exists with the same name, and raise an error if not. The client can + /// use this to check whether an exchange exists without modifying the + /// server state. When set, all other method fields except name and no-wait + /// are ignored. A declare with both passive and no-wait has no effect. + /// Arguments are compared for semantic equivalence. + passive: bool, + /// Request a durable exchange. + /// If set when creating a new exchange, the exchange will be marked as durable. + /// Durable exchanges remain active when a server restarts. Non-durable exchanges + /// (transient exchanges) are purged if/when a server restarts. + durable: bool, + /// Auto-delete when unused. + /// If set, the exchange is deleted when all queues have + /// finished using it. + auto_delete: bool, + /// Create internal exchange. + /// If set, the exchange may not be used directly by publishers, + /// but only when bound to other exchanges. Internal exchanges + /// are used to construct wiring that is not visible to + /// applications. + internal: bool, +}; + +pub const GetMessageOptions = struct { + /// Specifies the name of the queue to get a message from. + queue: []const u8, + /// When `true`, indicates that the message does not require an acknowledgment + /// and will be instantly acknowledged upon delivery. + no_ack: bool, +}; + +pub const GetMessagePropertiesResult = struct { + /// Delivery tag used to acknowledge or reject the message. + delivery_tag: u64, + /// The number of messages still available in the queue. + message_count: u32, + /// Basic properties including custom headers. + properties: Decoder.BasicProperties, + /// Indicates whether the message includes a body frame. + has_body: bool, +}; + +pub const BasicNackOptions = struct { + delivery_tag: u64, + /// Requeue the message. + /// If requeue is true, the server will attempt to requeue the message. + requeue: bool, + /// Reject multiple messages. + /// If set to `true`, the delivery tag is treated as "up to and including", + /// so that multiple messages can be rejected with a single method. + /// If set to zero, the delivery tag refers to a single message. + /// If the multiple field is `true`, and the delivery tag is `false`, + /// this indicates rejection of all outstanding messages. + multiple: bool, +}; diff --git a/ocam/src/cdc/runner.zig b/ocam/src/cdc/runner.zig new file mode 100644 index 00000000..a4b3b1ac --- /dev/null +++ b/ocam/src/cdc/runner.zig @@ -0,0 +1,1719 @@ +const std = @import("std"); +const log = std.log.scoped(.amqp); + +const vsr = @import("../vsr.zig"); +const assert = std.debug.assert; +const maybe = vsr.stdx.maybe; + +const stdx = vsr.stdx; +const tb = vsr.tigerbeetle; +const IO = vsr.io.IO; +const Time = vsr.time.Time; +const MessagePool = vsr.message_pool.MessagePool; +const MessageBus = vsr.message_bus.MessageBusType(IO); +const Operation = vsr.tigerbeetle.Operation; +const Client = vsr.ClientType(Operation, MessageBus); +const TimestampRange = vsr.lsm.TimestampRange; + +pub const amqp = @import("amqp.zig"); + +/// CDC processor targeting an AMQP 0.9.1 compliant server (e.g., RabbitMQ). +/// Producer: TigerBeetle `get_change_events` operation. +/// Consumer: AMQP publisher. +/// Both consumer and producer run concurrently using `io_uring`. +/// See `DualBuffer` for more details. +pub const Runner = struct { + const StateRecoveryMode = union(enum) { + recover, + override: u64, + }; + + const constants = struct { + const tick_ms = vsr.constants.tick_ms; + const app_id = "tigerbeetle"; + const progress_tracker_queue = "tigerbeetle.internal.progress"; + const locker_queue = "tigerbeetle.internal.locker"; + + const idle_interval_default: stdx.Duration = .seconds(1); + const amqp_timeout_default: stdx.Duration = .seconds(30); + const tigerbeetle_timeout_default: stdx.Duration = .seconds(30); + const event_count_max_default: u32 = Operation.get_change_events.result_max( + vsr.constants.message_body_size_max, + ); + }; + + io: IO, + idle_completion: IO.Completion = undefined, + idle_interval: stdx.Duration, + event_count_max: u32, + + message_pool: MessagePool, + vsr_client: Client, + vsr_client_timeout: vsr.Timeout, + buffer: DualBuffer, + + amqp_client: amqp.Client, + publish_exchange: []const u8, + publish_routing_key: []const u8, + progress_tracker_queue: []const u8, + locker_queue: []const u8, + + connected: struct { + /// VSR client registered. + vsr: bool = false, + /// AMQP client connected and ready to publish. + amqp: bool = false, + }, + /// The producer is responsible for reading events from TigerBeetle. + producer: enum { + idle, + // Waiting for the rate limit to allow more requests. + rate_limit, + // Calling the VSR client. + request, + /// No events to publish. + /// Waiting for the idle timeout to check for new events. + waiting, + }, + + /// The consumer is responsible to publish events on the AMQP server. + consumer: enum { + idle, + publish, + progress_update, + }, + + rate_limit: RateLimit, + metrics: Metrics, + + state: union(enum) { + unknown: StateRecoveryMode, + recovering: struct { + timestamp_last: ?u64, + phase: union(enum) { + validate_exchange, + declare_locker_queue, + declare_progress_queue, + get_progress_message, + nack_progress_message: struct { + delivery_tag: u64, + }, + }, + }, + last: struct { + /// Last event read from TigerBeetle. + producer_timestamp: u64, + /// Last event published. + consumer_timestamp: u64, + }, + }, + + pub fn init( + self: *Runner, + allocator: std.mem.Allocator, + time: Time, + options: struct { + /// TigerBeetle cluster ID. + cluster_id: u128, + /// TigerBeetle cluster addresses. + addresses: []const stdx.SocketAddress, + /// AMQP host address. + host: stdx.SocketAddress, + /// AMQP User name for PLAIN authentication. + user: []const u8, + /// AMQP Password for PLAIN authentication. + password: []const u8, + /// AMQP vhost. + vhost: []const u8, + /// AMQP exchange name for publishing messages. + publish_exchange: ?[]const u8, + /// AMQP routing key for publishing messages. + publish_routing_key: ?[]const u8, + /// Overrides the number max of events produced/consumed each time. + event_count_max: ?u32, + /// Overrides the number of milliseconds to query again if there's no new events to + /// process. + /// Must be greater than zero. + idle_interval_ms: ?u32, + /// Limits the number of requests per second. + /// Must be greater than zero. + requests_per_second_limit: ?u32, + /// Overrides the timeout, in seconds, + /// for receiving a reply from the AMQP server. + /// Must be greater than zero. + amqp_timeout_seconds: ?u32, + /// Overrides the timeout, in seconds, + /// for receiving a reply from the TigerBeetle cluster. + /// Must be greater than zero. + tigerbeetle_timeout_seconds: ?u32, + /// Indicates whether to recover the last timestamp published on the state + /// tracker queue, or override it with a user-defined value. + recovery_mode: StateRecoveryMode, + }, + ) !void { + assert(options.addresses.len > 0); + + const idle_interval: stdx.Duration = if (options.idle_interval_ms) |value| + .ms(value) + else + constants.idle_interval_default; + assert(idle_interval.ns > 0); + + const event_count_max: u32 = if (options.event_count_max) |event_count_max| + @min(event_count_max, constants.event_count_max_default) + else + constants.event_count_max_default; + assert(event_count_max > 0); + + const amqp_timeout: stdx.Duration = if (options.amqp_timeout_seconds) |value| + .seconds(value) + else + constants.amqp_timeout_default; + assert(amqp_timeout.ns > 0); + + const tigerbeetle_timeout: stdx.Duration = if (options.tigerbeetle_timeout_seconds) |value| + .seconds(value) + else + constants.tigerbeetle_timeout_default; + assert(tigerbeetle_timeout.ns > 0); + + const publish_exchange: []const u8 = options.publish_exchange orelse ""; + const publish_routing_key: []const u8 = options.publish_routing_key orelse ""; + assert(publish_exchange.len > 0 or publish_routing_key.len > 0); + + const progress_tracker_queue_owned: []const u8 = try std.fmt.allocPrint( + allocator, + "{s}.{}", + .{ + constants.progress_tracker_queue, + options.cluster_id, + }, + ); + errdefer allocator.free(progress_tracker_queue_owned); + assert(progress_tracker_queue_owned.len <= 255); + + const locker_queue_owned: []const u8 = try std.fmt.allocPrint( + allocator, + "{s}.{}", + .{ + constants.locker_queue, + options.cluster_id, + }, + ); + errdefer allocator.free(locker_queue_owned); + assert(locker_queue_owned.len <= 255); + + const dual_buffer = try DualBuffer.init(allocator, event_count_max); + errdefer self.buffer.deinit(allocator); + + self.* = .{ + .idle_interval = idle_interval, + .event_count_max = event_count_max, + .publish_exchange = publish_exchange, + .publish_routing_key = publish_routing_key, + .progress_tracker_queue = progress_tracker_queue_owned, + .locker_queue = locker_queue_owned, + .connected = .{}, + .io = undefined, + .producer = .idle, + .consumer = .idle, + .rate_limit = undefined, + .metrics = undefined, + .state = .{ .unknown = options.recovery_mode }, + .buffer = dual_buffer, + .message_pool = undefined, + .vsr_client = undefined, + .vsr_client_timeout = undefined, + .amqp_client = undefined, + }; + + self.rate_limit = RateLimit.init(time, .{ + .limit = options.requests_per_second_limit orelse std.math.maxInt(u32), + // The rate limit is expressed in "requests per second". + .period = .seconds(1), + }); + + self.metrics = .{ + .producer = .{ + .timer = .init(time), + }, + .consumer = .{ + .timer = .init(time), + }, + .flush_ticks = 0, + .flush_timeout_ticks = @divExact(30 * std.time.ms_per_s, constants.tick_ms), + }; + + self.io = try IO.init(32, 0); + errdefer self.io.deinit(); + + self.message_pool = try MessagePool.init(allocator, .client); + errdefer self.message_pool.deinit(allocator); + + self.vsr_client = try Client.init( + allocator, + time, + &self.message_pool, + .{ + .id = stdx.unique_u128(), + .cluster = options.cluster_id, + .replica_count = @intCast(options.addresses.len), + .aof_recovery = false, + .message_bus_options = .{ + .configuration = options.addresses, + .io = &self.io, + .trace = null, + .time = time, + }, + }, + ); + errdefer self.vsr_client.deinit(allocator); + + self.vsr_client_timeout = .{ + .name = "vsr_client_timeout", + .id = self.vsr_client.id, + .after = stdx.div_ceil( + tigerbeetle_timeout.to_ms(), + constants.tick_ms, + ), + }; + + self.amqp_client = try amqp.Client.init(allocator, .{ + .io = &self.io, + .message_count_max = self.event_count_max, + .message_body_size_max = Message.json_string_size_max, + .reply_timeout_ticks = stdx.div_ceil( + amqp_timeout.to_ms(), + constants.tick_ms, + ), + }); + errdefer self.amqp_client.deinit(allocator); + + // Starting both the VSR and the AMQP clients: + + try self.amqp_client.connect( + &struct { + fn callback(context: *amqp.Client) void { + const runner: *Runner = @alignCast(@fieldParentPtr("amqp_client", context)); + assert(!runner.connected.amqp); + maybe(runner.connected.vsr); + log.info("AMQP connected.", .{}); + runner.connected.amqp = true; + runner.recover(); + } + }.callback, + .{ + .host = options.host, + .user_name = options.user, + .password = options.password, + .vhost = options.vhost, + }, + ); + } + + pub fn deinit(self: *Runner, allocator: std.mem.Allocator) void { + self.amqp_client.deinit(allocator); + self.vsr_client.deinit(allocator); + self.message_pool.deinit(allocator); + self.io.deinit(); + self.buffer.deinit(allocator); + allocator.free(self.locker_queue); + allocator.free(self.progress_tracker_queue); + } + + /// To make the CDC stateless, internal queues are used to store the state: + /// + /// - Progress tracking queue: + /// A persistent queue with a maximum size of 1 message and "drop head" behavior on overflow. + /// During publishing, a message containing the last timestamp is pushed into this queue at + /// the end of each published batch. + /// On restart, the presence of a message indicates the `timestamp_min` from which to resume + /// processing events. Otherwise, processing starts from the beginning. + /// The queue name is generated to be unique based on the `cluster_id`. + /// The initial timestamp can be overridden via the command line. + /// + /// - Locker queue: + /// A temporary, exclusive queue used to ensure that only a single CDC process is publishing + /// at any given time. This queue is not used for publishing or consuming messages. + /// The queue name is generated to be unique based on the `cluster_id`. + fn recover(self: *Runner) void { + assert(self.connected.amqp); + assert(self.state == .unknown); + const recovery_mode = self.state.unknown; + const timestamp_override: ?u64 = switch (recovery_mode) { + .recover => null, + .override => |timestamp| timestamp, + }; + + const is_default_exchange = self.publish_exchange.len == 0; + self.state = .{ + .recovering = .{ + .timestamp_last = timestamp_override, + .phase = if (is_default_exchange) + // No need to validate the default exchange, skipping `validate_exchange`. + .declare_locker_queue + else + .validate_exchange, + }, + }; + self.recover_dispatch(); + } + + fn recover_dispatch(self: *Runner) void { + assert(self.connected.amqp); + assert(self.state == .recovering); + switch (self.state.recovering.phase) { + // Check whether the exchange exists. + // Declaring the exchange with `passive==true` only asserts if it already exists. + .validate_exchange => { + assert(self.publish_exchange.len > 0); + maybe(self.state.recovering.timestamp_last == null); + + self.amqp_client.exchange_declare( + &struct { + fn callback(context: *amqp.Client) void { + const runner: *Runner = @alignCast(@fieldParentPtr( + "amqp_client", + context, + )); + assert(runner.state == .recovering); + const recovering = &runner.state.recovering; + assert(recovering.phase == .validate_exchange); + maybe(recovering.timestamp_last == null); + + recovering.phase = .declare_locker_queue; + runner.recover_dispatch(); + } + }.callback, + .{ + .exchange = self.publish_exchange, + .type = "", + .passive = true, + .durable = false, + .internal = false, + .auto_delete = false, + }, + ); + }, + // Declaring the locker queue. + // With `durable=false`, a temporary queue is created that exists only for the + // duration of the current connection, and with `exclusive=true`, no other connection + // can declare the same queue while this one is active. + // This effectively acts as a distributed lock to prevent multiple CDC + // instances from running simultaneously. + .declare_locker_queue => { + maybe(self.state.recovering.timestamp_last == null); + + self.amqp_client.queue_declare( + &struct { + fn callback(context: *amqp.Client) void { + const runner: *Runner = @alignCast(@fieldParentPtr( + "amqp_client", + context, + )); + switch (runner.state) { + .recovering => |*recovering| { + assert(recovering.phase == .declare_locker_queue); + maybe(recovering.timestamp_last == null); + + recovering.phase = .declare_progress_queue; + runner.recover_dispatch(); + }, + else => unreachable, + } + } + }.callback, + .{ + .queue = self.locker_queue, + .passive = false, + .durable = false, + .exclusive = true, + .auto_delete = true, + .arguments = .{ + .overflow = .drop_head, + .max_length = 0, + .max_length_bytes = 0, + .single_active_consumer = true, + }, + }, + ); + }, + // Declaring the progress tracking queue. + // It's a no-op if the queue already exists. + .declare_progress_queue => { + maybe(self.state.recovering.timestamp_last == null); + + self.amqp_client.queue_declare( + &struct { + fn callback(context: *amqp.Client) void { + const runner: *Runner = @alignCast(@fieldParentPtr( + "amqp_client", + context, + )); + switch (runner.state) { + .recovering => |*recovering| { + assert(recovering.phase == .declare_progress_queue); + + // Overriding the progress-tracking timestamp. + if (recovering.timestamp_last) |timestamp_override| { + runner.state = .{ + .last = .{ + .consumer_timestamp = timestamp_override, + .producer_timestamp = timestamp_override + 1, + }, + }; + return runner.vsr_register(); + } + assert(recovering.timestamp_last == null); + + recovering.phase = .get_progress_message; + runner.recover_dispatch(); + }, + else => unreachable, + } + } + }.callback, + .{ + .queue = self.progress_tracker_queue, + .passive = false, + .durable = true, + .exclusive = false, + .auto_delete = false, + .arguments = .{ + .overflow = .drop_head, + .max_length = 1, + .max_length_bytes = 0, + .single_active_consumer = true, + }, + }, + ); + }, + // Getting the message header from the progress tracking queue. + .get_progress_message => { + assert(self.state.recovering.timestamp_last == null); + self.amqp_client.get_message( + &struct { + fn callback( + context: *amqp.Client, + found: ?amqp.GetMessagePropertiesResult, + ) amqp.Decoder.Error!void { + const runner: *Runner = @alignCast(@fieldParentPtr( + "amqp_client", + context, + )); + switch (runner.state) { + .recovering => |*recovering| { + assert(recovering.phase == .get_progress_message); + assert(recovering.timestamp_last == null); + + if (found) |result| { + // Since this queue is declared with `limit == 1`, + // we don't expect more than one message. + if (result.message_count > 0) fatal( + "Unexpected message_count={} in the progress queue.", + .{result.message_count}, + ); + assert(!result.has_body); + assert(result.delivery_tag > 0); + // Recovering from a valid timestamp is crucial, + // otherwise `get_change_events` may return empty results + // due to invalid filters. + const progress_tracker = try ProgressTrackerMessage.parse( + result.properties.headers, + ); + assert(TimestampRange.valid(progress_tracker.timestamp)); + + // Downgrading the CDC job is not allowed. + if (vsr.constants.config.process.release.value < + progress_tracker.release.value) + { + fatal("The last event was published using a newer " ++ + "release (event={} current={}).", .{ + progress_tracker.release, + vsr.constants.config.process.release, + }); + } + + recovering.timestamp_last = progress_tracker.timestamp; + recovering.phase = .{ + .nack_progress_message = .{ + .delivery_tag = result.delivery_tag, + }, + }; + + return runner.recover_dispatch(); + } + + // No previous progress record found, + // starting from the beginning. + assert(found == null); + runner.state = .{ .last = .{ + .consumer_timestamp = 0, + .producer_timestamp = TimestampRange.timestamp_min, + } }; + runner.vsr_register(); + }, + else => unreachable, + } + } + }.callback, + .{ + .queue = self.progress_tracker_queue, + .no_ack = false, + }, + ); + }, + // Sending a `nack` with `requeue=true`, so the message remains in the progress + // tracking queue in case we restart and need to recover again. + .nack_progress_message => |message| { + assert(self.state.recovering.timestamp_last != null); + assert(TimestampRange.valid(self.state.recovering.timestamp_last.?)); + assert(message.delivery_tag > 0); + + self.amqp_client.nack(&struct { + fn callback(context: *amqp.Client) void { + const runner: *Runner = @alignCast(@fieldParentPtr( + "amqp_client", + context, + )); + switch (runner.state) { + .recovering => |*recovering| { + assert(recovering.phase == .nack_progress_message); + assert(recovering.timestamp_last != null); + assert(TimestampRange.valid(recovering.timestamp_last.?)); + + const nack = recovering.phase.nack_progress_message; + assert(nack.delivery_tag > 0); + + runner.state = .{ .last = .{ + .consumer_timestamp = recovering.timestamp_last.?, + .producer_timestamp = recovering.timestamp_last.? + 1, + } }; + runner.vsr_register(); + }, + else => unreachable, + } + } + }.callback, .{ + .delivery_tag = message.delivery_tag, + .requeue = true, + .multiple = false, + }); + }, + } + } + + fn vsr_register(self: *Runner) void { + assert(self.connected.amqp); + assert(!self.connected.vsr); + assert(self.producer == .idle); + assert(self.consumer == .idle); + assert(self.state == .last); + + // Register the VSR client as the last step to avoid unnecessarily joining the cluster + // in case the CDC fails due to connectivity or configuration issues with the AMQP server. + assert(!self.vsr_client_timeout.ticking); + self.vsr_client_timeout.start(); + self.vsr_client.register( + &struct { + fn callback( + user_data: u128, + result: *const vsr.RegisterResult, + ) void { + const runner: *Runner = @ptrFromInt(@as(usize, @intCast(user_data))); + assert(runner.connected.amqp); + assert(!runner.connected.vsr); + + assert(runner.vsr_client_timeout.ticking); + runner.vsr_client_timeout.stop(); + + log.info("VSR client registered.", .{}); + runner.vsr_client.batch_size_limit = result.batch_size_limit; + runner.connected.vsr = true; + + log.info("Starting CDC.", .{}); + runner.produce(); + } + }.callback, + @as(u128, @intCast(@intFromPtr(self))), + ); + } + + /// The "Producer" fetches events from TigerBeetle (`get_change_events` operation) into a buffer + /// to be consumed by the "Consumer". + fn produce(self: *Runner) void { + assert(self.connected.vsr); + assert(self.connected.amqp); + assert(self.state == .last); + assert(TimestampRange.valid(self.state.last.producer_timestamp)); + assert(self.state.last.consumer_timestamp == 0 or + TimestampRange.valid(self.state.last.consumer_timestamp)); + assert(self.state.last.producer_timestamp > self.state.last.consumer_timestamp); + switch (self.producer) { + .idle => { + if (!self.buffer.producer_begin()) { + // No free buffers (they must be `ready` and `consuming`). + // The running consumer will resume the producer once it finishes. + assert(self.consumer != .idle); + assert(self.buffer.find(.ready) != null); + assert(self.buffer.find(.consuming) != null); + return; + } + self.producer = .rate_limit; + self.metrics.producer.timer.reset(); + self.produce_dispatch(); + }, + else => unreachable, // Already running. + } + } + + fn produce_dispatch(self: *Runner) void { + assert(self.connected.vsr); + assert(self.connected.amqp); + assert(self.state == .last); + assert(TimestampRange.valid(self.state.last.producer_timestamp)); + assert(self.state.last.consumer_timestamp == 0 or + TimestampRange.valid(self.state.last.consumer_timestamp)); + assert(self.state.last.producer_timestamp > self.state.last.consumer_timestamp); + dispatch: switch (self.producer) { + .idle => unreachable, + // Check the configured rate limit. + .rate_limit => switch (self.rate_limit.attempt()) { + .ok => { + self.producer = .request; + continue :dispatch self.producer; + }, + .wait => |duration| { + assert(duration.ns > 0); + self.io.timeout( + *Runner, + self, + struct { + fn callback( + runner: *Runner, + completion: *IO.Completion, + result: IO.TimeoutError!void, + ) void { + result catch unreachable; + _ = completion; + assert(runner.producer == .rate_limit); + assert(runner.buffer.find(.producing) != null); + maybe(runner.consumer == .idle); + + runner.producer = .request; + runner.produce_dispatch(); + } + }.callback, + &self.idle_completion, + @intCast(duration.ns), + ); + }, + }, + // Submitting the request through the VSR client. + .request => { + assert(self.buffer.find(.producing) != null); + + const filter: tb.ChangeEventsFilter = .{ + .limit = self.event_count_max, + .timestamp_min = self.state.last.producer_timestamp, + .timestamp_max = 0, + }; + + assert(!self.vsr_client_timeout.ticking); + self.vsr_client_timeout.start(); + self.vsr_client.request( + &produce_request_callback, + @intFromPtr(self), + .get_change_events, + std.mem.asBytes(&filter), + ); + }, + // No running consumer and no events returned from the last query, + // waiting for the timeout to resume the producer. + .waiting => { + self.io.timeout( + *Runner, + self, + struct { + fn callback( + runner: *Runner, + completion: *IO.Completion, + result: IO.TimeoutError!void, + ) void { + result catch unreachable; + _ = completion; + assert(runner.buffer.all_free()); + assert(runner.consumer == .idle); + assert(runner.producer == .waiting); + + const producer_begin = runner.buffer.producer_begin(); + assert(producer_begin); + runner.producer = .rate_limit; + runner.produce_dispatch(); + } + }.callback, + &self.idle_completion, + @intCast(self.idle_interval.ns), + ); + }, + } + } + + fn produce_request_callback( + context: u128, + operation_vsr: vsr.Operation, + timestamp: u64, + result: []align(vsr.constants.cache_line_size) const u8, + ) void { + const operation = operation_vsr.cast(tb.Operation); + assert(operation == .get_change_events); + assert(timestamp != 0); + const runner: *Runner = @ptrFromInt(@as(usize, @intCast(context))); + assert(runner.producer == .request); + + assert(runner.vsr_client_timeout.ticking); + runner.vsr_client_timeout.stop(); + + const source: []const tb.ChangeEvent = stdx.bytes_as_slice(.exact, tb.ChangeEvent, result); + const target: []tb.ChangeEvent = runner.buffer.get_producer_buffer(); + assert(source.len <= target.len); + + stdx.copy_disjoint( + .inexact, + tb.ChangeEvent, + target, + source, + ); + runner.buffer.producer_finish(@intCast(source.len)); + + if (runner.buffer.all_free()) { + // No events to publish. + // Going idle and will check again for new events. + assert(source.len == 0); + assert(runner.consumer == .idle); + runner.producer = .waiting; + return runner.produce_dispatch(); + } + + runner.producer = .idle; + runner.metrics.producer.record(source.len); + assert(source.len > 0 or runner.consumer != .idle); + if (source.len > 0) { + const timestamp_next = source[source.len - 1].timestamp + 1; + assert(TimestampRange.valid(timestamp_next)); + runner.state.last.producer_timestamp = timestamp_next; + + // Since the buffer was populated, + // resume consuming (if not already running). + if (runner.consumer == .idle) runner.consume(); + + // Resume producing (if there's a free buffer). + runner.produce(); + } + } + + /// The "Consumer" reads from the buffer populated by the "Producer" + /// and publishes the events to the AMQP server. + fn consume(self: *Runner) void { + assert(self.connected.vsr); + assert(self.connected.amqp); + assert(self.state == .last); + assert(TimestampRange.valid(self.state.last.producer_timestamp)); + assert(self.state.last.consumer_timestamp == 0 or + TimestampRange.valid(self.state.last.consumer_timestamp)); + assert(self.state.last.producer_timestamp > self.state.last.consumer_timestamp); + switch (self.consumer) { + .idle => { + if (!self.buffer.consumer_begin()) { + // No buffers ready (they must be both `free` or still `producing`). + // The running/waiting producer will resume the consumer once it finishes. + if (self.buffer.all_free()) { + assert(self.producer == .idle); + } else { + assert(self.producer == .rate_limit or self.producer == .request); + assert(self.buffer.find(.free) != null); + assert(self.buffer.find(.producing) != null); + } + return; + } + self.consumer = .publish; + self.metrics.consumer.timer.reset(); + self.consume_dispatch(); + }, + else => unreachable, // Already running. + } + } + + fn consume_dispatch(self: *Runner) void { + assert(self.connected.vsr); + assert(self.connected.amqp); + assert(self.state == .last); + assert(TimestampRange.valid(self.state.last.producer_timestamp)); + assert(self.state.last.consumer_timestamp == 0 or + TimestampRange.valid(self.state.last.consumer_timestamp)); + assert(self.state.last.producer_timestamp > self.state.last.consumer_timestamp); + switch (self.consumer) { + .idle => unreachable, + // Publishes a batch of events and waits until the AMQP server acknowledges it. + // N.B.: TigerBeetle guarantees at-least-once semantics when publishing, + // and makes a best effort to prevent duplicate messages. + // Publishing uses `confirm.select` instead of `tx.select`, as the former provides + // better performance with equivalent delivery guarantees. However, neither can + // ensure exactly-once delivery in case of crashes in the middle of the operation. + // From https://www.rabbitmq.com/docs/semantics: + // "RabbitMQ provides no atomicity guarantees even in case of transactions involving + // just a single queue, e.g. a fault during tx.commit can result in a sub-set of the + // transaction's publishes appearing in the queue after a broker restart. + .publish => { + const events: []const tb.ChangeEvent = self.buffer.get_consumer_buffer(); + assert(events.len > 0); + for (events) |*event| { + const message = Message.init(event); + self.amqp_client.publish_enqueue(.{ + .exchange = self.publish_exchange, + .routing_key = self.publish_routing_key, + .mandatory = true, + .immediate = false, + .properties = .{ + .content_type = Message.content_type, + .delivery_mode = .persistent, + .app_id = constants.app_id, + // AMQP timestamp in seconds. + .timestamp = @divTrunc(event.timestamp, std.time.ns_per_s), + .headers = message.header(), + }, + .body = message.body(), + }); + } + self.amqp_client.publish_send(&struct { + fn callback(context: *amqp.Client) void { + const runner: *Runner = @alignCast(@fieldParentPtr( + "amqp_client", + context, + )); + assert(runner.consumer == .publish); + runner.consumer = .progress_update; + runner.consume_dispatch(); + } + }.callback); + }, + // Publishes the progress-tracking message containing the last timestamp + // *after* the batch of events has been acknowledged by the AMQP server. + .progress_update => { + const progress_tracker: ProgressTrackerMessage = progress: { + const events = self.buffer.get_consumer_buffer(); + assert(events.len > 0); + break :progress .{ + .timestamp = events[events.len - 1].timestamp, + .release = vsr.constants.config.process.release, + }; + }; + self.amqp_client.publish_enqueue(.{ + .exchange = "", // No exchange sends directly to this queue. + .routing_key = self.progress_tracker_queue, + .mandatory = true, + .immediate = false, + .properties = .{ + .delivery_mode = .persistent, + .timestamp = @intCast(std.time.milliTimestamp()), + .headers = progress_tracker.header(), + }, + .body = null, + }); + self.amqp_client.publish_send(&struct { + fn callback(context: *amqp.Client) void { + const runner: *Runner = @alignCast(@fieldParentPtr( + "amqp_client", + context, + )); + assert(runner.consumer == .progress_update); + + const event_count: usize, const timestamp_last: u64 = events: { + const events = runner.buffer.get_consumer_buffer(); + assert(events.len > 0); + break :events .{ events.len, events[events.len - 1].timestamp }; + }; + runner.buffer.consumer_finish(); + runner.state.last.consumer_timestamp = timestamp_last; + + // Resume consuming (if there's a buffer ready). + runner.consumer = .idle; + runner.metrics.consumer.record(event_count); + runner.consume(); + + // Since the buffer was released, + // resume producing (if not already running). + if (runner.producer == .idle) runner.produce(); + } + }.callback); + }, + } + } + + pub fn tick(self: *Runner) void { + assert(!self.vsr_client.evicted); + self.vsr_client.tick(); + self.amqp_client.tick(); + self.io.run_for_ns(constants.tick_ms * std.time.ns_per_ms) catch unreachable; + + self.metrics.tick(); + + self.vsr_client_timeout.tick(); + if (self.vsr_client_timeout.fired()) { + const timeout: stdx.Duration = .ms(self.vsr_client_timeout.ticks * constants.tick_ms); + fatal("Timed out: no reply from the TigerBeetle cluster within {}.", .{timeout}); + } + } +}; + +/// Terminates the process with non-zero exit code. +/// Similar to `vsr.fatal`, but not logged in the `vsr` scope. +fn fatal(comptime format: []const u8, args: anytype) noreturn { + log.err(format, args); + + const status = vsr.FatalReason.cli.exit_status(); + assert(status != 0); + std.process.exit(status); +} + +/// Rate limit to throttle the maximum number of requests to TigerBeetle within a time period. +pub const RateLimit = struct { + const Options = struct { + /// The rate limit expressed as "requests per second". + /// Must be greater than zero. Inclusive. + limit: u32, + /// Time interval used to enforce the request limit. + period: stdx.Duration, + }; + + count: u32, + timer: vsr.time.Timer, + options: Options, + + pub fn init(time: vsr.time.Time, options: Options) RateLimit { + assert(options.limit > 0); + assert(options.period.ns > 0); + + return .{ + .count = 0, + .timer = .init(time), + .options = options, + }; + } + + /// Attempt to increment the counter. + /// Return `.ok` if it succeed within the configured `limit` per second, + /// or `.wait` with the required amount of time to wait. + pub fn attempt(self: *RateLimit) union(enum) { + ok, + wait: stdx.Duration, + } { + assert(self.options.limit > 0); + assert(self.options.period.ns > 0); + assert(self.count <= self.options.limit); + + if (self.count == 0) { + self.timer.reset(); + self.count = 1; + return .ok; + } + assert(self.count > 0); + + const duration = self.timer.read(); + maybe(duration.ns == 0); + + if (duration.ns >= self.options.period.ns) { + self.timer.reset(); + self.count = 0; + } else if (self.count == self.options.limit) { + assert(duration.ns < self.options.period.ns); + return .{ .wait = .{ + .ns = self.options.period.ns - duration.ns, + } }; + } + + self.count += 1; + assert(self.count <= self.options.limit); + + return .ok; + } +}; + +/// Inspired by the StateMachine metrics, +/// though the current method of shipping the metrics is a temporary solution. +const Metrics = struct { + const TimingSummary = struct { + timer: vsr.time.Timer, + + duration_min: ?stdx.Duration = null, + duration_max: ?stdx.Duration = null, + duration_sum: stdx.Duration = .{ .ns = 0 }, + event_count: u64 = 0, + count: u64 = 0, + + fn record( + metrics: *TimingSummary, + event_count: u64, + ) void { + metrics.timing( + event_count, + metrics.timer.read(), + ); + } + + fn timing( + metrics: *TimingSummary, + event_count: u64, + duration: stdx.Duration, + ) void { + maybe(duration.ns == 0); + maybe(event_count == 0); + + metrics.count += 1; + metrics.event_count += event_count; + metrics.duration_min = if (metrics.duration_min) |min| duration.min(min) else duration; + metrics.duration_max = if (metrics.duration_max) |max| duration.max(max) else duration; + metrics.duration_sum.ns += duration.ns; + } + }; + + producer: TimingSummary, + consumer: TimingSummary, + flush_ticks: u64, + flush_timeout_ticks: u64, + + fn tick(self: *Metrics) void { + assert(self.flush_ticks < self.flush_timeout_ticks); + self.flush_ticks += 1; + if (self.flush_ticks == self.flush_timeout_ticks) { + self.flush_ticks = 0; + self.log_and_reset(); + } + } + + fn log_and_reset(metrics: *Metrics) void { + const Fields = enum { producer, consumer }; + const runner: *const Runner = @alignCast(@fieldParentPtr("metrics", metrics)); + inline for (comptime std.enums.values(Fields)) |field| { + const summary: *TimingSummary = &@field(metrics, @tagName(field)); + if (summary.count > 0 and summary.duration_sum.ns > 0) { + assert(runner.state == .last); + assert(summary.duration_min != null); + assert(summary.duration_max != null); + + const timestamp_last = switch (field) { + .consumer => runner.state.last.consumer_timestamp, + .producer => runner.state.last.producer_timestamp, + }; + const event_rate = @divTrunc( + summary.event_count * std.time.ns_per_s, + summary.duration_sum.ns, + ); + log.info("{s}: p0={}ms mean={}ms p100={}ms " ++ + "event_count={} throughput={} op/s " ++ + "last timestamp={} ({})", .{ + @tagName(field), + summary.duration_min.?.to_ms(), + @divFloor(summary.duration_sum.to_ms(), summary.count), + summary.duration_max.?.to_ms(), + summary.event_count, + event_rate, + timestamp_last, + stdx.InstantUnix{ .ns = timestamp_last }, + }); + } + summary.* = .{ + .timer = summary.timer, + }; + } + } +}; + +/// Buffers swapped between producer and consumer, allowing reading from TigerBeetle +/// and publishing to AMQP to happen concurrently. +const DualBuffer = struct { + const State = enum { + free, + producing, + ready, + consuming, + }; + + const Buffer = struct { + buffer: []tb.ChangeEvent, + state: union(State) { + free, + producing, + ready: u32, + consuming: u32, + } = .free, + }; + + buffer_1: Buffer, + buffer_2: Buffer, + + pub fn init(allocator: std.mem.Allocator, event_count: u32) !DualBuffer { + assert(event_count > 0); + assert(event_count <= Runner.constants.event_count_max_default); + + const buffer_1 = try allocator.alloc(tb.ChangeEvent, event_count); + errdefer allocator.free(buffer_1); + + const buffer_2 = try allocator.alloc(tb.ChangeEvent, event_count); + errdefer allocator.free(buffer_2); + + return .{ + .buffer_1 = .{ + .buffer = buffer_1, + .state = .free, + }, + .buffer_2 = .{ + .buffer = buffer_2, + .state = .free, + }, + }; + } + + pub fn deinit(self: *DualBuffer, allocator: std.mem.Allocator) void { + allocator.free(self.buffer_2.buffer); + allocator.free(self.buffer_1.buffer); + } + + pub fn producer_begin(self: *DualBuffer) bool { + self.assert_state(); + // Already producing. + assert(self.find(.producing) == null); + const buffer = self.find(.free) orelse + // No free buffers. + return false; + buffer.state = .producing; + return true; + } + + pub fn get_producer_buffer(self: *DualBuffer) []tb.ChangeEvent { + self.assert_state(); + const buffer = self.find(.producing).?; + return buffer.buffer; + } + + pub fn producer_finish(self: *DualBuffer, count: u32) void { + self.assert_state(); + const buffer = self.find(.producing).?; + buffer.state = if (count == 0) .free else .{ .ready = count }; + } + + pub fn consumer_begin(self: *DualBuffer) bool { + self.assert_state(); + // Already consuming. + assert(self.find(.consuming) == null); + const buffer = self.find(.ready) orelse + // No buffers ready. + return false; + const count = buffer.state.ready; + buffer.state = .{ .consuming = count }; + return true; + } + + pub fn get_consumer_buffer(self: *DualBuffer) []const tb.ChangeEvent { + self.assert_state(); + const buffer = self.find(.consuming).?; + return buffer.buffer[0..buffer.state.consuming]; + } + + pub fn consumer_finish(self: *DualBuffer) void { + self.assert_state(); + const buffer = self.find(.consuming).?; + buffer.state = .free; + } + + pub fn all_free(self: *const DualBuffer) bool { + return self.buffer_1.state == .free and + self.buffer_2.state == .free; + } + + fn find(self: *DualBuffer, state: State) ?*Buffer { + self.assert_state(); + if (self.buffer_1.state == state) return &self.buffer_1; + if (self.buffer_2.state == state) return &self.buffer_2; + return null; + } + + fn assert_state(self: *const DualBuffer) void { + // Two buffers: one can be producing while the other is consuming, + // but never two consumers or producers. + assert(!(self.buffer_1.state == .producing and self.buffer_2.state == .producing)); + assert(!(self.buffer_1.state == .consuming and self.buffer_2.state == .consuming)); + assert(!(self.buffer_1.state == .ready and self.buffer_2.state == .ready)); + maybe(self.buffer_1.state == .free and self.buffer_2.state == .free); + } +}; + +/// Progress tracker message with no body, containing the timestamp +/// and the release version of the last acknowledged publish. +const ProgressTrackerMessage = struct { + release: vsr.Release, + timestamp: u64, + + fn header(self: *const ProgressTrackerMessage) amqp.Encoder.Table { + const vtable: amqp.Encoder.Table.VTable = comptime .{ + .write = &struct { + fn write(context: *const anyopaque, encoder: *amqp.Encoder.TableEncoder) void { + const message: *const ProgressTrackerMessage = @ptrCast(@alignCast(context)); + var release_buffer: [ + std.fmt.count("{}", vsr.Release.from(.{ + .major = std.math.maxInt(u16), + .minor = std.math.maxInt(u8), + .patch = std.math.maxInt(u8), + })) + ]u8 = undefined; + encoder.put("release", .{ .string = std.fmt.bufPrint( + &release_buffer, + "{}", + .{message.release}, + ) catch unreachable }); + encoder.put("timestamp", .{ .int64 = @intCast(message.timestamp) }); + } + }.write, + }; + return .{ .context = self, .vtable = &vtable }; + } + + fn parse(table: ?amqp.Decoder.Table) amqp.Decoder.Error!ProgressTrackerMessage { + if (table) |headers| { + var timestamp: ?u64 = null; + var release: ?vsr.Release = null; + + // Intentionally allows the presence of header fields other than `timestamp`, + // since some plugin may insert additional headers into messages. + var iterator = headers.iterator(); + while (try iterator.next()) |entry| { + if (std.mem.eql(u8, entry.key, "timestamp")) { + switch (entry.value) { + .int64 => |int64| { + const value: u64 = @intCast(int64); + if (!TimestampRange.valid(value)) break; + timestamp = value; + }, + else => break, + } + } + if (std.mem.eql(u8, entry.key, "release")) { + switch (entry.value) { + .string => |value| { + release = vsr.Release.parse(value) catch break; + }, + else => break, + } + } + + if (timestamp != null and release != null) return .{ + .timestamp = timestamp.?, + .release = release.?, + }; + } + } + fatal( + \\Invalid progress tracker message. + \\Use `--timestamp-last` to restore a valid initial timestamp. + , .{}); + } +}; + +/// Message with the body in the JSON schema. +pub const Message = struct { + pub const content_type = "application/json"; + + pub const json_string_size_max = size: { + var counting_writer = std.io.countingWriter(std.io.null_writer); + std.json.stringify( + worse_case(Message), + stringify_options, + counting_writer.writer(), + ) catch unreachable; + break :size counting_writer.bytes_written; + }; + + const stringify_options = std.json.StringifyOptions{ + .whitespace = .minified, + .emit_nonportable_numbers_as_strings = true, + }; + + timestamp: u64, + type: tb.ChangeEventType, + ledger: u32, + transfer: struct { + id: u128, + amount: u128, + pending_id: u128, + user_data_128: u128, + user_data_64: u64, + user_data_32: u32, + timeout: u32, + code: u16, + flags: u16, + timestamp: u64, + }, + debit_account: struct { + id: u128, + debits_pending: u128, + debits_posted: u128, + credits_pending: u128, + credits_posted: u128, + user_data_128: u128, + user_data_64: u64, + user_data_32: u32, + code: u16, + flags: u16, + timestamp: u64, + }, + credit_account: struct { + id: u128, + debits_pending: u128, + debits_posted: u128, + credits_pending: u128, + credits_posted: u128, + user_data_128: u128, + user_data_64: u64, + user_data_32: u32, + code: u16, + flags: u16, + timestamp: u64, + }, + + pub fn init(event: *const tb.ChangeEvent) Message { + return .{ + .timestamp = event.timestamp, + .type = event.type, + .ledger = event.ledger, + .transfer = .{ + .id = event.transfer_id, + .amount = event.transfer_amount, + .pending_id = event.transfer_pending_id, + .user_data_128 = event.transfer_user_data_128, + .user_data_64 = event.transfer_user_data_64, + .user_data_32 = event.transfer_user_data_32, + .timeout = event.transfer_timeout, + .code = event.transfer_code, + .flags = @bitCast(event.transfer_flags), + .timestamp = event.transfer_timestamp, + }, + .debit_account = .{ + .id = event.debit_account_id, + .debits_pending = event.debit_account_debits_pending, + .debits_posted = event.debit_account_debits_posted, + .credits_pending = event.debit_account_credits_pending, + .credits_posted = event.debit_account_credits_posted, + .user_data_128 = event.debit_account_user_data_128, + .user_data_64 = event.debit_account_user_data_64, + .user_data_32 = event.debit_account_user_data_32, + .code = event.debit_account_code, + .flags = @bitCast(event.debit_account_flags), + .timestamp = event.debit_account_timestamp, + }, + .credit_account = .{ + .id = event.credit_account_id, + .debits_pending = event.credit_account_debits_pending, + .debits_posted = event.credit_account_debits_posted, + .credits_pending = event.credit_account_credits_pending, + .credits_posted = event.credit_account_credits_posted, + .user_data_128 = event.credit_account_user_data_128, + .user_data_64 = event.credit_account_user_data_64, + .user_data_32 = event.credit_account_user_data_32, + .code = event.credit_account_code, + .flags = @bitCast(event.credit_account_flags), + .timestamp = event.credit_account_timestamp, + }, + }; + } + + fn header(self: *const Message) amqp.Encoder.Table { + const vtable: amqp.Encoder.Table.VTable = comptime .{ + .write = &struct { + fn write(context: *const anyopaque, encoder: *amqp.Encoder.TableEncoder) void { + const message: *const Message = @ptrCast(@alignCast(context)); + encoder.put("event_type", .{ .string = @tagName(message.type) }); + + // N.B.: Unsigned integers like u32 and u16 are not universally supported by + // all RabbitMQ clients. + // To ensure compatibility, we promote them to a signed integer. + encoder.put("ledger", .{ .int64 = message.ledger }); + encoder.put("transfer_code", .{ .int32 = message.transfer.code }); + encoder.put("debit_account_code", .{ + .int32 = message.debit_account.code, + }); + encoder.put("credit_account_code", .{ + .int32 = message.credit_account.code, + }); + } + }.write, + }; + return .{ .context = self, .vtable = &vtable }; + } + + fn body(self: *const Message) amqp.Encoder.Body { + const vtable: amqp.Encoder.Body.VTable = comptime .{ + .write = &struct { + fn write(context: *const anyopaque, buffer: []u8) usize { + const message: *const Message = @ptrCast(@alignCast(context)); + var fbs = std.io.fixedBufferStream(buffer); + std.json.stringify(message, .{ + .whitespace = .minified, + .emit_nonportable_numbers_as_strings = true, + }, fbs.writer()) catch unreachable; + return fbs.pos; + } + }.write, + }; + return .{ .context = self, .vtable = &vtable }; + } + + /// Fill all fields for the largest string representation. + fn worse_case(comptime T: type) T { + var value: T = undefined; + for (std.meta.fields(T)) |field| { + @field(value, field.name) = switch (@typeInfo(field.type)) { + .int => std.math.maxInt(field.type), + .@"enum" => max: { + var name: []const u8 = ""; + for (std.enums.values(tb.ChangeEventType)) |tag| { + if (@tagName(tag).len > name.len) { + name = @tagName(tag); + } + } + break :max @field(field.type, name); + }, + .@"struct" => worse_case(field.type), + else => unreachable, + }; + } + return value; + } +}; + +const testing = std.testing; +const fixtures = @import("../testing/fixtures.zig"); + +test "amqp: RateLimit" { + // Simulated clock with 300ms resolution, + // to force an uneven ratio of 3.333 requests per second. + const resolution: u64 = 300 * std.time.ns_per_ms; + var time_sim = fixtures.init_time(.{ .resolution = resolution }); + const time = time_sim.time(); + var rate_limit = RateLimit.init( + time, + .{ + .limit = 3, + .period = .seconds(1), + }, + ); + + try testing.expect(rate_limit.attempt() == .ok); + time.tick(); + + try testing.expect(rate_limit.attempt() == .ok); + time.tick(); + + try testing.expect(rate_limit.attempt() == .ok); + try switch (rate_limit.attempt()) { + .ok => testing.expect(false), + .wait => |duration| testing.expectEqual( + // 3 requests in 600ms, needs to wait 400ms. + std.time.ns_per_s - (2 * resolution), + duration.ns, + ), + }; + time.tick(); + + try switch (rate_limit.attempt()) { + .ok => testing.expect(false), + .wait => |duration| testing.expectEqual( + // 3 requests in 900ms, needs to wait 100ms. + std.time.ns_per_s - (3 * resolution), + duration.ns, + ), + }; + time.tick(); + + try testing.expect(rate_limit.attempt() == .ok); + time.tick(); + + try testing.expect(rate_limit.attempt() == .ok); + time.tick(); + + try testing.expect(rate_limit.attempt() == .ok); + time.tick(); + + try testing.expect(rate_limit.attempt() == .wait); +} + +test "amqp: DualBuffer" { + const event_count_max = Runner.constants.event_count_max_default; + + var prng = stdx.PRNG.from_seed_testing(); + var dual_buffer = try DualBuffer.init(testing.allocator, event_count_max); + defer dual_buffer.deinit(testing.allocator); + + for (0..4096) |_| { + try testing.expect(dual_buffer.all_free()); + + // Starts a producer: + const producer_begin = dual_buffer.producer_begin(); + try testing.expect(producer_begin); + try testing.expect(!dual_buffer.all_free()); + // We can't consume yet. + try testing.expect(!dual_buffer.consumer_begin()); + + const producer1_buffer = dual_buffer.get_producer_buffer(); + try testing.expectEqual(@as(usize, event_count_max), producer1_buffer.len); + + const producer1_count = prng.range_inclusive(u32, 1, event_count_max); + prng.fill(std.mem.sliceAsBytes(producer1_buffer[0..producer1_count])); + dual_buffer.producer_finish(producer1_count); + + // Starts a consumer after the producer has finished: + const consumer_begin = dual_buffer.consumer_begin(); + try testing.expect(consumer_begin); + try testing.expect(!dual_buffer.all_free()); + + // Concurrently starts another producer: + const producer_begin_concurrently = dual_buffer.producer_begin(); + try testing.expect(producer_begin_concurrently); + try testing.expect(!dual_buffer.all_free()); + + const producer2_buffer = dual_buffer.get_producer_buffer(); + try testing.expectEqual(@as(usize, event_count_max), producer2_buffer.len); + + const producer2_count = prng.range_inclusive(u32, 0, event_count_max); + maybe(producer2_count == 0); // Testing zeroed producers. + prng.fill(std.mem.sliceAsBytes(producer2_buffer[0..producer2_count])); + dual_buffer.producer_finish(producer2_count); + + // Consuming the first producer: + const consumer_buffer = dual_buffer.get_consumer_buffer(); + try testing.expectEqual(producer1_buffer.ptr, consumer_buffer.ptr); + try testing.expectEqual(@as(usize, producer1_count), consumer_buffer.len); + try testing.expectEqualSlices( + u8, + std.mem.sliceAsBytes(producer1_buffer[0..producer1_count]), + std.mem.sliceAsBytes(consumer_buffer), + ); + dual_buffer.consumer_finish(); + + // Consuming the second producer. + // It might not have produced anything, so the buffer cannot be consumed: + const consumer_begin_again = dual_buffer.consumer_begin(); + if (producer2_count == 0) { + try testing.expect(!consumer_begin_again); + try testing.expect(dual_buffer.all_free()); + continue; + } + + try testing.expect(consumer_begin_again); + try testing.expect(!dual_buffer.all_free()); + + const consumer2_buffer = dual_buffer.get_consumer_buffer(); + try testing.expectEqual(producer2_buffer.ptr, consumer2_buffer.ptr); + try testing.expectEqual(@as(usize, producer2_count), consumer2_buffer.len); + try testing.expectEqualSlices( + u8, + std.mem.sliceAsBytes(producer2_buffer[0..producer2_count]), + std.mem.sliceAsBytes(consumer2_buffer), + ); + + dual_buffer.consumer_finish(); + try testing.expect(dual_buffer.all_free()); + } +} + +test "amqp: ProgressTrackerMessage" { + const buffer = try testing.allocator.alloc(u8, amqp.frame_min_size); + defer testing.allocator.free(buffer); + + const values: []const u64 = &.{ + TimestampRange.timestamp_min, + 1745055501942402250, + TimestampRange.timestamp_max, + }; + for (values) |value| { + const message: ProgressTrackerMessage = .{ + .release = vsr.Release.minimum, + .timestamp = value, + }; + var encoder = amqp.Encoder.init(buffer); + encoder.write_table(message.header()); + + var decoder = amqp.Decoder.init(buffer[0..encoder.index]); + const decoded_message = try ProgressTrackerMessage.parse(try decoder.read_table()); + try testing.expectEqual(message.release.value, decoded_message.release.value); + try testing.expectEqual(message.timestamp, decoded_message.timestamp); + } +} + +test "amqp: JSON message" { + const Snap = stdx.Snap; + const snap = Snap.snap_fn("src"); + + const buffer = try testing.allocator.alloc(u8, Message.json_string_size_max); + defer testing.allocator.free(buffer); + + { + const message: Message = std.mem.zeroInit(Message, .{}); + const size = message.body().write(buffer); + try testing.expectEqual(@as(usize, 564), size); + + try snap(@src(), + \\{"timestamp":0,"type":"single_phase","ledger":0,"transfer":{"id":0,"amount":0,"pending_id":0,"user_data_128":0,"user_data_64":0,"user_data_32":0,"timeout":0,"code":0,"flags":0,"timestamp":0},"debit_account":{"id":0,"debits_pending":0,"debits_posted":0,"credits_pending":0,"credits_posted":0,"user_data_128":0,"user_data_64":0,"user_data_32":0,"code":0,"flags":0,"timestamp":0},"credit_account":{"id":0,"debits_pending":0,"debits_posted":0,"credits_pending":0,"credits_posted":0,"user_data_128":0,"user_data_64":0,"user_data_32":0,"code":0,"flags":0,"timestamp":0}} + ).diff(buffer[0..size]); + } + + { + const message = comptime Message.worse_case(Message); + const size = message.body().write(buffer); + try testing.expectEqual(@as(usize, 1425), size); + try testing.expectEqual(size, buffer.len); + + try snap(@src(), + \\{"timestamp":"18446744073709551615","type":"two_phase_pending","ledger":4294967295,"transfer":{"id":"340282366920938463463374607431768211455","amount":"340282366920938463463374607431768211455","pending_id":"340282366920938463463374607431768211455","user_data_128":"340282366920938463463374607431768211455","user_data_64":"18446744073709551615","user_data_32":4294967295,"timeout":4294967295,"code":65535,"flags":65535,"timestamp":"18446744073709551615"},"debit_account":{"id":"340282366920938463463374607431768211455","debits_pending":"340282366920938463463374607431768211455","debits_posted":"340282366920938463463374607431768211455","credits_pending":"340282366920938463463374607431768211455","credits_posted":"340282366920938463463374607431768211455","user_data_128":"340282366920938463463374607431768211455","user_data_64":"18446744073709551615","user_data_32":4294967295,"code":65535,"flags":65535,"timestamp":"18446744073709551615"},"credit_account":{"id":"340282366920938463463374607431768211455","debits_pending":"340282366920938463463374607431768211455","debits_posted":"340282366920938463463374607431768211455","credits_pending":"340282366920938463463374607431768211455","credits_posted":"340282366920938463463374607431768211455","user_data_128":"340282366920938463463374607431768211455","user_data_64":"18446744073709551615","user_data_32":4294967295,"code":65535,"flags":65535,"timestamp":"18446744073709551615"}} + ).diff(buffer); + } +} + +test "amqp: metrics" { + var time_sim = fixtures.init_time(.{}); + var summary: Metrics.TimingSummary = .{ + .timer = .init(time_sim.time()), + }; + + try testing.expectEqual(@as(u64, 0), summary.count); + try testing.expectEqual(@as(u64, 0), summary.event_count); + try testing.expectEqual(@as(u64, 0), summary.duration_sum.ns); + try testing.expect(summary.duration_max == null); + try testing.expect(summary.duration_min == null); + + summary.timing(10, .{ .ns = 50 }); + try testing.expectEqual(@as(u64, 1), summary.count); + try testing.expectEqual(@as(u64, 10), summary.event_count); + try testing.expectEqual(@as(u64, 50), summary.duration_sum.ns); + try testing.expect(summary.duration_min != null); + try testing.expect(summary.duration_max != null); + try testing.expectEqual(@as(u64, 50), summary.duration_min.?.ns); + try testing.expectEqual(@as(u64, 50), summary.duration_max.?.ns); + + summary.timing(5, .{ .ns = 100 }); + try testing.expectEqual(@as(u64, 2), summary.count); + try testing.expectEqual(@as(u64, 15), summary.event_count); + try testing.expectEqual(@as(u64, 150), summary.duration_sum.ns); + try testing.expect(summary.duration_min != null); + try testing.expect(summary.duration_max != null); + try testing.expectEqual(@as(u64, 50), summary.duration_min.?.ns); + try testing.expectEqual(@as(u64, 100), summary.duration_max.?.ns); + + summary.timing(0, .{ .ns = 10 }); + try testing.expectEqual(@as(u64, 3), summary.count); + try testing.expectEqual(@as(u64, 15), summary.event_count); + try testing.expectEqual(@as(u64, 160), summary.duration_sum.ns); + try testing.expect(summary.duration_min != null); + try testing.expect(summary.duration_max != null); + try testing.expectEqual(@as(u64, 10), summary.duration_min.?.ns); + try testing.expectEqual(@as(u64, 100), summary.duration_max.?.ns); +} diff --git a/ocam/src/clients/.gitignore b/ocam/src/clients/.gitignore new file mode 100644 index 00000000..5761abcf --- /dev/null +++ b/ocam/src/clients/.gitignore @@ -0,0 +1 @@ +*.o diff --git a/ocam/src/clients/README.md b/ocam/src/clients/README.md new file mode 100644 index 00000000..64887365 --- /dev/null +++ b/ocam/src/clients/README.md @@ -0,0 +1,58 @@ +# Clients + +## Documentation + +Documentation for clients (i.e. client `README.md`s) are generated +from [../scripts/client_readmes.zig](../scripts/client_readmes.zig). + +Each client implements the `Docs` struct from +[docs_types.zig](./docs_types.zig). + +The template for the README is in code in +[../scripts/client_readmes.zig](../scripts/client_readmes.zig). + +Existing `Docs` struct implementations are in: + +* [dotnet/docs.zig](./dotnet/docs.zig), which generates [dotnet/README.md](./dotnet/README.md) +* [go/docs.zig](./go/docs.zig), which generates [go/README.md](./go/README.md) +* [java/docs.zig](./java/docs.zig), which generates [java/README.md](./java/README.md) +* [node/docs.zig](./node/docs.zig), which generates [node/README.md](./node/README.md) +* [python/docs.zig](./python/docs.zig), which generates [python/README.md](./node/README.md) + +### Run + +Go to the repo root. + +If you don't already have the TigerBeetle version of `zig` run: + +```console +./zig/download.[sh|bat] +``` + +Use the `.sh` script if you're on macOS or Linux. +Use the `.bat` script if you're on Windows. + +To build and run the client docs generator: + +```console +./zig/zig build scripts -- ci +``` + +### Just one language + +To run the generator only for a certain language (defined by `.markdown_name`): + +```console +./zig/zig build scripts -- ci --language=go +``` + +Docs are only regenerated/modified when there would be a diff so the +mtime of each README changes only as needed. + +### Format files + +To format all Zig files (again, run from the repo root): + +```console +./zig/zig fmt . +``` diff --git a/ocam/src/clients/c/samples/main.c b/ocam/src/clients/c/samples/main.c new file mode 100644 index 00000000..a626528b --- /dev/null +++ b/ocam/src/clients/c/samples/main.c @@ -0,0 +1,401 @@ +#define IS_POSIX __unix__ || __APPLE__ || !_WIN32 + +#include +#include +#include +#include + +#if IS_POSIX +#include +#include +#elif _WIN32 +#include +#endif + +#include "../tb_client.h" + +// config.message_size_max - @sizeOf(vsr.Header): +#define MAX_MESSAGE_SIZE ((1024 * 1024) - 256) + +// Synchronization context between the callback and the main thread. +typedef struct completion_context { + uint8_t reply[MAX_MESSAGE_SIZE]; + int size; + bool completed; + + // In this example we synchronize using a condition variable: + #if IS_POSIX + pthread_mutex_t lock; + pthread_cond_t cv; + #elif _WIN32 + CRITICAL_SECTION lock; + CONDITION_VARIABLE cv; + #endif + +} completion_context_t; + +void completion_context_init(completion_context_t *ctx); +void completion_context_destroy(completion_context_t *ctx); + +// Sends and blocks the current thread until the reply arrives. +TB_CLIENT_STATUS send_request( + tb_client_t *client, + tb_packet_t *packet, + completion_context_t *ctx +); + +// For benchmarking purposes. +long long get_time_ms(void); + +// Completion function, called by tb_client no notify that a request as completed. +void on_completion( + uintptr_t context, + tb_packet_t *packet, + uint64_t timestamp, + const uint8_t *data, + uint32_t size +); + +int main(int argc, char **argv) { + printf("TigerBeetle C Sample\n"); + printf("Connecting...\n"); + tb_client_t client; + + const char *address = getenv("TB_ADDRESS"); + if (address == NULL) address = "3000"; + + uint8_t cluster_id[16]; + memset(&cluster_id, 0, 16); + + TB_INIT_STATUS init_status = tb_client_init( + &client, // Output client. + cluster_id, // Cluster ID. + address, // Cluster addresses. + strlen(address), // + (uintptr_t)NULL, // No need for a global context. + &on_completion // Completion callback. + ); + + if (init_status != TB_INIT_SUCCESS) { + printf("Failed to initialize tb_client\n"); + exit(-1); + } + + completion_context_t ctx; + completion_context_init(&ctx); + + tb_packet_t packet; + + //////////////////////////////////////////////////////////// + // Submitting a batch of accounts: // + //////////////////////////////////////////////////////////// + + #define ACCOUNTS_LEN 2 + #define ACCOUNTS_SIZE sizeof(tb_account_t) * ACCOUNTS_LEN + tb_account_t accounts[ACCOUNTS_LEN]; + + // Zeroing the memory, so we don't have to initialize every field. + memset(&accounts, 0, ACCOUNTS_SIZE); + + accounts[0].id = 1; + accounts[0].code = 2; + accounts[0].ledger = 777; + + accounts[1].id = 2; + accounts[1].code = 2; + accounts[1].ledger = 777; + + packet.operation = TB_OPERATION_CREATE_ACCOUNTS; // The operation to execute. + packet.data = accounts; // The data to be sent. + packet.data_size = ACCOUNTS_SIZE; // + packet.user_data = &ctx; // User-defined context. + packet.status = TB_PACKET_OK; // Set when the reply arrives. + + printf("Creating accounts...\n"); + + TB_CLIENT_STATUS client_status = send_request(&client, &packet, &ctx); + if (client_status != TB_CLIENT_OK) { + printf("Failed to send the request\n"); + exit(-1); + } + + if (packet.status != TB_PACKET_OK) { + // Checking if the request failed: + printf("Error calling create_accounts (ret=%d)\n", packet.status); + exit(-1); + } + + // Checking for errors creating the accounts: + tb_create_account_result_t *create_accounts_result = (tb_create_account_result_t*)ctx.reply; + int results_len = ctx.size / sizeof(tb_create_account_result_t); + printf("create_account results:\n"); + for(int i=0;i max_latency_ms) max_latency_ms = elapsed_ms; + total_time_ms += elapsed_ms; + + if (packet.status != TB_PACKET_OK) { + // Checking if the request failed: + printf("Error calling create_transfers (ret=%d)\n", packet.status); + exit(-1); + } + + // Checking for errors creating transfers: + tb_create_transfer_result_t *create_transfers_result = (tb_create_transfer_result_t*)ctx.reply; + int results_len = ctx.size / sizeof(tb_create_transfer_result_t); + printf("create_transfers results:\n"); + for(int i=0;iuser_data; + + // Signaling the main thread we received the reply: + pthread_mutex_lock(&ctx->lock); + + memcpy (ctx->reply, data, size); + ctx->size = size; + ctx->completed = true; + + pthread_cond_signal(&ctx->cv); + pthread_mutex_unlock(&ctx->lock); +} + +TB_CLIENT_STATUS send_request( + tb_client_t *client, + tb_packet_t *packet, + completion_context_t *ctx +) { + // Locks the mutex: + if (pthread_mutex_lock(&ctx->lock) != 0) { + printf("Failed to lock mutex\n"); + exit(-1); + } + + // Submits the request asynchronously: + ctx->completed = false; + TB_CLIENT_STATUS client_status = tb_client_submit(client, packet); + if (client_status == TB_CLIENT_OK) { + // Uses a condvar to sync this thread with the callback: + while (!ctx->completed) { + if (pthread_cond_wait(&ctx->cv, &ctx->lock) != 0) { + printf("Failed to wait condvar\n"); + exit(-1); + } + } + } + + if (pthread_mutex_unlock(&ctx->lock) != 0) { + printf("Failed to unlock mutex\n"); + exit(-1); + } + + return client_status; +} + +void completion_context_init(completion_context_t *ctx) { + if (pthread_mutex_init(&ctx->lock, NULL) != 0) { + printf("Failed to initialize mutex\n"); + exit(-1); + } + + if (pthread_cond_init(&ctx->cv, NULL) != 0) { + printf("Failed to initialize condition var\n"); + exit(-1); + } +} + +void completion_context_destroy(completion_context_t *ctx) { + pthread_cond_destroy(&ctx->cv); + pthread_mutex_destroy(&ctx->lock); +} + +long long get_time_ms(void) { + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) { + printf("Failed to call clock_gettime\n"); + exit(-1); + } + return (ts.tv_sec*1000)+(ts.tv_nsec/1000000); +} + +#elif _WIN32 + +void on_completion( + uintptr_t context, + tb_packet_t *packet, + uint64_t timestamp, + const uint8_t *data, + uint32_t size +) { + (void)timestamp; // Not used. + // The user_data gives context to a request: + completion_context_t *ctx = (completion_context_t*)packet->user_data; + + // Signaling the main thread we received the reply: + EnterCriticalSection(&ctx->lock); + + memcpy (ctx->reply, data, size); + ctx->size = size; + ctx->completed = true; + + WakeConditionVariable(&ctx->cv); + LeaveCriticalSection(&ctx->lock); +} + +TB_CLIENT_STATUS send_request( + tb_client_t *client, + tb_packet_t *packet, + completion_context_t *ctx +) { + // Locks the mutex: + EnterCriticalSection(&ctx->lock); + + // Submits the request asynchronously: + ctx->completed = false; + TB_CLIENT_STATUS client_status = tb_client_submit(client, packet); + if (client_status == TB_CLIENT_OK) { + // Uses a condvar to sync this thread with the callback: + while (!ctx->completed) { + SleepConditionVariableCS (&ctx->cv, &ctx->lock, INFINITE); + } + } + + LeaveCriticalSection(&ctx->lock); + return client_status; +} + +void completion_context_init(completion_context_t *ctx) { + InitializeCriticalSection(&ctx->lock); + InitializeConditionVariable(&ctx->cv); +} + +void completion_context_destroy(completion_context_t *ctx) { + DeleteCriticalSection(&ctx->lock); +} + +long long get_time_ms(void) { + return GetTickCount64(); +} + +#endif diff --git a/ocam/src/clients/c/tb_client.h b/ocam/src/clients/c/tb_client.h new file mode 100644 index 00000000..bb4554e6 --- /dev/null +++ b/ocam/src/clients/c/tb_client.h @@ -0,0 +1,388 @@ + ////////////////////////////////////////////////////////// + // This file was auto-generated by tb_client_header.zig // + // Do not manually modify. // + ////////////////////////////////////////////////////////// + +#ifndef TB_CLIENT_H +#define TB_CLIENT_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +typedef __uint128_t tb_uint128_t; + +typedef enum TB_ACCOUNT_FLAGS { + TB_ACCOUNT_LINKED = 1 << 0, + TB_ACCOUNT_DEBITS_MUST_NOT_EXCEED_CREDITS = 1 << 1, + TB_ACCOUNT_CREDITS_MUST_NOT_EXCEED_DEBITS = 1 << 2, + TB_ACCOUNT_HISTORY = 1 << 3, + TB_ACCOUNT_IMPORTED = 1 << 4, + TB_ACCOUNT_CLOSED = 1 << 5, +} TB_ACCOUNT_FLAGS; + +typedef struct tb_account_t { + tb_uint128_t id; + tb_uint128_t debits_pending; + tb_uint128_t debits_posted; + tb_uint128_t credits_pending; + tb_uint128_t credits_posted; + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint32_t reserved; + uint32_t ledger; + uint16_t code; + uint16_t flags; + uint64_t timestamp; +} tb_account_t; + +typedef enum TB_TRANSFER_FLAGS { + TB_TRANSFER_LINKED = 1 << 0, + TB_TRANSFER_PENDING = 1 << 1, + TB_TRANSFER_POST_PENDING_TRANSFER = 1 << 2, + TB_TRANSFER_VOID_PENDING_TRANSFER = 1 << 3, + TB_TRANSFER_BALANCING_DEBIT = 1 << 4, + TB_TRANSFER_BALANCING_CREDIT = 1 << 5, + TB_TRANSFER_CLOSING_DEBIT = 1 << 6, + TB_TRANSFER_CLOSING_CREDIT = 1 << 7, + TB_TRANSFER_IMPORTED = 1 << 8, +} TB_TRANSFER_FLAGS; + +typedef struct tb_transfer_t { + tb_uint128_t id; + tb_uint128_t debit_account_id; + tb_uint128_t credit_account_id; + tb_uint128_t amount; + tb_uint128_t pending_id; + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint32_t timeout; + uint32_t ledger; + uint16_t code; + uint16_t flags; + uint64_t timestamp; +} tb_transfer_t; + +typedef enum TB_CREATE_ACCOUNT_STATUS { + TB_CREATE_ACCOUNT_CREATED = 0xFFFFFFFF, + TB_CREATE_ACCOUNT_LINKED_EVENT_FAILED = 1, + TB_CREATE_ACCOUNT_LINKED_EVENT_CHAIN_OPEN = 2, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_EXPECTED = 22, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_NOT_EXPECTED = 23, + TB_CREATE_ACCOUNT_TIMESTAMP_MUST_BE_ZERO = 3, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE = 24, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE = 25, + TB_CREATE_ACCOUNT_RESERVED_FIELD = 4, + TB_CREATE_ACCOUNT_RESERVED_FLAG = 5, + TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_ZERO = 6, + TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 7, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_FLAGS = 15, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_128 = 16, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_64 = 17, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_32 = 18, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_LEDGER = 19, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_CODE = 20, + TB_CREATE_ACCOUNT_EXISTS = 21, + TB_CREATE_ACCOUNT_FLAGS_ARE_MUTUALLY_EXCLUSIVE = 8, + TB_CREATE_ACCOUNT_DEBITS_PENDING_MUST_BE_ZERO = 9, + TB_CREATE_ACCOUNT_DEBITS_POSTED_MUST_BE_ZERO = 10, + TB_CREATE_ACCOUNT_CREDITS_PENDING_MUST_BE_ZERO = 11, + TB_CREATE_ACCOUNT_CREDITS_POSTED_MUST_BE_ZERO = 12, + TB_CREATE_ACCOUNT_LEDGER_MUST_NOT_BE_ZERO = 13, + TB_CREATE_ACCOUNT_CODE_MUST_NOT_BE_ZERO = 14, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS = 26, +} TB_CREATE_ACCOUNT_STATUS; + +typedef enum TB_CREATE_TRANSFER_STATUS { + TB_CREATE_TRANSFER_CREATED = 0xFFFFFFFF, + TB_CREATE_TRANSFER_LINKED_EVENT_FAILED = 1, + TB_CREATE_TRANSFER_LINKED_EVENT_CHAIN_OPEN = 2, + TB_CREATE_TRANSFER_IMPORTED_EVENT_EXPECTED = 56, + TB_CREATE_TRANSFER_IMPORTED_EVENT_NOT_EXPECTED = 57, + TB_CREATE_TRANSFER_TIMESTAMP_MUST_BE_ZERO = 3, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE = 58, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE = 59, + TB_CREATE_TRANSFER_RESERVED_FLAG = 4, + TB_CREATE_TRANSFER_ID_MUST_NOT_BE_ZERO = 5, + TB_CREATE_TRANSFER_ID_MUST_NOT_BE_INT_MAX = 6, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_FLAGS = 36, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_PENDING_ID = 40, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_TIMEOUT = 44, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_DEBIT_ACCOUNT_ID = 37, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CREDIT_ACCOUNT_ID = 38, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_AMOUNT = 39, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_128 = 41, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_64 = 42, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_32 = 43, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_LEDGER = 67, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CODE = 45, + TB_CREATE_TRANSFER_EXISTS = 46, + TB_CREATE_TRANSFER_ID_ALREADY_FAILED = 68, + TB_CREATE_TRANSFER_FLAGS_ARE_MUTUALLY_EXCLUSIVE = 7, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_ZERO = 8, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 9, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_ZERO = 10, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 11, + TB_CREATE_TRANSFER_ACCOUNTS_MUST_BE_DIFFERENT = 12, + TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_ZERO = 13, + TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_ZERO = 14, + TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_INT_MAX = 15, + TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_DIFFERENT = 16, + TB_CREATE_TRANSFER_TIMEOUT_RESERVED_FOR_PENDING_TRANSFER = 17, + TB_CREATE_TRANSFER_CLOSING_TRANSFER_MUST_BE_PENDING = 64, + TB_CREATE_TRANSFER_LEDGER_MUST_NOT_BE_ZERO = 19, + TB_CREATE_TRANSFER_CODE_MUST_NOT_BE_ZERO = 20, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_NOT_FOUND = 21, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_NOT_FOUND = 22, + TB_CREATE_TRANSFER_ACCOUNTS_MUST_HAVE_THE_SAME_LEDGER = 23, + TB_CREATE_TRANSFER_TRANSFER_MUST_HAVE_THE_SAME_LEDGER_AS_ACCOUNTS = 24, + TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_FOUND = 25, + TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_PENDING = 26, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_DEBIT_ACCOUNT_ID = 27, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CREDIT_ACCOUNT_ID = 28, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_LEDGER = 29, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CODE = 30, + TB_CREATE_TRANSFER_EXCEEDS_PENDING_TRANSFER_AMOUNT = 31, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_AMOUNT = 32, + TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_POSTED = 33, + TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_VOIDED = 34, + TB_CREATE_TRANSFER_PENDING_TRANSFER_EXPIRED = 35, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS = 60, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_DEBIT_ACCOUNT = 61, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_CREDIT_ACCOUNT = 62, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMEOUT_MUST_BE_ZERO = 63, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ALREADY_CLOSED = 65, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ALREADY_CLOSED = 66, + TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_PENDING = 47, + TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_PENDING = 48, + TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_POSTED = 49, + TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_POSTED = 50, + TB_CREATE_TRANSFER_OVERFLOWS_DEBITS = 51, + TB_CREATE_TRANSFER_OVERFLOWS_CREDITS = 52, + TB_CREATE_TRANSFER_OVERFLOWS_TIMEOUT = 53, + TB_CREATE_TRANSFER_EXCEEDS_CREDITS = 54, + TB_CREATE_TRANSFER_EXCEEDS_DEBITS = 55, +} TB_CREATE_TRANSFER_STATUS; + +typedef struct tb_create_account_result_t { + uint64_t timestamp; + uint32_t status; + uint32_t reserved; +} tb_create_account_result_t; + +typedef struct tb_create_transfer_result_t { + uint64_t timestamp; + uint32_t status; + uint32_t reserved; +} tb_create_transfer_result_t; + +typedef struct tb_account_filter_t { + tb_uint128_t account_id; + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint16_t code; + uint8_t reserved[58]; + uint64_t timestamp_min; + uint64_t timestamp_max; + uint32_t limit; + uint32_t flags; +} tb_account_filter_t; + +typedef enum TB_ACCOUNT_FILTER_FLAGS { + TB_ACCOUNT_FILTER_DEBITS = 1 << 0, + TB_ACCOUNT_FILTER_CREDITS = 1 << 1, + TB_ACCOUNT_FILTER_REVERSED = 1 << 2, +} TB_ACCOUNT_FILTER_FLAGS; + +typedef struct tb_account_balance_t { + tb_uint128_t debits_pending; + tb_uint128_t debits_posted; + tb_uint128_t credits_pending; + tb_uint128_t credits_posted; + uint64_t timestamp; + uint8_t reserved[56]; +} tb_account_balance_t; + +typedef struct tb_query_filter_t { + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint32_t ledger; + uint16_t code; + uint8_t reserved[6]; + uint64_t timestamp_min; + uint64_t timestamp_max; + uint32_t limit; + uint32_t flags; +} tb_query_filter_t; + +typedef enum TB_QUERY_FILTER_FLAGS { + TB_QUERY_FILTER_REVERSED = 1 << 0, +} TB_QUERY_FILTER_FLAGS; + +// Opaque struct serving as a handle for the client instance. +// This struct must be "pinned" (not copyable or movable), as its address must remain stable +// throughout the lifetime of the client instance. +typedef struct tb_client_t { + uint64_t opaque[4]; +} tb_client_t; + +// Struct containing the state of a request submitted through the client. +// This struct must be "pinned" (not copyable or movable), as its address must remain stable +// throughout the lifetime of the request. +typedef struct tb_packet_t { + void* user_data; + void* data; + uint32_t data_size; + uint16_t user_tag; + uint8_t operation; + uint8_t status; + uint8_t opaque[64]; +} tb_packet_t; + +typedef enum TB_OPERATION { + TB_OPERATION_PULSE = 128, + TB_OPERATION_GET_CHANGE_EVENTS = 137, + TB_OPERATION_LOOKUP_ACCOUNTS = 140, + TB_OPERATION_LOOKUP_TRANSFERS = 141, + TB_OPERATION_GET_ACCOUNT_TRANSFERS = 142, + TB_OPERATION_GET_ACCOUNT_BALANCES = 143, + TB_OPERATION_QUERY_ACCOUNTS = 144, + TB_OPERATION_QUERY_TRANSFERS = 145, + TB_OPERATION_CREATE_ACCOUNTS = 146, + TB_OPERATION_CREATE_TRANSFERS = 147, +} TB_OPERATION; + +typedef enum TB_PACKET_STATUS { + TB_PACKET_OK = 0, + TB_PACKET_TOO_MUCH_DATA = 1, + TB_PACKET_CLIENT_EVICTED = 2, + TB_PACKET_CLIENT_RELEASE_TOO_LOW = 3, + TB_PACKET_CLIENT_RELEASE_TOO_HIGH = 4, + TB_PACKET_CLIENT_SHUTDOWN = 5, + TB_PACKET_INVALID_OPERATION = 6, + TB_PACKET_INVALID_DATA_SIZE = 7, +} TB_PACKET_STATUS; + +typedef enum TB_INIT_STATUS { + TB_INIT_SUCCESS = 0, + TB_INIT_UNEXPECTED = 1, + TB_INIT_OUT_OF_MEMORY = 2, + TB_INIT_ADDRESS_INVALID = 3, + TB_INIT_ADDRESS_LIMIT_EXCEEDED = 4, + TB_INIT_SYSTEM_RESOURCES = 5, + TB_INIT_NETWORK_SUBSYSTEM = 6, +} TB_INIT_STATUS; + +typedef enum TB_CLIENT_STATUS { + TB_CLIENT_OK = 0, + TB_CLIENT_INVALID = 1, +} TB_CLIENT_STATUS; + +typedef enum TB_REGISTER_LOG_CALLBACK_STATUS { + TB_REGISTER_LOG_CALLBACK_SUCCESS = 0, + TB_REGISTER_LOG_CALLBACK_ALREADY_REGISTERED = 1, + TB_REGISTER_LOG_CALLBACK_NOT_REGISTERED = 2, +} TB_REGISTER_LOG_CALLBACK_STATUS; + +typedef enum TB_LOG_LEVEL { + TB_LOG_ERR = 0, + TB_LOG_WARN = 1, + TB_LOG_INFO = 2, + TB_LOG_DEBUG = 3, +} TB_LOG_LEVEL; + +typedef struct tb_init_parameters_t { + tb_uint128_t cluster_id; + tb_uint128_t client_id; + uint8_t* addresses_ptr; + uint64_t addresses_len; +} tb_init_parameters_t; + +// Per-client callback invoked every time a `tb_client_submit` completes or is canceled. +// Use `packet->userdata` to identify the specific submission. +// `result` is null iff `packet->status != TB_PACKET_OK` +// `result` is only valid for the duration of the callback itself. +typedef void (*tb_completion_t)( + uintptr_t userdata, + tb_packet_t* packet, + uint64_t timestamp, + const uint8_t *result, // nullable + uint32_t result_size +); + +// Initialize a new TigerBeetle client which connects to the addresses provided and +// completes submitted packets by invoking the callback with the given context. +TB_INIT_STATUS tb_client_init( + tb_client_t *client_out, + // 128-bit unsigned integer represented as a 16-byte little-endian array. + const uint8_t cluster_id[16], + const char *address_ptr, + uint32_t address_len, + uintptr_t completion_ctx, + tb_completion_t completion_callback +); + +// Initialize a new TigerBeetle client that echoes back any submitted data. +TB_INIT_STATUS tb_client_init_echo( + tb_client_t *client_out, + // 128-bit unsigned integer represented as a 16-byte little-endian array. + const uint8_t cluster_id[16], + const char *address_ptr, + uint32_t address_len, + uintptr_t completion_ctx, + tb_completion_t completion_callback +); + +// Retrieve the parameters initially passed to `tb_client_init` or `tb_client_init_echo`. +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_init_parameters( + tb_client_t* client, + tb_init_parameters_t* init_parameters_out +); + +// Retrieve the callback context initially passed to `tb_client_init` or `tb_client_init_echo`. +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_completion_context( + tb_client_t* client, + uintptr_t* completion_ctx_out +); + +// Submit a packet with its `operation`, `data`, and `data_size` fields set. +// Once completed, `completion_callback` will be invoked with `completion_ctx` +// and the given packet on the `tb_client` thread (separate from the caller's thread). +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_submit( + tb_client_t *client, + tb_packet_t *packet +); + +// Closes the client, causing any previously submitted packets to be completed with +// `TB_PACKET_CLIENT_SHUTDOWN` before freeing any allocated client resources from init. +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_deinit( + tb_client_t *client +); + +// Registers or unregisters the application log callback. +TB_REGISTER_LOG_CALLBACK_STATUS tb_client_register_log_callback( + void (*callback)(TB_LOG_LEVEL, const uint8_t*, uint32_t), + bool debug +); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // TB_CLIENT_H diff --git a/ocam/src/clients/c/tb_client.zig b/ocam/src/clients/c/tb_client.zig new file mode 100644 index 00000000..6445f022 --- /dev/null +++ b/ocam/src/clients/c/tb_client.zig @@ -0,0 +1,34 @@ +const std = @import("std"); + +pub const vsr = @import("../../vsr.zig"); +pub const exports = @import("tb_client_exports.zig"); + +const MessageBus = @import("../../message_bus.zig").MessageBusType(@import("../../io.zig").IO); + +pub const InitError = @import("tb_client/context.zig").InitError; +pub const InitParameters = @import("tb_client/context.zig").InitParameters; +pub const ClientInterface = @import("tb_client/context.zig").ClientInterface; +pub const CompletionCallback = @import("tb_client/context.zig").CompletionCallback; +pub const Packet = @import("tb_client/packet.zig").Packet.Extern; +pub const PacketStatus = @import("tb_client/packet.zig").Packet.Status; +pub const Operation = vsr.tigerbeetle.Operation; + +const ContextType = @import("tb_client/context.zig").ContextType; +const DefaultContext = blk: { + const ClientType = @import("../../vsr/client.zig").ClientType; + const Client = ClientType(Operation, MessageBus); + break :blk ContextType(Client); +}; + +const TestingContext = blk: { + const EchoClientType = @import("tb_client/echo_client.zig").EchoClientType; + const EchoClient = EchoClientType(MessageBus); + break :blk ContextType(EchoClient); +}; + +pub const init = DefaultContext.init; +pub const init_echo = TestingContext.init; + +test { + std.testing.refAllDecls(DefaultContext); +} diff --git a/ocam/src/clients/c/tb_client/context.zig b/ocam/src/clients/c/tb_client/context.zig new file mode 100644 index 00000000..edbcd682 --- /dev/null +++ b/ocam/src/clients/c/tb_client/context.zig @@ -0,0 +1,1170 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; + +const log = std.log.scoped(.tb_client_context); + +const vsr = @import("../tb_client.zig").vsr; + +const constants = vsr.constants; +const stdx = vsr.stdx; +const maybe = stdx.maybe; +const Header = vsr.Header; + +const MultiBatchDecoder = vsr.multi_batch.MultiBatchDecoder; +const MultiBatchEncoder = vsr.multi_batch.MultiBatchEncoder; + +const IO = vsr.io.IO; +const TimeOS = vsr.time.TimeOS; +const message_pool = vsr.message_pool; + +const MessagePool = message_pool.MessagePool; +const Message = MessagePool.Message; +const Packet = @import("packet.zig").Packet; +const Signal = @import("signal.zig").Signal; + +const KiB = stdx.KiB; + +const io_thread_stack_size = 512 * KiB; + +pub const InitParameters = extern struct { + cluster_id: u128, + client_id: u128, + addresses_ptr: [*]const u8, + addresses_len: u64, +}; + +/// Thread-safe client interface allocated by the user. +/// Contains the `VTable` with function pointers to the StateMachine-specific implementation +/// and the synchronization status. +/// Safe to call from multiple threads, even after `deinit` is called. +pub const ClientInterface = extern struct { + pub const Error = error{ClientInvalid}; + pub const VTable = struct { + submit_fn: *const fn (*anyopaque, *Packet.Extern) void, + completion_context_fn: *const fn (*anyopaque) usize, + deinit_fn: *const fn (*anyopaque) void, + init_parameters_fn: *const fn (*anyopaque, *InitParameters) void, + }; + + /// Magic number used as a tag, preventing the use of uninitialized pointers. + const beetle: u64 = 0xBEE71E; + + // Since the client interface is an intrusive struct allocated by the user, + // it is exported as an opaque `[_]u64` array. + // An `extern union` is used to ensure a platform-independent size for pointer fields, + // avoiding the need for different versions of `tb_client.h` on 32-bit targets. + + context: extern union { + ptr: ?*anyopaque, + int_ptr: u64, + }, + vtable: extern union { + ptr: *const VTable, + int_ptr: u64, + }, + locker: Locker, + reserved: u32, + magic_number: u64, + + pub fn init(interface: *ClientInterface, context: *anyopaque, vtable: *const VTable) void { + interface.* = .{ + .context = .{ .ptr = context }, + .vtable = .{ .ptr = vtable }, + .locker = .{}, + .reserved = 0, + .magic_number = 0, + }; + } + + pub fn submit(interface: *ClientInterface, packet: *Packet.Extern) Error!void { + if (interface.magic_number != beetle) return Error.ClientInvalid; + assert(interface.reserved == 0); + + interface.locker.lock(); + defer interface.locker.unlock(); + + const context = interface.context.ptr orelse return Error.ClientInvalid; + interface.vtable.ptr.submit_fn(context, packet); + } + + pub fn completion_context(interface: *ClientInterface) Error!usize { + if (interface.magic_number != beetle) return Error.ClientInvalid; + assert(interface.reserved == 0); + + interface.locker.lock(); + defer interface.locker.unlock(); + + const context = interface.context.ptr orelse return Error.ClientInvalid; + return interface.vtable.ptr.completion_context_fn(context); + } + + pub fn deinit(interface: *ClientInterface) Error!void { + if (interface.magic_number != beetle) return Error.ClientInvalid; + assert(interface.reserved == 0); + + const context: *anyopaque = context: { + interface.locker.lock(); + defer interface.locker.unlock(); + + const context = interface.context.ptr orelse return Error.ClientInvalid; + interface.context = .{ .ptr = null }; + + break :context context; + }; + interface.vtable.ptr.deinit_fn(context); + } + + pub fn init_parameters( + interface: *ClientInterface, + out_parameters: *InitParameters, + ) Error!void { + if (interface.magic_number != beetle) return Error.ClientInvalid; + assert(interface.reserved == 0); + + interface.locker.lock(); + defer interface.locker.unlock(); + + const context = interface.context.ptr orelse return Error.ClientInvalid; + return interface.vtable.ptr.init_parameters_fn(context, out_parameters); + } + + comptime { + assert(@sizeOf(ClientInterface) == 32); + assert(@alignOf(ClientInterface) == 8); + } +}; + +/// The function pointer called by the IO thread when a request is completed or fails. +/// The memory referenced by `result` is only valid for the duration of this callback. +/// `result_ptr` is `null` for unsuccessful requests. See `packet.status` for more details. +pub const CompletionCallback = *const fn ( + context: usize, + packet: *Packet.Extern, + timestamp: u64, + result: ?[*]const u8, + result_size: u32, +) callconv(.c) void; + +pub const InitError = std.mem.Allocator.Error || error{ + Unexpected, + AddressInvalid, + AddressLimitExceeded, + SystemResources, + NetworkSubsystemFailed, +}; + +/// Implements a `ClientInterface` with specialized `vsr.Client` and `StateMachine` types. +pub fn ContextType( + comptime Client: type, +) type { + return struct { + const Context = @This(); + const GPA = std.heap.GeneralPurposeAllocator(.{ + .thread_safe = true, + }); + + const Operation = Client.Operation; + const allowed_operations = [_]Operation{ + .create_accounts, + .create_transfers, + .lookup_accounts, + .lookup_transfers, + .get_account_transfers, + .get_account_balances, + .query_accounts, + .query_transfers, + .get_change_events, + }; + + const UserData = extern struct { + self: *Context, + packet: *Packet, + + comptime { + assert(@sizeOf(UserData) == @sizeOf(u128)); + } + }; + + const PacketError = error{ + TooMuchData, + ClientShutdown, + ClientEvicted, + ClientReleaseTooLow, + ClientReleaseTooHigh, + InvalidOperation, + InvalidDataSize, + }; + + /// Thread-local variable to track whether the current thread is + /// the IO thread or a user thread. + /// Used to assert that certain functions are only called from the + /// correct thread. + threadlocal var thread_caller: union(enum) { + user, + io: std.Thread.Id, + } = .user; + + gpa: GPA, + time_os: TimeOS = .{}, + client_id: u128, + cluster_id: u128, + addresses_owned: []const u8, + + addresses: stdx.BoundedArrayType(stdx.SocketAddress, constants.replicas_max) = .{}, + io: IO, + message_pool: MessagePool, + client: Client, + batch_size_limit: ?u32 = null, + + completion_callback: CompletionCallback, + completion_context: usize, + + interface: *ClientInterface, + submitted: Packet.Queue, + pending: Packet.Queue, + + signal: Signal, + eviction_reason: ?vsr.Header.Eviction.Reason = null, + thread: std.Thread, + + request_timer: stdx.Instant, + request_latency: ?stdx.Duration = null, + + pub fn init( + root_allocator: std.mem.Allocator, + client_out: *ClientInterface, + cluster_id: u128, + addresses: []const u8, + completion_ctx: usize, + completion_callback: CompletionCallback, + ) InitError!void { + var context: *Context = context: { + // Wrap the root allocator - usually heap.c_allocator when built as a library - in + // a GPA to keep maximum compatibility while gaining the extra safety. As a library, + // libtbclient is running inside another process's address space. + var gpa = GPA{ + .backing_allocator = root_allocator, + }; + errdefer assert(gpa.deinit() == .ok); + + const context = try gpa.allocator().create(Context); + + // Moving the GPA is safe, since we don't have any live reference to `allocator`. + context.gpa = gpa; + + break :context context; + }; + + errdefer { + var gpa: GPA = context.gpa; + gpa.allocator().destroy(context); + assert(gpa.deinit() == .ok); + } + + const allocator = context.gpa.allocator(); + + context.* = .{ + .gpa = context.gpa, + + .client_id = stdx.unique_u128(), + .cluster_id = cluster_id, + + .completion_callback = completion_callback, + .completion_context = completion_ctx, + + .interface = client_out, + .submitted = Packet.Queue.init(.{ + .name = null, + .verify_push = builtin.is_test, + }), + .pending = Packet.Queue.init(.{ + .name = null, + .verify_push = builtin.is_test, + }), + + .addresses_owned = undefined, + .io = undefined, + .message_pool = undefined, + .client = undefined, + .signal = undefined, + .thread = undefined, + .request_timer = undefined, + }; + context.addresses_owned = try allocator.dupe(u8, addresses); + errdefer allocator.free(context.addresses_owned); + + const time = context.time_os.time(); + + log.debug("{}: init: parsing vsr addresses: {s}", .{ context.client_id, addresses }); + context.addresses = .{}; + const addresses_parsed = vsr.parse_addresses( + addresses, + context.addresses.unused_capacity_slice(), + ) catch |err| return switch (err) { + error.AddressLimitExceeded => error.AddressLimitExceeded, + error.AddressHasMoreThanOneColon, + error.AddressHasTrailingComma, + error.AddressInvalid, + error.PortInvalid, + => error.AddressInvalid, + }; + assert(addresses_parsed.len > 0); + assert(addresses_parsed.len <= constants.replicas_max); + context.addresses.resize(addresses_parsed.len) catch unreachable; + + log.debug("{}: init: initializing IO", .{context.client_id}); + context.io = IO.init(32, 0) catch |err| { + log.err("{}: failed to initialize IO: {s}", .{ + context.client_id, + @errorName(err), + }); + return switch (err) { + error.ProcessFdQuotaExceeded => error.SystemResources, + error.Unexpected => error.Unexpected, + else => unreachable, + }; + }; + errdefer context.io.deinit(); + + log.debug("{}: init: initializing MessagePool", .{context.client_id}); + context.message_pool = try MessagePool.init(allocator, .client); + errdefer context.message_pool.deinit(allocator); + + log.debug("{}: init: initializing client (cluster_id={x:0>32}, addresses={any})", .{ + context.client_id, + cluster_id, + context.addresses.const_slice(), + }); + context.client = Client.init( + allocator, + time, + &context.message_pool, + .{ + .id = context.client_id, + .cluster = cluster_id, + .replica_count = context.addresses.count_as(u8), + .aof_recovery = false, + .message_bus_options = .{ + .configuration = context.addresses.const_slice(), + .io = &context.io, + .trace = null, + .time = time, + }, + .eviction_callback = client_eviction_callback, + }, + ) catch |err| { + log.err("{}: failed to initialize Client: {s}", .{ + context.client_id, + @errorName(err), + }); + return switch (err) { + error.OutOfMemory => error.OutOfMemory, + }; + }; + errdefer context.client.deinit(allocator); + + ClientInterface.init(client_out, context, comptime &.{ + .submit_fn = &vtable_submit_fn, + .completion_context_fn = &vtable_completion_context_fn, + .deinit_fn = &vtable_deinit_fn, + .init_parameters_fn = &vtable_init_parameters_fn, + }); + + log.debug("{}: init: initializing signal", .{context.client_id}); + try context.signal.init(&context.io, Context.signal_notify_callback); + errdefer context.signal.deinit(); + + context.request_timer = context.client.time.monotonic(); + context.client.register(client_register_callback, @intFromPtr(context)); + + log.debug("{}: init: spawning thread", .{context.client_id}); + context.thread = std.Thread.spawn( + .{ .stack_size = io_thread_stack_size }, + Context.io_thread, + .{context}, + ) catch |err| { + log.err("{}: failed to spawn thread: {s}", .{ + context.client_id, + @errorName(err), + }); + return switch (err) { + error.Unexpected => error.Unexpected, + error.OutOfMemory => error.OutOfMemory, + error.SystemResources, + error.ThreadQuotaExceeded, + error.LockedMemoryLimitExceeded, + => error.SystemResources, + }; + }; + + // Setting `magic_number` tags the interface as initialized. + // Writing it at the end so that if `init` fails part-way through and the + // user doesn’t handle the error before using it, we'll still be able to validate. + client_out.magic_number = ClientInterface.beetle; + } + + fn deinit(self: *Context) void { + assert(thread_caller == .io); + assert(self.signal.status() == .shutdown_completed); + assert(self.submitted.pop() == null); + assert(self.pending.pop() == null); + maybe(self.eviction_reason != null); + + assert(self.client.shutdown_complete()); + self.signal.deinit(); + self.client.deinit(self.gpa.allocator()); + self.message_pool.deinit(self.gpa.allocator()); + self.io.deinit(); + + self.gpa.allocator().free(self.addresses_owned); + + // NB: Copy the allocator back out before trying to destroy `self` with it! + var gpa: GPA = self.gpa; + gpa.allocator().destroy(self); + assert(gpa.deinit() == .ok); + } + + fn tick(self: *Context) void { + if (self.eviction_reason == null) { + self.client.tick(); + } + } + + fn io_thread(self: *Context) void { + // Initializing the flag as the IO thread. + assert(thread_caller == .user); + thread_caller = .{ .io = std.Thread.getCurrentId() }; + defer thread_caller = .user; + + while (self.signal.status() != .shutdown_completed) { + self.tick(); + self.io.run_for_ns(constants.tick_ms * std.time.ns_per_ms) catch |err| { + log.err("{}: IO.run() failed: {s}", .{ + self.client_id, + @errorName(err), + }); + @panic("IO.run() failed"); + }; + } + + self.cancel_request_inflight(); + + while (self.pending.pop()) |packet| { + packet.assert_phase(.pending); + self.packet_cancel(packet); + } + + // The submitted queue is no longer accessible to user threads, + // so synchronization is not required here. + while (self.submitted.pop()) |packet| { + packet.assert_phase(.submitted); + self.packet_cancel(packet); + } + + // Close every connection and drain outstanding IO before tearing the + // client down. + self.client.shutdown(); + while (!self.client.shutdown_complete()) { + self.io.run_for_ns(constants.tick_ms * std.time.ns_per_ms) catch |err| { + log.err("{}: IO.run() failed during shutdown: {s}", .{ + self.client_id, + @errorName(err), + }); + @panic("IO.run() failed"); + }; + } + + self.deinit(); + } + + /// Cancel the current inflight request (and the entire batched linked list of packets), + /// as it won't be replied anymore. + fn cancel_request_inflight(self: *Context) void { + assert(thread_caller == .io); + if (self.client.request_inflight) |*inflight| { + if (inflight.message.header.operation != .register) { + const packet: *Packet = @as(UserData, @bitCast(inflight.user_data)).packet; + packet.assert_phase(.sent); + self.packet_cancel(packet); + } + } + } + + /// Calls the user callback when a packet (the entire batched linked list of packets) + /// is canceled due to the client being either evicted or shutdown. + fn packet_cancel(self: *Context, packet_list: *Packet) void { + assert(thread_caller == .io); + assert(packet_list.link.next == null); + assert(packet_list.phase != .complete); + packet_list.assert_phase(packet_list.phase); + + const result = if (self.eviction_reason) |reason| switch (reason) { + .reserved => unreachable, + .client_release_too_low => error.ClientReleaseTooLow, + .client_release_too_high => error.ClientReleaseTooHigh, + else => error.ClientEvicted, + } else result: { + assert(self.signal.status() != .running); + break :result error.ClientShutdown; + }; + + var it: ?*Packet = packet_list; + while (it) |batched| { + if (batched != packet_list) batched.assert_phase(.batched); + it = batched.multi_batch_next; + self.notify_completion(batched, result); + } + } + + fn packet_enqueue(self: *Context, packet: *Packet) void { + assert(thread_caller == .io); + assert(self.batch_size_limit != null); + packet.assert_phase(.submitted); + + const operation: Operation = operation_from_int(packet.operation) orelse { + return self.notify_completion(packet, error.InvalidOperation); + }; + + // Make sure the packet.data wouldn't overflow a request, + // and that the corresponding results won't overflow a reply. + const batch: struct { + event_size: u32, + result_size: u32, + event_count: u32, + result_count_expected: u32, + } = batch: { + const event_size: u32 = operation.event_size(); + assert(event_size > 0); + + const result_size: u32 = operation.result_size(); + assert(result_size > 0); + + const slice: []const u8 = packet.slice(); + assert(slice.len == packet.data_size); + maybe(slice.len == 0); + if (slice.len % event_size != 0) { + return self.notify_completion(packet, error.InvalidDataSize); + } + + const event_count: u32 = @intCast(@divExact(slice.len, event_size)); + const event_max: u32 = operation.event_max(self.batch_size_limit.?); + if (event_count > event_max) { + return self.notify_completion(packet, error.TooMuchData); + } + const result_max: u32 = operation.result_max(self.batch_size_limit.?); + const result_count_expected: u32 = operation.result_count_expected(slice); + if (result_count_expected > result_max) { + return self.notify_completion(packet, error.TooMuchData); + } + + break :batch .{ + .event_size = event_size, + .result_size = result_size, + .event_count = @intCast(@divExact(slice.len, event_size)), + .result_count_expected = result_count_expected, + }; + }; + assert(packet.data_size == batch.event_count * batch.event_size); + maybe(batch.event_count == 0); + maybe(batch.result_count_expected == 0); + + // Avoid making a packet inflight by cancelling it if the client was shutdown. + if (self.signal.status() != .running) { + maybe(self.eviction_reason != null); + self.packet_cancel(packet); + return; + } + + // Nothing inflight means the packet should be submitted right now. + if (self.client.request_inflight == null) { + assert(self.pending.count() == 0); + packet.phase = .pending; + packet.multi_batch_time_monotonic = self.client.time.monotonic().ns; + packet.multi_batch_count = 1; + packet.multi_batch_event_count = @intCast(batch.event_count); + packet.multi_batch_result_count_expected = @intCast(batch.result_count_expected); + self.packet_send(packet); + return; + } + + var it = self.pending.iterate(); + while (it.next()) |root| { + root.assert_phase(.pending); + + if (root.operation != packet.operation) continue; + + // Check if the message has enough space for the submitted number of events: + const request_size: u32 = size: { + const trailer_size = vsr.multi_batch.trailer_total_size(.{ + .element_size = batch.event_size, + .batch_count = root.multi_batch_count + 1, + }); + const event_count: u32 = batch.event_count + + root.multi_batch_event_count; + break :size (event_count * batch.event_size) + trailer_size; + }; + if (request_size > self.batch_size_limit.?) continue; + + // Check if the reply has enough space for the maximum expected number of results: + const reply_size_expected: u32 = size: { + const trailer_size = vsr.multi_batch.trailer_total_size(.{ + .element_size = batch.result_size, + .batch_count = root.multi_batch_count + 1, + }); + const event_count: u32 = batch.result_count_expected + + root.multi_batch_result_count_expected; + break :size (event_count * batch.result_size) + trailer_size; + }; + if (reply_size_expected > constants.message_body_size_max) continue; + + packet.phase = .batched; + if (root.multi_batch_next == null) { + assert(root.multi_batch_tail == null); + assert(root.multi_batch_count == 1); + root.multi_batch_next = packet; + root.multi_batch_tail = packet; + } else { + assert(root.multi_batch_tail != null); + assert(root.multi_batch_count > 1); + root.multi_batch_tail.?.multi_batch_next = packet; + root.multi_batch_tail = packet; + } + root.multi_batch_count += 1; + root.multi_batch_event_count += @intCast(batch.event_count); + root.multi_batch_result_count_expected += @intCast(batch.result_count_expected); + return; + } + + // Couldn't batch with existing packet so push to pending directly. + packet.phase = .pending; + packet.multi_batch_time_monotonic = self.client.time.monotonic().ns; + packet.multi_batch_count = 1; + packet.multi_batch_event_count = @intCast(batch.event_count); + packet.multi_batch_result_count_expected = @intCast(batch.result_count_expected); + self.pending.push(packet); + } + + /// Sends the packet (the entire batched linked list of packets) through the vsr client. + /// Always called by the io thread. + fn packet_send(self: *Context, packet_list: *Packet) void { + assert(thread_caller == .io); + assert(self.batch_size_limit != null); + assert(self.client.request_inflight == null); + packet_list.assert_phase(.pending); + + // On shutdown, cancel this packet as well as any others batched onto it. + if (self.signal.status() != .running) { + return self.packet_cancel(packet_list); + } + assert(self.eviction_reason == null); + + const message = self.client.get_message().build(.request); + defer { + self.client.release_message(message.base()); + packet_list.assert_phase(.sent); + } + + const operation: Operation = operation_from_int(packet_list.operation).?; + const event_size: u32 = operation.event_size(); + const request_size: u32 = request_size: { + if (!operation.is_multi_batch()) { + assert(packet_list.multi_batch_next == null); + const source: []const u8 = packet_list.slice(); + stdx.copy_disjoint( + .inexact, + u8, + message.buffer[@sizeOf(Header)..], + source, + ); + break :request_size @intCast(source.len); + } + assert(operation.is_multi_batch()); + + var message_encoder = MultiBatchEncoder.init(message.buffer[@sizeOf(Header)..], .{ + .element_size = event_size, + }); + + var it: ?*Packet = packet_list; + var multi_batch_events_count: u16 = 0; + while (it) |batched| { + if (batched != packet_list) batched.assert_phase(.batched); + it = batched.multi_batch_next; + + const source: []const u8 = batched.slice(); + const target = message_encoder.writable().?; + assert(target.len >= source.len); + stdx.copy_disjoint( + .exact, + u8, + target[0..source.len], + source, + ); + message_encoder.add(@intCast(source.len)); + + const events_count: u16 = @intCast(@divExact(source.len, event_size)); + multi_batch_events_count += events_count; + } + assert(multi_batch_events_count == packet_list.multi_batch_event_count); + assert(message_encoder.batch_count == packet_list.multi_batch_count); + + // Check if the reply has enough space for the maximum expected number of results. + const result_size: u32 = operation.result_size(); + const trailer_size = vsr.multi_batch.trailer_total_size(.{ + .element_size = result_size, + .batch_count = packet_list.multi_batch_count, + }); + const reply_size_max: u32 = (result_size * + packet_list.multi_batch_result_count_expected) + trailer_size; + assert(reply_size_max % result_size == 0); + assert(reply_size_max <= constants.message_body_size_max); + + break :request_size message_encoder.finish(); + }; + assert(request_size % event_size == 0); + assert(request_size <= self.batch_size_limit.?); + + // Sending the request. + const previous_request_latency = + self.request_latency orelse stdx.Duration{ .ns = 0 }; + message.header.* = .{ + .release = self.client.release, + .client = self.client.id, + .request = 0, // Set by client.raw_request. + .cluster = self.client.cluster, + .command = .request, + .operation = operation.to_vsr(), + .size = @sizeOf(vsr.Header) + request_size, + .previous_request_latency = @intCast(@min( + previous_request_latency.to_us(), + std.math.maxInt(u32), + )), + }; + + self.request_timer = .{ .ns = packet_list.multi_batch_time_monotonic }; + + packet_list.phase = .sent; + self.client.raw_request( + Context.client_result_callback, + @bitCast(UserData{ + .self = self, + .packet = packet_list, + }), + message.ref(), + ); + assert(message.header.request != 0); + } + + fn signal_notify_callback(signal: *Signal) void { + assert(thread_caller == .io); + + const self: *Context = @alignCast(@fieldParentPtr("signal", signal)); + switch (self.signal.status()) { + .running => if (self.batch_size_limit == null) { + // Don't send any requests until registration completes. + assert(self.client.request_inflight != null); + assert(self.client.request_inflight.?.message.header.operation == .register); + return; + }, + // Shutdown flushes pending requests. + .shutdown_completed, .shutdown_requested => return, + } + + // Prevents IO thread starvation under heavy client load. + // Process only the minimal number of packets for the next pending request. + const enqueued_count = self.pending.count(); + const safety_limit = 8 * 1024; // Avoid unbounded loop in case of invalid packets. + for (0..safety_limit) |_| { + const packet: *Packet = pop: { + self.interface.locker.lock(); + defer self.interface.locker.unlock(); + + break :pop self.submitted.pop() orelse return; + }; + self.packet_enqueue(packet); + + // Packets can be processed without increasing `pending.count`: + // - If the packet is invalid. + // - If there's no in-flight request, the packet is sent immediately without + // using the pending queue. + // - If the packet can be batched with another previously enqueued packet. + if (self.pending.count() > enqueued_count) break; + } + + // Defer this work to later, + // allowing the IO thread to remain free for processing completions. + const empty: bool = empty: { + self.interface.locker.lock(); + defer self.interface.locker.unlock(); + + break :empty self.submitted.empty(); + }; + if (!empty) { + self.signal.notify(); + } + } + + fn client_register_callback(user_data: u128, result: *const vsr.RegisterResult) void { + assert(thread_caller == .io); + + const self: *Context = @ptrFromInt(@as(usize, @intCast(user_data))); + assert(self.client.request_inflight == null); + assert(self.batch_size_limit == null); + assert(result.batch_size_limit > 0); + + const current_timestamp = self.client.time.monotonic(); + self.request_latency = + self.request_timer.elapsed(current_timestamp); + + // The client might have a smaller message size limit. + maybe(constants.message_body_size_max < result.batch_size_limit); + self.batch_size_limit = @min(result.batch_size_limit, constants.message_body_size_max); + + // Some requests may have queued up while the client was registering. + signal_notify_callback(&self.signal); + } + + fn client_eviction_callback(client: *Client, eviction: *const Message.Eviction) void { + assert(thread_caller == .io); + + const self: *Context = @fieldParentPtr("client", client); + assert(self.eviction_reason == null); + + log.debug("{}: client_eviction_callback: reason={?s} reason_int={}", .{ + self.client_id, + std.enums.tagName(vsr.Header.Eviction.Reason, eviction.header.reason), + @intFromEnum(eviction.header.reason), + }); + + // The client was evicted, clearing the interface context so no more + // requests can be submitted. + // In-flight requests fail with the eviction reason; subsequent ones fail + // with "shutdown". + self.interface.locker.lock(); + defer self.interface.locker.unlock(); + + self.interface.context = .{ .ptr = null }; + + // Stops the IO thread, which then deinitializes the client before + // it exits (see `io_thread`). + self.eviction_reason = eviction.header.reason; + self.signal.stop(); + } + + fn client_result_callback( + raw_user_data: u128, + operation_vsr: vsr.Operation, + timestamp: u64, + reply: []align(constants.cache_line_size) const u8, + ) void { + assert(thread_caller == .io); + + const user_data: UserData = @bitCast(raw_user_data); + const self: *Context = user_data.self; + const packet_list: *Packet = user_data.packet; + const operation = operation_vsr.cast(Client.Operation); + assert(self.eviction_reason == null); + assert(packet_list.operation == @intFromEnum(operation)); + assert(timestamp > 0); + packet_list.assert_phase(.sent); + + const current_timestamp = self.client.time.monotonic(); + self.request_latency = + self.request_timer.elapsed(current_timestamp); + + // Submit the next pending packet (if any) now that VSR has completed this one. + assert(self.client.request_inflight == null); + while (self.pending.pop()) |packet_next| { + self.packet_send(packet_next); + if (self.client.request_inflight != null) break; + } + + // The callback should never be called with an operation not in `allowed_operations`. + // This also guards from sending an unsupported operation. + assert(operation_from_int(@intFromEnum(operation)) != null); + + if (!operation.is_multi_batch()) { + assert(packet_list.multi_batch_next == null); + return self.notify_completion(packet_list, .{ + .timestamp = timestamp, + .reply = reply, + }); + } + assert(operation.is_multi_batch()); + + const result_size: u32 = operation.result_size(); + assert(result_size > 0); + var reply_decoder = MultiBatchDecoder.init(reply, .{ + .element_size = result_size, + }) catch unreachable; + assert(packet_list.multi_batch_count == reply_decoder.batch_count()); + + // Copying it because `packet` is no longer valid after the callback. + const multi_batch_results_expected: u16 = + packet_list.multi_batch_result_count_expected; + var multi_batch_results_actual: u16 = 0; + var it: ?*Packet = packet_list; + while (it) |batched| { + if (batched != packet_list) batched.assert_phase(.batched); + assert(batched.operation == @intFromEnum(operation)); + + // NB: The reference to `batched` isn't valid after `notify_completion`. + it = batched.multi_batch_next; + + const batched_reply: []const u8 = reply_decoder.pop().?; + multi_batch_results_actual += @intCast(@divExact( + batched_reply.len, + result_size, + )); + self.notify_completion(batched, .{ + .timestamp = timestamp, + .reply = batched_reply, + }); + } + assert(reply_decoder.pop() == null); + assert(multi_batch_results_actual <= multi_batch_results_expected); + } + + fn notify_completion( + self: *Context, + packet: *Packet, + completion: PacketError!struct { + timestamp: u64, + reply: []const u8, + }, + ) void { + assert(thread_caller == .io); + + const result = completion catch |err| { + packet.status = switch (err) { + error.TooMuchData => .too_much_data, + error.ClientEvicted => .client_evicted, + error.ClientReleaseTooLow => .client_release_too_low, + error.ClientReleaseTooHigh => .client_release_too_high, + error.ClientShutdown => .client_shutdown, + error.InvalidOperation => .invalid_operation, + error.InvalidDataSize => .invalid_data_size, + }; + assert(packet.status != .ok); + packet.phase = .complete; + + // The packet completed with an error. + self.completion_callback( + self.completion_context, + packet.cast(), + 0, + null, + 0, + ); + return; + }; + + // The packet completed normally. + assert(packet.status == .ok); + packet.phase = .complete; + self.completion_callback( + self.completion_context, + packet.cast(), + result.timestamp, + result.reply.ptr, + @intCast(result.reply.len), + ); + } + + // VTable functions called by `ClientInterface`, which are thread-safe. + + fn vtable_submit_fn(context: *anyopaque, packet_extern: *Packet.Extern) void { + assert(thread_caller == .user); + + const self: *Context = @ptrCast(@alignCast(context)); + + // Packet is caller-allocated to enable elastic intrusive-link-list-based + // memory management. However, some of Packet's fields are essentially private. + // Initialize them here to avoid threading default fields through FFI boundary. + const packet: *Packet = packet_extern.cast(); + packet.* = .{ + .user_data = packet_extern.user_data, + .operation = packet_extern.operation, + .data_size = packet_extern.data_size, + .data = packet_extern.data, + .user_tag = packet_extern.user_tag, + .status = .ok, + .link = .{}, + .multi_batch_time_monotonic = 0, + .multi_batch_next = null, + .multi_batch_tail = null, + .multi_batch_count = 0, + .multi_batch_event_count = 0, + .multi_batch_result_count_expected = 0, + .phase = .submitted, + }; + + // Enqueue the packet and notify the IO thread to process it asynchronously. + assert(self.signal.status() == .running); + self.submitted.push(packet); + self.signal.notify(); + } + + fn vtable_completion_context_fn(context: *anyopaque) usize { + const self: *Context = @ptrCast(@alignCast(context)); + return self.completion_context; + } + + fn vtable_deinit_fn(context: *anyopaque) void { + assert(thread_caller == .user); + + const self: *Context = @ptrCast(@alignCast(context)); + + // Copy the thread handle here, since stopping the I/O thread deinitializes + // the context and invalidates the `self` pointer. + const thread = self.thread; + defer thread.join(); + + self.signal.stop(); + } + + fn vtable_init_parameters_fn(context: *anyopaque, out_parameters: *InitParameters) void { + assert(thread_caller == .user); + + const self: *Context = @ptrCast(@alignCast(context)); + assert(self.signal.status() == .running); + + out_parameters.cluster_id = self.cluster_id; + out_parameters.client_id = self.client_id; + out_parameters.addresses_ptr = self.addresses_owned.ptr; + out_parameters.addresses_len = self.addresses_owned.len; + } + + fn operation_from_int(op: u8) ?Operation { + inline for (allowed_operations) |operation| { + if (op == @intFromEnum(operation)) { + return operation; + } + } + return null; + } + }; +} + +/// Implements the `Mutex` API as an `extern` struct, based on `std.Thread.Futex`. +/// Vendored from `std.Thread.Mutex.FutexImpl`. +const Locker = extern struct { + const Futex = std.Thread.Futex; + const unlocked: u32 = 0b00; + const locked: u32 = 0b01; + const contended: u32 = 0b11; // Must contain the `locked` bit for x86 optimization below. + + state: std.atomic.Value(u32) = std.atomic.Value(u32).init(unlocked), + + fn lock(self: *Locker) void { + if (!self.try_lock()) { + self.lock_slow(); + } + } + + fn try_lock(self: *Locker) bool { + // On x86, use `lock bts` instead of `lock cmpxchg` as: + // - they both seem to mark the cache-line as modified regardless: https://stackoverflow.com/a/63350048. + // - `lock bts` is smaller instruction-wise which makes it better for inlining. + if (comptime builtin.target.cpu.arch.isX86()) { + const locked_bit = @ctz(locked); + return self.state.bitSet(locked_bit, .acquire) == 0; + } + + // Acquire barrier ensures grabbing the lock happens before the critical section + // and that the previous lock holder's critical section happens before we grab the lock. + return self.state.cmpxchgWeak(unlocked, locked, .acquire, .monotonic) == null; + } + + fn lock_slow(self: *Locker) void { + @branchHint(.cold); + + // Avoid doing an atomic swap below if we already know the state is contended. + // An atomic swap unconditionally stores which marks the cache-line as modified + // unnecessarily. + if (self.state.load(.monotonic) == contended) { + Futex.wait(&self.state, contended); + } + + // Try to acquire the lock while also telling the existing lock holder that there are + // threads waiting. + // + // Once we sleep on the Futex, we must acquire the mutex using `contended` rather than + // `locked`. + // If not, threads sleeping on the Futex wouldn't see the state change in unlock and + // potentially deadlock. + // The downside is that the last mutex unlocker will see `contended` and do an unnecessary + // Futex wake but this is better than having to wake all waiting threads on mutex unlock. + // + // Acquire barrier ensures grabbing the lock happens before the critical section + // and that the previous lock holder's critical section happens before we grab the lock. + while (self.state.swap(contended, .acquire) != unlocked) { + Futex.wait(&self.state, contended); + } + } + + fn unlock(self: *Locker) void { + // Unlock the mutex and wake up a waiting thread if any. + // + // A waiting thread will acquire with `contended` instead of `locked` + // which ensures that it wakes up another thread on the next unlock(). + // + // Release barrier ensures the critical section happens before we let go of the lock + // and that our critical section happens before the next lock holder grabs the lock. + const state = self.state.swap(unlocked, .release); + assert(state != unlocked); + + if (state == contended) { + Futex.wake(&self.state, 1); + } + } +}; + +const testing = std.testing; +test "Locker: smoke test" { + var locker = Locker{}; + + try testing.expect(locker.try_lock()); + try testing.expect(!locker.try_lock()); + locker.unlock(); + + locker.lock(); + try testing.expect(!locker.try_lock()); + locker.unlock(); +} + +test "Locker: contended" { + const threads_count = 4; + const increments = 1000; + + const State = struct { + locker: Locker = .{}, + counter: u32 = 0, + }; + + const Runner = struct { + thread: std.Thread = undefined, + state: *State, + fn run(self: *@This()) void { + while (true) { + self.state.locker.lock(); + defer self.state.locker.unlock(); + + if (self.state.counter == increments) break; + self.state.counter += 1; + } + } + }; + + var state = State{}; + var runners: [threads_count]Runner = undefined; + for (&runners) |*runner| { + runner.* = .{ .state = &state }; + runner.thread = try std.Thread.spawn(.{}, Runner.run, .{runner}); + } + for (&runners) |*runner| { + runner.thread.join(); + } + + try testing.expectEqual(state.counter, increments); +} diff --git a/ocam/src/clients/c/tb_client/echo_client.zig b/ocam/src/clients/c/tb_client/echo_client.zig new file mode 100644 index 00000000..89a913a5 --- /dev/null +++ b/ocam/src/clients/c/tb_client/echo_client.zig @@ -0,0 +1,292 @@ +const std = @import("std"); +const assert = std.debug.assert; +const mem = std.mem; + +const vsr = @import("../tb_client.zig").vsr; +const Header = vsr.Header; +const stdx = vsr.stdx; +const constants = vsr.constants; +const MessagePool = vsr.message_pool.MessagePool; +const Message = MessagePool.Message; +const Time = vsr.time.Time; + +pub fn EchoClientType(comptime MessageBus: type) type { + return struct { + const EchoClient = @This(); + + // Exposing the same types the real client does: + const VSRClient = vsr.ClientType(EchoOperation, MessageBus); + pub const Operation = VSRClient.Operation; + pub const Request = VSRClient.Request; + + id: u128, + cluster: u128, + release: vsr.Release = vsr.Release.minimum, + request_number: u32 = 0, + reply_timestamp: u64 = 0, // Fake timestamp, just a counter. + request_inflight: ?Request = null, + message_pool: *MessagePool, + time: Time, + + pub fn init( + allocator: mem.Allocator, + time: Time, + message_pool: *MessagePool, + options: struct { + id: u128, + cluster: u128, + replica_count: u8, + message_bus_options: MessageBus.Options, + eviction_callback: ?*const fn ( + client: *EchoClient, + eviction: *const Message.Eviction, + ) void = null, + aof_recovery: bool, + }, + ) !EchoClient { + _ = allocator; + _ = options.replica_count; + _ = options.message_bus_options; + assert(!options.aof_recovery); + + return EchoClient{ + .id = options.id, + .cluster = options.cluster, + .message_pool = message_pool, + .time = time, + }; + } + + pub fn deinit(self: *EchoClient, allocator: std.mem.Allocator) void { + _ = allocator; + if (self.request_inflight) |inflight| self.release_message(inflight.message.base()); + } + + /// EchoClient has no real IO to drain. + pub fn shutdown(_: *EchoClient) void {} + pub fn shutdown_complete(_: *const EchoClient) bool { + return true; + } + + pub fn tick(self: *EchoClient) void { + const inflight = self.request_inflight orelse return; + self.request_inflight = null; + + self.reply_timestamp += 1; + const timestamp = self.reply_timestamp; + + // Allocate a reply message. + const reply = self.get_message().build(.request); + defer self.release_message(reply.base()); + + // Copy the request message's entire content including header into the reply. + const operation = inflight.message.header.operation; + stdx.copy_disjoint( + .exact, + u8, + reply.buffer, + inflight.message.buffer, + ); + + // Similarly to the real client, release the request message before invoking the + // callback. This necessitates a `copy_disjoint` above. + self.release_message(inflight.message.base()); + + switch (inflight.callback) { + .request => |callback| { + callback(inflight.user_data, operation, timestamp, reply.body_used()); + }, + .register => |callback| { + const result = vsr.RegisterResult{ + .batch_size_limit = constants.message_body_size_max, + }; + callback(inflight.user_data, &result); + }, + } + } + + pub fn register( + self: *EchoClient, + callback: Request.RegisterCallback, + user_data: u128, + ) void { + assert(self.request_inflight == null); + assert(self.request_number == 0); + + const message = self.get_message().build(.request); + errdefer self.release_message(message.base()); + + // We will set parent, session, view and checksums only when sending for the first time: + message.header.* = .{ + .client = self.id, + .request = self.request_number, + .cluster = self.cluster, + .command = .request, + .operation = .register, + .release = vsr.Release.minimum, + .previous_request_latency = 0, + }; + + assert(self.request_number == 0); + self.request_number += 1; + + self.request_inflight = .{ + .message = message, + .user_data = user_data, + .callback = .{ .register = callback }, + }; + } + + pub fn request( + self: *EchoClient, + callback: Request.Callback, + user_data: u128, + operation: Operation, + events: []const u8, + ) void { + const event_size = operation.event_size(); + assert(events.len <= constants.message_body_size_max); + assert(events.len % event_size == 0); + + const message = self.get_message().build(.request); + errdefer self.release_message(message.base()); + + message.header.* = .{ + .client = self.id, + .request = 0, // Set by raw_request() below. + .cluster = self.cluster, + .command = .request, + .release = vsr.Release.minimum, + .operation = operation.to_vsr(), + .size = @intCast(@sizeOf(Header) + events.len), + .previous_request_latency = 0, + }; + + stdx.copy_disjoint(.exact, u8, message.body_used(), events); + self.raw_request(callback, user_data, message); + } + + pub fn raw_request( + self: *EchoClient, + callback: Request.Callback, + user_data: u128, + message: *Message.Request, + ) void { + assert(message.header.client == self.id); + assert(message.header.cluster == self.cluster); + assert(message.header.release.value == self.release.value); + assert(!message.header.operation.vsr_reserved()); + assert(message.header.size >= @sizeOf(Header)); + assert(message.header.size <= constants.message_size_max); + + message.header.request = self.request_number; + self.request_number += 1; + + assert(self.request_inflight == null); + self.request_inflight = .{ + .message = message, + .user_data = user_data, + .callback = .{ .request = callback }, + }; + } + + pub fn get_message(self: *EchoClient) *Message { + return self.message_pool.get_message(null); + } + + pub fn release_message(self: *EchoClient, message: *Message) void { + self.message_pool.unref(message); + } + }; +} + +/// Mocks the Accounting StateMachine operation, but replaces +/// all `Result`s with `Event`s, since the echo client replies +/// with the same content of the input. +pub const EchoOperation = enum(u8) { + const Operation = vsr.tigerbeetle.Operation; + + pulse = @intFromEnum(Operation.pulse), + + get_change_events = @intFromEnum(Operation.get_change_events), + + create_accounts = @intFromEnum(Operation.create_accounts), + create_transfers = @intFromEnum(Operation.create_transfers), + lookup_accounts = @intFromEnum(Operation.lookup_accounts), + lookup_transfers = @intFromEnum(Operation.lookup_transfers), + get_account_transfers = @intFromEnum(Operation.get_account_transfers), + get_account_balances = @intFromEnum(Operation.get_account_balances), + query_accounts = @intFromEnum(Operation.query_accounts), + query_transfers = @intFromEnum(Operation.query_transfers), + + comptime { + const operation_type_info = @typeInfo(Operation).@"enum"; + const echo_type_info = @typeInfo(EchoOperation).@"enum"; + assert(echo_type_info.tag_type == operation_type_info.tag_type); + assert(echo_type_info.is_exhaustive); + assert(echo_type_info.fields.len <= operation_type_info.fields.len); + for (echo_type_info.fields) |field| { + assert(@hasField(Operation, field.name)); + + const a = @field(Operation, field.name); + const b = @field(EchoOperation, field.name); + assert(@intFromEnum(a) == @intFromEnum(b)); + } + } + + inline fn cast(operation: EchoOperation) Operation { + return @enumFromInt(@intFromEnum(operation)); + } + + pub fn EventType(comptime operation: EchoOperation) type { + return operation.cast().EventType(); + } + + pub inline fn event_size(operation: EchoOperation) u32 { + return operation.cast().event_size(); + } + + pub inline fn is_batchable(operation: EchoOperation) bool { + return operation.cast().is_batchable(); + } + + pub inline fn is_multi_batch(operation: EchoOperation) bool { + return operation.cast().is_multi_batch(); + } + + pub inline fn event_max(operation: EchoOperation, batch_size_limit: u32) u32 { + return operation.cast().event_max(batch_size_limit); + } + + pub inline fn result_count_expected( + operation: EchoOperation, + batch: []const u8, + ) u32 { + return operation.cast().result_count_expected(batch); + } + + pub fn from_vsr(operation: vsr.Operation) ?EchoOperation { + if (operation == .pulse) return .pulse; + if (operation.vsr_reserved()) return null; + + return vsr.Operation.to(EchoOperation, operation); + } + + pub fn to_vsr(operation: EchoOperation) vsr.Operation { + return vsr.Operation.from(EchoOperation, operation); + } + + // Re-exporting functions where results are equal to events. + + pub fn ResultType(comptime operation: EchoOperation) type { + return operation.EventType(); + } + + pub inline fn result_size(operation: EchoOperation) u32 { + return operation.event_size(); + } + + pub inline fn result_max(operation: EchoOperation, batch_size_limit: u32) u32 { + return operation.event_max(batch_size_limit); + } +}; diff --git a/ocam/src/clients/c/tb_client/packet.zig b/ocam/src/clients/c/tb_client/packet.zig new file mode 100644 index 00000000..1ecb33a5 --- /dev/null +++ b/ocam/src/clients/c/tb_client/packet.zig @@ -0,0 +1,158 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const tb_client = @import("../tb_client.zig"); +const stdx = tb_client.vsr.stdx; +const maybe = stdx.maybe; + +const QueueType = tb_client.vsr.queue.QueueType; + +pub const Packet = extern struct { + pub const Status = enum(u8) { + ok, + too_much_data, + client_evicted, + client_release_too_low, + client_release_too_high, + client_shutdown, + invalid_operation, + invalid_data_size, + }; + + /// External packet type exposed to the user. + pub const Extern = extern struct { + user_data: ?*anyopaque, + data: ?*anyopaque, + data_size: u32, + user_tag: u16, + operation: u8, + status: Status, + @"opaque": [64]u8 = @splat(0), + + pub fn cast(self: *Extern) *Packet { + return @ptrCast(self); + } + }; + + const Phase = enum(u8) { + submitted, + pending, + batched, + sent, + complete, + }; + + pub const Queue = QueueType(Packet); + + user_data: ?*anyopaque, + data: ?*anyopaque, + data_size: u32, + user_tag: u16, + operation: u8, + status: Status, + + link: Queue.Link, + + multi_batch_time_monotonic: u64, + multi_batch_next: ?*Packet, + multi_batch_tail: ?*Packet, + multi_batch_count: u16, + multi_batch_event_count: u16, + multi_batch_result_count_expected: u16, + phase: Phase, + reserved: [25]u8 = @splat(0), + + pub fn cast(self: *Packet) *Extern { + return @ptrCast(self); + } + + pub fn slice(packet: *const Packet) []const u8 { + if (packet.data_size == 0) { + // It may be an empty array (null pointer) + // or a buffer with no elements (valid pointer and size == 0). + stdx.maybe(packet.data == null); + return &[0]u8{}; + } + + const data: [*]const u8 = @ptrCast(packet.data.?); + return data[0..packet.data_size]; + } + + /// Asserts the internal state of the packet according to its expected phase. + /// Inline function, so `expected` can be comptime known. + pub inline fn assert_phase(packet: *const Packet, expected: Phase) void { + assert(packet.phase == expected); + assert(packet.data_size == 0 or packet.data != null); + assert(stdx.zeroed(&packet.reserved)); + maybe(packet.user_data == null); + maybe(packet.user_tag == 0); + + switch (expected) { + .submitted => { + assert(packet.link.next == null); + assert(packet.multi_batch_next == null); + assert(packet.multi_batch_tail == null); + assert(packet.multi_batch_count == 0); + assert(packet.multi_batch_event_count == 0); + assert(packet.multi_batch_result_count_expected == 0); + assert(packet.multi_batch_time_monotonic == 0); + }, + .pending => { + assert(packet.multi_batch_count >= 1); + assert(packet.multi_batch_next == null or packet.multi_batch_count > 1); + assert((packet.multi_batch_next == null) == (packet.multi_batch_tail == null)); + maybe(packet.data_size == 0); + maybe(packet.multi_batch_event_count == 0); + maybe(packet.multi_batch_result_count_expected == 0); + maybe(packet.link.next == null); + assert(packet.multi_batch_time_monotonic != 0); + }, + .batched => { + assert(packet.link.next == null); + assert(packet.multi_batch_tail == null); + assert(packet.multi_batch_count == 0); + assert(packet.multi_batch_event_count == 0); + assert(packet.multi_batch_result_count_expected == 0); + maybe(packet.multi_batch_next != null); + assert(packet.multi_batch_time_monotonic == 0); + }, + .sent => { + assert(packet.link.next == null); + assert(packet.multi_batch_count > 0); + assert(packet.multi_batch_next == null or packet.multi_batch_count > 1); + assert((packet.multi_batch_next == null) == (packet.multi_batch_tail == null)); + maybe(packet.multi_batch_event_count == 0); + maybe(packet.multi_batch_result_count_expected == 0); + assert(packet.multi_batch_time_monotonic != 0); + }, + .complete => { + // The packet pointer isn't available after completed, + // it may be deallocated by the user; + unreachable; + }, + } + } + + comptime { + assert(@sizeOf(Extern) % @alignOf(Extern) == 0); + assert(@alignOf(Extern) == 8); + + assert(@sizeOf(Packet) == @sizeOf(Extern)); + assert(@alignOf(Packet) == @alignOf(Extern)); + + // Asserting the fields are identical. + for (std.meta.fields(Extern)) |field_extern| { + if (std.mem.eql(u8, field_extern.name, "opaque")) continue; + const field_packet = std.meta.fields(Packet)[ + std.meta.fieldIndex( + Packet, + field_extern.name, + ).? + ]; + assert(field_packet.type == field_extern.type); + assert(field_packet.alignment == field_extern.alignment); + assert(@offsetOf(Packet, field_extern.name) == + @offsetOf(Extern, field_extern.name)); + } + } +}; diff --git a/ocam/src/clients/c/tb_client/signal.zig b/ocam/src/clients/c/tb_client/signal.zig new file mode 100644 index 00000000..82f6b9ff --- /dev/null +++ b/ocam/src/clients/c/tb_client/signal.zig @@ -0,0 +1,228 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const vsr = @import("../tb_client.zig").vsr; +const TimeOS = vsr.time.TimeOS; +const IO = vsr.io.IO; + +const Atomic = std.atomic.Value; + +/// A Signal is a way to trigger a registered callback on a IO instance when notification +/// occurs from another thread. +pub const Signal = struct { + io: *IO, + completion: IO.Completion, + event: IO.Event, + event_state: Atomic(enum(u8) { + running, + waiting, + notified, + shutdown, + }), + + listening: Atomic(bool), + on_signal_fn: *const fn (*Signal) void, + + pub fn init(self: *Signal, io: *IO, on_signal_fn: *const fn (*Signal) void) !void { + const event = try io.open_event(); + errdefer io.close_event(event); + + self.* = .{ + .io = io, + .completion = undefined, + .event = event, + .event_state = @TypeOf(self.event_state).init(.running), + .listening = Atomic(bool).init(true), + .on_signal_fn = on_signal_fn, + }; + + self.wait(); + } + + pub fn deinit(self: *Signal) void { + assert(self.event != IO.INVALID_EVENT); + assert(self.status() == .shutdown_completed); + + self.io.close_event(self.event); + self.* = undefined; + } + + /// Requests to stop listening for notifications. + /// The caller must continue processing `IO.run()` until `state() == .stopped`. + /// Safe to call from multiple threads. + pub fn stop(self: *Signal) void { + const listening = self.listening.swap(false, .release); + if (listening) { + self.notify(); + } + } + + /// Returns the current state. + /// Safe to call from multiple threads. + pub fn status(self: *const Signal) enum { + /// Listening for event notifications. + /// Call `notify()` to trigger the callback. + running, + /// `stop()` was called, but the event listener is still waiting for the IO operation + /// to complete. Further calls to `notify()` have no effect. + shutdown_requested, + /// No pending listening events. It is safe to call `deinit()`. + shutdown_completed, + } { + return switch (self.event_state.load(.acquire)) { + .shutdown => .shutdown_completed, + .running, + .waiting, + .notified, + => if (self.listening.load(.acquire)) + .running + else + .shutdown_requested, + }; + } + + /// Schedules the `on_signal` callback to be invoked on the IO thread. + /// Calling `notify()` when `state() != .running` has no effect. + /// Safe to call from multiple threads. + pub fn notify(self: *Signal) void { + // Try to transition from `waiting` to `notified`. + // If it fails, analyze the current state to determine if a notification is needed. + var state: @TypeOf(self.event_state.raw) = .waiting; + while (self.event_state.cmpxchgStrong( + state, + .notified, + .release, + .acquire, + )) |state_actual| { + switch (state_actual) { + .waiting, .running => state = state_actual, // Try again. + .notified => return, // Already notified. + .shutdown => return, // Ignore notifications after shutdown. + } + } + + if (state == .waiting) { + self.io.event_trigger(self.event, &self.completion); + } + } + + fn wait(self: *Signal) void { + // It is not guaranteed to be `running` here, as another caller might have requested + // a stop during the callback. + assert(self.status() != .shutdown_completed); + + const state = self.event_state.swap(.waiting, .acquire); + self.io.event_listen(self.event, &self.completion, on_event); + switch (state) { + // We should be the only ones who could've started waiting. + .waiting => unreachable, + // Wait for a `notify`. + .running => {}, + // A `notify` was already called in the meantime, + // calling it again asynchronously. + .notified => self.notify(), + // Cannot be called after shutdown. + .shutdown => unreachable, + } + } + + fn on_event(completion: *IO.Completion) void { + const self: *Signal = @fieldParentPtr("completion", completion); + const listening: bool = self.listening.load(.acquire); + const state = self.event_state.cmpxchgStrong( + .notified, + if (listening) .running else .shutdown, + .release, + .acquire, + ) orelse { + if (listening) { + (self.on_signal_fn)(self); + self.wait(); + } + return; + }; + + switch (state) { + .running => unreachable, // Multiple racing calls to on_signal(). + .waiting => unreachable, // on_signal() called without transitioning to a waking state. + .notified => unreachable, // Not possible due to CAS semantics. + .shutdown => unreachable, // Shutdown is a final state. + } + } +}; + +test "signal" { + try struct { + const Context = @This(); + + io: IO, + count: u32 = 0, + main_thread_id: std.Thread.Id, + signal: Signal, + + const delay = 5 * std.time.ns_per_ms; + const events_count = 5; + + fn run_test() !void { + var self: Context = .{ + .io = try IO.init(32, 0), + .main_thread_id = std.Thread.getCurrentId(), + .signal = undefined, + }; + defer self.io.deinit(); + + try Signal.init(&self.signal, &self.io, on_signal); + defer self.signal.deinit(); + + var time: TimeOS = .{}; + const timer = time.monotonic(); + + const thread = try std.Thread.spawn(.{}, Context.notify, .{&self}); + + // Wait for the number of events to complete. + while (self.count < events_count) try self.io.run(); + + // Begin shutdown and keep ticking until it's completed. + self.signal.stop(); + while (self.signal.status() != .shutdown_completed) try self.io.run(); + thread.join(); + + // Notify after shutdown should be ignored. + self.signal.notify(); + + // Make sure the event was triggered multiple times. + assert(self.count == events_count); + + // Make sure at least some time has passed. + const elapsed = timer.elapsed(time.monotonic()); + assert(elapsed.ns >= delay); + } + + fn notify(self: *Context) void { + assert(std.Thread.getCurrentId() != self.main_thread_id); + while (self.signal.status() != .shutdown_completed) { + std.time.sleep(delay + 1); + + // Triggering the event: + self.signal.notify(); + + // The same signal may be triggered multiple times, + // but it should only fire once. + self.signal.notify(); + } + } + + fn on_signal(signal: *Signal) void { + const self: *Context = @fieldParentPtr("signal", signal); + assert(std.Thread.getCurrentId() == self.main_thread_id); + switch (self.signal.status()) { + .running => { + assert(self.count < events_count); + self.count += 1; + }, + .shutdown_requested => assert(self.count == events_count), + .shutdown_completed => unreachable, + } + } + }.run_test(); +} diff --git a/ocam/src/clients/c/tb_client/signal_fuzz.zig b/ocam/src/clients/c/tb_client/signal_fuzz.zig new file mode 100644 index 00000000..59f8b0fe --- /dev/null +++ b/ocam/src/clients/c/tb_client/signal_fuzz.zig @@ -0,0 +1,110 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const Signal = @import("./signal.zig").Signal; +const IO = @import("../../../io.zig").IO; +const stdx = @import("stdx"); +const fuzz = @import("../../../testing/fuzz.zig"); + +const threads_limit = 8; +const Threads = stdx.BoundedArrayType(std.Thread, threads_limit); + +const StopRequest = enum(u8) { + none, + io_thread, + user_thread, +}; + +const Context = struct { + const Atomic = std.atomic.Value(StopRequest); + + main_thread_id: std.Thread.Id, + signal: Signal, + running_count: u32 = 0, + stop_request: Atomic = Atomic.init(.none), +}; + +pub fn main(_: std.mem.Allocator, args: fuzz.FuzzArgs) !void { + var prng = stdx.PRNG.from_seed(args.seed); + const events_max = args.events_max orelse 100; + + for (0..events_max) |_| { + var io = try IO.init(32, 0); + defer io.deinit(); + + var context: Context = .{ + .main_thread_id = std.Thread.getCurrentId(), + .signal = undefined, + }; + + try Signal.init(&context.signal, &io, on_signal); + defer context.signal.deinit(); + + const threads_max = prng.range_inclusive(u32, 1, threads_limit); + var threads: Threads = .{}; + for (0..threads_max) |_| { + const thread = try std.Thread.spawn(.{}, notify, .{&context}); + threads.push(thread); + } + + while (context.signal.status() != .shutdown_completed) { + if (context.running_count > 0) { + // Setting a random `stop_request`. + _ = context.stop_request.cmpxchgStrong( + .none, + prng.enum_uniform(StopRequest), + .acquire, + .monotonic, + ); + } + + const tick_us = 10; + try io.run_for_ns(tick_us * std.time.ns_per_us); + } + + for (threads.slice()) |*thread| { + thread.join(); + } + + assert(context.running_count > 0); + assert(context.stop_request.load(.monotonic) != .none); + } +} + +fn notify(context: *Context) void { + assert(std.Thread.getCurrentId() != context.main_thread_id); + while (context.signal.status() != .shutdown_completed) { + const delay_us = 1; // Shorter than `tick_us`. + std.time.sleep(delay_us * std.time.ns_per_us); + + if (context.stop_request.load(.monotonic) == .user_thread) { + // Stop can be called by multiple threads. + context.signal.stop(); + } + + // Notify has no effect if called after `stop()`. + context.signal.notify(); + } +} + +fn on_signal(signal: *Signal) void { + const context: *Context = @fieldParentPtr("signal", signal); + assert(std.Thread.getCurrentId() == context.main_thread_id); + switch (context.signal.status()) { + .running => { + context.running_count += 1; + if (context.stop_request.load(.monotonic) == .io_thread) { + // Stop the signal while the notification is running. + context.signal.stop(); + } + }, + .shutdown_requested => { + // It's not possible if `stop` was called from the IO thread. + assert(context.stop_request.load(.monotonic) == .user_thread); + + // Requested while running, so still counts as one event. + context.running_count += 1; + }, + .shutdown_completed => unreachable, + } +} diff --git a/ocam/src/clients/c/tb_client_exports.zig b/ocam/src/clients/c/tb_client_exports.zig new file mode 100644 index 00000000..470cb8cf --- /dev/null +++ b/ocam/src/clients/c/tb_client_exports.zig @@ -0,0 +1,281 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const vsr = @import("../../vsr.zig"); +const tb = vsr.tb_client; +const stdx = vsr.stdx; + +pub const tb_packet_t = tb.Packet; +pub const tb_packet_status = tb.PacketStatus; + +pub const tb_client_t = extern struct { + @"opaque": [4]u64, + + pub inline fn cast(self: *tb_client_t) *tb.ClientInterface { + return @ptrCast(self); + } + + comptime { + assert(@sizeOf(tb_client_t) == @sizeOf(tb.ClientInterface)); + assert(@bitSizeOf(tb_client_t) == @bitSizeOf(tb.ClientInterface)); + assert(@alignOf(tb_client_t) == @alignOf(tb.ClientInterface)); + } +}; + +pub const tb_init_status = enum(c_int) { + success = 0, + unexpected, + out_of_memory, + address_invalid, + address_limit_exceeded, + system_resources, + network_subsystem, +}; + +pub const tb_client_status = enum(c_int) { + ok = 0, + invalid, +}; + +pub const tb_register_log_callback_status = enum(c_int) { + success = 0, + already_registered, + not_registered, +}; + +pub const tb_log_level = enum(c_int) { + err = @intFromEnum(std.log.Level.err), + warn = @intFromEnum(std.log.Level.warn), + info = @intFromEnum(std.log.Level.info), + debug = @intFromEnum(std.log.Level.debug), + + comptime { + assert(std.enums.values(std.log.Level).len == std.enums.values(tb_log_level).len); + for (std.enums.values(std.log.Level)) |std_level| { + const level: tb_log_level = @enumFromInt(@intFromEnum(std_level)); + assert(std.mem.eql(u8, @tagName(std_level), @tagName(level))); + } + } +}; + +pub const tb_operation = tb.Operation; +pub const tb_completion_t = tb.CompletionCallback; +pub const tb_init_parameters = tb.InitParameters; + +pub const tb_account_t = vsr.tigerbeetle.Account; +pub const tb_transfer_t = vsr.tigerbeetle.Transfer; +pub const tb_account_flags = vsr.tigerbeetle.AccountFlags; +pub const tb_transfer_flags = vsr.tigerbeetle.TransferFlags; +pub const tb_create_account_status = vsr.tigerbeetle.CreateAccountStatus; +pub const tb_create_transfer_status = vsr.tigerbeetle.CreateTransferStatus; +pub const tb_create_account_result_t = vsr.tigerbeetle.CreateAccountResult; +pub const tb_create_transfer_result_t = vsr.tigerbeetle.CreateTransferResult; +pub const tb_account_filter_t = vsr.tigerbeetle.AccountFilter; +pub const tb_account_filter_flags = vsr.tigerbeetle.AccountFilterFlags; +pub const tb_account_balance_t = vsr.tigerbeetle.AccountBalance; +pub const tb_query_filter_t = vsr.tigerbeetle.QueryFilter; +pub const tb_query_filter_flags = vsr.tigerbeetle.QueryFilterFlags; + +pub fn init_error_to_status(err: tb.InitError) tb_init_status { + return switch (err) { + error.Unexpected => .unexpected, + error.OutOfMemory => .out_of_memory, + error.AddressInvalid => .address_invalid, + error.AddressLimitExceeded => .address_limit_exceeded, + error.SystemResources => .system_resources, + error.NetworkSubsystemFailed => .network_subsystem, + }; +} + +pub fn init( + tb_client_out: *tb_client_t, + cluster_id_ptr: *const [16]u8, + addresses_ptr: [*:0]const u8, + addresses_len: u32, + completion_ctx: usize, + completion_callback: tb_completion_t, +) callconv(.c) tb_init_status { + const addresses = @as([*]const u8, @ptrCast(addresses_ptr))[0..addresses_len]; + + // Passing u128 by value is prone to ABI issues. Pass as a [16]u8, and explicitly copy into + // memory we know will be aligned correctly. Don't just use bytesToValue here, as that keeps + // pointer alignment, and will result in a potentially unaligned access of a + // `*align(1) const u128`. + const cluster_id: u128 = blk: { + var cluster_id: u128 = undefined; + stdx.copy_disjoint(.exact, u8, std.mem.asBytes(&cluster_id), cluster_id_ptr); + + break :blk cluster_id; + }; + + tb.init( + std.heap.c_allocator, + tb_client_out.cast(), + cluster_id, + addresses, + completion_ctx, + completion_callback, + ) catch |err| return init_error_to_status(err); + return .success; +} + +pub fn init_echo( + tb_client_out: *tb_client_t, + cluster_id_ptr: *const [16]u8, + addresses_ptr: [*:0]const u8, + addresses_len: u32, + completion_ctx: usize, + completion_callback: tb_completion_t, +) callconv(.c) tb_init_status { + const addresses = @as([*]const u8, @ptrCast(addresses_ptr))[0..addresses_len]; + + // See explanation in init(). + const cluster_id: u128 = blk: { + var cluster_id: u128 = undefined; + stdx.copy_disjoint(.exact, u8, std.mem.asBytes(&cluster_id), cluster_id_ptr); + + break :blk cluster_id; + }; + + tb.init_echo( + std.heap.c_allocator, + tb_client_out.cast(), + cluster_id, + addresses, + completion_ctx, + completion_callback, + ) catch |err| return init_error_to_status(err); + return .success; +} + +pub fn submit(tb_client: ?*tb_client_t, packet: *tb_packet_t) callconv(.c) tb_client_status { + const client: *tb.ClientInterface = if (tb_client) |ptr| ptr.cast() else return .invalid; + client.submit(packet) catch |err| switch (err) { + error.ClientInvalid => return .invalid, + }; + return .ok; +} + +pub fn deinit(tb_client: ?*tb_client_t) callconv(.c) tb_client_status { + const client: *tb.ClientInterface = if (tb_client) |ptr| ptr.cast() else return .invalid; + client.deinit() catch |err| switch (err) { + error.ClientInvalid => return .invalid, + }; + return .ok; +} + +pub fn init_parameters( + tb_client: ?*tb_client_t, + out_parameters: *tb_init_parameters, +) callconv(.c) tb_client_status { + const client: *tb.ClientInterface = if (tb_client) |ptr| ptr.cast() else return .invalid; + client.init_parameters(out_parameters) catch |err| switch (err) { + error.ClientInvalid => return .invalid, + }; + return .ok; +} + +pub fn completion_context( + tb_client: ?*tb_client_t, + completion_ctx_out: *usize, +) callconv(.c) tb_client_status { + const client: *tb.ClientInterface = if (tb_client) |ptr| ptr.cast() else return .invalid; + completion_ctx_out.* = client.completion_context() catch |err| switch (err) { + error.ClientInvalid => return .invalid, + }; + return .ok; +} + +pub fn register_log_callback( + callback_maybe: ?Logging.Callback, + debug: bool, +) callconv(.c) tb_register_log_callback_status { + Logging.global.mutex.lock(); + defer Logging.global.mutex.unlock(); + + if (Logging.global.callback == null) { + if (callback_maybe) |callback| { + Logging.global.callback = callback; + Logging.global.debug = debug; + return .success; + } else { + return .not_registered; + } + } else { + if (callback_maybe == null) { + Logging.global.callback = null; + Logging.global.debug = debug; + return .success; + } else { + return .already_registered; + } + } +} + +pub const Logging = struct { + const Callback = *const fn ( + message_level: tb_log_level, + message_ptr: [*]const u8, + message_len: u32, + ) callconv(.c) void; + + const log_line_max = 8192; + + /// Logging is global per process; it would be nice to be able to define a different logger + /// for each client instance, though. + var global: Logging = .{}; + + callback: ?Callback = null, + mutex: std.Thread.Mutex = .{}, + buffer: [log_line_max]u8 = undefined, + debug: bool = false, + + /// A logger which defers to an application provided handler. + pub fn application_logger( + comptime message_level: std.log.Level, + comptime scope: @Type(.enum_literal), + comptime format: []const u8, + args: anytype, + ) void { + // Debug logs are dropped here unless debug is set, because of the potential penalty in + // crossing FFI to drop them. + if (message_level == .debug and !Logging.global.debug) { + return; + } + + // Other messages are silently dropped if no logging callback is specified - unless they're + // warn or err. The value in having those for debugging is too high to silence them, even + // until client libraries catch up and implement a callback handler. + if (Logging.global.callback == null and (message_level == .warn or message_level == .err)) { + std.log.defaultLog(message_level, scope, format, args); + return; + } + + // Protect everything with a mutex - logging can be called from different threads + // simultaneously, and there's only one buffer for now. + Logging.global.mutex.lock(); + defer Logging.global.mutex.unlock(); + + const callback = Logging.global.callback orelse return; + + const tb_message_level: tb_log_level = @enumFromInt(@intFromEnum(message_level)); + const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; + const output = std.fmt.bufPrint( + &Logging.global.buffer, + prefix ++ format, + args, + ) catch |err| switch (err) { + error.NoSpaceLeft => blk: { + // Print an error indicating the log message has been truncated, before the + // truncated log itself. + const message = "the following log message has been truncated:"; + callback(tb_message_level, message.ptr, message.len); + + break :blk &Logging.global.buffer; + }, + else => unreachable, + }; + + callback(tb_message_level, output.ptr, @intCast(output.len)); + } +}; diff --git a/ocam/src/clients/c/tb_client_header.zig b/ocam/src/clients/c/tb_client_header.zig new file mode 100644 index 00000000..2c514b18 --- /dev/null +++ b/ocam/src/clients/c/tb_client_header.zig @@ -0,0 +1,308 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const vsr = @import("vsr"); +const exports = vsr.tb_client.exports; +const stdx = vsr.stdx; + +const type_mappings = .{ + .{ exports.tb_account_flags, "TB_ACCOUNT_FLAGS" }, + .{ exports.tb_account_t, "tb_account_t" }, + .{ exports.tb_transfer_flags, "TB_TRANSFER_FLAGS" }, + .{ exports.tb_transfer_t, "tb_transfer_t" }, + .{ exports.tb_create_account_status, "TB_CREATE_ACCOUNT_STATUS" }, + .{ exports.tb_create_transfer_status, "TB_CREATE_TRANSFER_STATUS" }, + .{ exports.tb_create_account_result_t, "tb_create_account_result_t" }, + .{ exports.tb_create_transfer_result_t, "tb_create_transfer_result_t" }, + .{ exports.tb_account_filter_t, "tb_account_filter_t" }, + .{ exports.tb_account_filter_flags, "TB_ACCOUNT_FILTER_FLAGS" }, + .{ exports.tb_account_balance_t, "tb_account_balance_t" }, + .{ exports.tb_query_filter_t, "tb_query_filter_t" }, + .{ exports.tb_query_filter_flags, "TB_QUERY_FILTER_FLAGS" }, + .{ + exports.tb_client_t, "tb_client_t", + \\// Opaque struct serving as a handle for the client instance. + \\// This struct must be "pinned" (not copyable or movable), as its address must remain stable + \\// throughout the lifetime of the client instance. + }, + .{ + exports.tb_packet_t, "tb_packet_t", + \\// Struct containing the state of a request submitted through the client. + \\// This struct must be "pinned" (not copyable or movable), as its address must remain stable + \\// throughout the lifetime of the request. + }, + .{ exports.tb_operation, "TB_OPERATION" }, + .{ exports.tb_packet_status, "TB_PACKET_STATUS" }, + .{ exports.tb_init_status, "TB_INIT_STATUS" }, + .{ exports.tb_client_status, "TB_CLIENT_STATUS" }, + .{ exports.tb_register_log_callback_status, "TB_REGISTER_LOG_CALLBACK_STATUS" }, + .{ exports.tb_log_level, "TB_LOG_LEVEL" }, + .{ exports.tb_init_parameters, "tb_init_parameters_t" }, +}; + +fn resolve_c_type(comptime Type: type) []const u8 { + switch (@typeInfo(Type)) { + .array => |info| return resolve_c_type(info.child), + .@"enum" => |info| return resolve_c_type(info.tag_type), + .@"struct" => return resolve_c_type(std.meta.Int(.unsigned, @bitSizeOf(Type))), + .bool => return "uint8_t", + .int => |info| { + assert(info.signedness == .unsigned); + return switch (info.bits) { + 8 => "uint8_t", + 16 => "uint16_t", + 32 => "uint32_t", + 64 => "uint64_t", + 128 => "tb_uint128_t", + else => @compileError("invalid int type"), + }; + }, + .optional => |info| switch (@typeInfo(info.child)) { + .pointer => return resolve_c_type(info.child), + else => @compileError("Unsupported optional type: " ++ @typeName(Type)), + }, + .pointer => |info| { + assert(info.size != .slice); + assert(!info.is_allowzero); + + inline for (type_mappings) |type_mapping| { + const ZigType = type_mapping[0]; + const c_name = type_mapping[1]; + + if (info.child == ZigType) { + const prefix = if (@typeInfo(ZigType) == .@"struct") "struct " else ""; + return prefix ++ c_name ++ "*"; + } + } + + return comptime resolve_c_type(info.child) ++ "*"; + }, + .void, .@"opaque" => return "void", + else => @compileError("Unhandled type: " ++ @typeName(Type)), + } +} + +fn emit_enum( + buffer: *std.ArrayList(u8), + comptime Type: type, + comptime type_info: anytype, + comptime c_name: []const u8, + comptime skip_fields: []const []const u8, +) !void { + var suffix_pos = std.mem.lastIndexOfScalar(u8, c_name, '_').?; + if (std.mem.count(u8, c_name, "_") == 1) suffix_pos = c_name.len; + + try buffer.writer().print("typedef enum {s} {{\n", .{c_name}); + + inline for (type_info.fields, 0..) |field, i| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + comptime var skip = false; + inline for (skip_fields) |sf| { + skip = skip or comptime std.mem.eql(u8, sf, field.name); + } + + if (!skip) { + const field_name = stdx.to_case(field.name, .UPPER_CASE); + if (@typeInfo(Type) == .@"enum") { + const int_value = @intFromEnum(@field(Type, field.name)); + try buffer.writer().print(" {s}_{s} = {s},\n", .{ + c_name[0..suffix_pos], + field_name, + if (int_value == std.math.maxInt(@TypeOf(int_value))) + std.fmt.comptimePrint("0x{X}", .{int_value}) + else + std.fmt.comptimePrint("{}", .{int_value}), + }); + } else { + // Packed structs. + try buffer.writer().print(" {s}_{s} = 1 << {},\n", .{ + c_name[0..suffix_pos], + field_name, + i, + }); + } + } + } + + try buffer.writer().print("}} {s};\n\n", .{c_name}); +} + +fn emit_struct( + buffer: *std.ArrayList(u8), + comptime type_info: anytype, + comptime c_name: []const u8, +) !void { + try buffer.writer().print("typedef struct {s} {{\n", .{c_name}); + + inline for (type_info.fields) |field| { + try buffer.writer().print(" {s} {s}", .{ + resolve_c_type(field.type), + field.name, + }); + + switch (@typeInfo(field.type)) { + .array => |array| try buffer.writer().print("[{d}]", .{array.len}), + else => {}, + } + + try buffer.writer().print(";\n", .{}); + } + + try buffer.writer().print("}} {s};\n\n", .{c_name}); +} + +pub fn main() !void { + @setEvalBranchQuota(100_000); + + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + + const allocator = arena.allocator(); + + var buffer = std.ArrayList(u8).init(allocator); + try buffer.writer().print( + \\ ////////////////////////////////////////////////////////// + \\ // This file was auto-generated by tb_client_header.zig // + \\ // Do not manually modify. // + \\ ////////////////////////////////////////////////////////// + \\ + \\#ifndef TB_CLIENT_H + \\#define TB_CLIENT_H + \\ + \\#ifdef __cplusplus + \\extern "C" {{ + \\#endif + \\ + \\#include + \\#include + \\#include + \\ + \\typedef __uint128_t tb_uint128_t; + \\ + \\ + , .{}); + + // Emit C type declarations. + inline for (type_mappings) |type_mapping| { + const ZigType = type_mapping[0]; + const c_name = type_mapping[1]; + if (type_mapping.len == 3) { + const comments: []const u8 = type_mapping[2]; + try buffer.writer().print(comments, .{}); + try buffer.writer().print("\n", .{}); + } + + switch (@typeInfo(ZigType)) { + .@"struct" => |info| switch (info.layout) { + .auto => @compileError("Invalid C struct type: " ++ @typeName(ZigType)), + .@"packed" => try emit_enum(&buffer, ZigType, info, c_name, &.{"padding"}), + .@"extern" => try emit_struct(&buffer, info, c_name), + }, + .@"enum" => |info| { + comptime var skip: []const []const u8 = &.{}; + if (ZigType == exports.tb_operation) { + skip = &.{ "reserved", "root", "register" }; + } + + try emit_enum(&buffer, ZigType, info, c_name, skip); + }, + else => try buffer.writer().print("typedef {s} {s}; \n\n", .{ + resolve_c_type(ZigType), + c_name, + }), + } + } + + // Emit C function declarations. + // TODO: use `std.meta.declaractions` and generate with pub + export functions. + // Zig 0.9.1 has `decl.data.Fn.arg_names` but it's currently/incorrectly a zero-sized slice. + try buffer.writer().print( + \\// Per-client callback invoked every time a `tb_client_submit` completes or is canceled. + \\// Use `packet->userdata` to identify the specific submission. + \\// `result` is null iff `packet->status != TB_PACKET_OK` + \\// `result` is only valid for the duration of the callback itself. + \\typedef void (*tb_completion_t)( + \\ uintptr_t userdata, + \\ tb_packet_t* packet, + \\ uint64_t timestamp, + \\ const uint8_t *result, // nullable + \\ uint32_t result_size + \\); + \\ + \\// Initialize a new TigerBeetle client which connects to the addresses provided and + \\// completes submitted packets by invoking the callback with the given context. + \\TB_INIT_STATUS tb_client_init( + \\ tb_client_t *client_out, + \\ // 128-bit unsigned integer represented as a 16-byte little-endian array. + \\ const uint8_t cluster_id[16], + \\ const char *address_ptr, + \\ uint32_t address_len, + \\ uintptr_t completion_ctx, + \\ tb_completion_t completion_callback + \\); + \\ + \\// Initialize a new TigerBeetle client that echoes back any submitted data. + \\TB_INIT_STATUS tb_client_init_echo( + \\ tb_client_t *client_out, + \\ // 128-bit unsigned integer represented as a 16-byte little-endian array. + \\ const uint8_t cluster_id[16], + \\ const char *address_ptr, + \\ uint32_t address_len, + \\ uintptr_t completion_ctx, + \\ tb_completion_t completion_callback + \\); + \\ + \\// Retrieve the parameters initially passed to `tb_client_init` or `tb_client_init_echo`. + \\// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was + \\// not initialized or has already been closed. + \\TB_CLIENT_STATUS tb_client_init_parameters( + \\ tb_client_t* client, + \\ tb_init_parameters_t* init_parameters_out + \\); + \\ + \\// Retrieve the callback context initially passed to `tb_client_init` or `tb_client_init_echo`. + \\// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was + \\// not initialized or has already been closed. + \\TB_CLIENT_STATUS tb_client_completion_context( + \\ tb_client_t* client, + \\ uintptr_t* completion_ctx_out + \\); + \\ + \\// Submit a packet with its `operation`, `data`, and `data_size` fields set. + \\// Once completed, `completion_callback` will be invoked with `completion_ctx` + \\// and the given packet on the `tb_client` thread (separate from the caller's thread). + \\// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was + \\// not initialized or has already been closed. + \\TB_CLIENT_STATUS tb_client_submit( + \\ tb_client_t *client, + \\ tb_packet_t *packet + \\); + \\ + \\// Closes the client, causing any previously submitted packets to be completed with + \\// `TB_PACKET_CLIENT_SHUTDOWN` before freeing any allocated client resources from init. + \\// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was + \\// not initialized or has already been closed. + \\TB_CLIENT_STATUS tb_client_deinit( + \\ tb_client_t *client + \\); + \\ + \\// Registers or unregisters the application log callback. + \\TB_REGISTER_LOG_CALLBACK_STATUS tb_client_register_log_callback( + \\ void (*callback)(TB_LOG_LEVEL, const uint8_t*, uint32_t), + \\ bool debug + \\); + \\ + \\ + , .{}); + + try buffer.writer().print( + \\#ifdef __cplusplus + \\}} // extern "C" + \\#endif + \\ + \\#endif // TB_CLIENT_H + \\ + , .{}); + + try std.io.getStdOut().writeAll(buffer.items); +} diff --git a/ocam/src/clients/c/tb_client_header_test.zig b/ocam/src/clients/c/tb_client_header_test.zig new file mode 100644 index 00000000..7b37586e --- /dev/null +++ b/ocam/src/clients/c/tb_client_header_test.zig @@ -0,0 +1,131 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const exports = @import("tb_client.zig").exports; +const c = @cImport(@cInclude("tb_client.h")); +const stdx = @import("stdx"); + +fn to_snakecase(comptime input: []const u8) []const u8 { + comptime var output: []const u8 = &.{}; + inline for (input, 0..) |char, i| { + const is_uppercase = (char >= 'A') and (char <= 'Z'); + if (is_uppercase and i > 0) output = "_" ++ output; + output = output ++ &[_]u8{char}; + } + return output; +} + +test "valid tb_client.h" { + @setEvalBranchQuota(20_000); + + comptime for (.{ + .{ exports.tb_account_t, "tb_account_t" }, + .{ exports.tb_transfer_t, "tb_transfer_t" }, + .{ exports.tb_account_flags, "TB_ACCOUNT_FLAGS" }, + .{ exports.tb_transfer_flags, "TB_TRANSFER_FLAGS" }, + .{ exports.tb_create_account_status, "TB_CREATE_ACCOUNT_STATUS" }, + .{ exports.tb_create_transfer_status, "TB_CREATE_TRANSFER_STATUS" }, + .{ exports.tb_create_account_result_t, "tb_create_account_result_t" }, + .{ exports.tb_create_transfer_result_t, "tb_create_transfer_result_t" }, + .{ exports.tb_account_filter_t, "tb_account_filter_t" }, + .{ exports.tb_account_filter_flags, "TB_ACCOUNT_FILTER_FLAGS" }, + .{ exports.tb_account_balance_t, "tb_account_balance_t" }, + + .{ u128, "tb_uint128_t" }, + .{ exports.tb_client_t, "tb_client_t" }, + .{ exports.tb_packet_t, "tb_packet_t" }, + .{ exports.tb_init_status, "TB_INIT_STATUS" }, + .{ exports.tb_client_status, "TB_CLIENT_STATUS" }, + .{ exports.tb_packet_status, "TB_PACKET_STATUS" }, + .{ exports.tb_operation, "TB_OPERATION" }, + .{ exports.tb_register_log_callback_status, "TB_REGISTER_LOG_CALLBACK_STATUS" }, + .{ exports.tb_log_level, "TB_LOG_LEVEL" }, + }) |c_export| { + const ty: type = c_export[0]; + const c_type_name = @as([]const u8, c_export[1]); + const c_type: type = @field(c, c_type_name); + + switch (@typeInfo(ty)) { + .int => assert(ty == c_type), + .pointer => assert(@sizeOf(ty) == @sizeOf(c_type)), + .@"enum" => { + const prefix_offset = std.mem.lastIndexOfScalar(u8, c_type_name, '_').?; + var c_enum_prefix: []const u8 = c_type_name[0 .. prefix_offset + 1]; + assert(c_type == c_uint); + + // TB_STATUS and TB_OPERATION are special cases in naming + if (std.mem.eql(u8, c_type_name, "TB_STATUS") or + std.mem.eql(u8, c_type_name, "TB_OPERATION")) + { + c_enum_prefix = c_type_name ++ "_"; + } + + // Compare the enum int values in C to the enum int values in Zig. + for (std.meta.fields(ty)) |field| { + if (std.mem.startsWith(u8, field.name, "deprecated_")) continue; + const c_enum_field = stdx.to_case(to_snakecase(field.name), .UPPER_CASE); + const c_value = @field(c, c_enum_prefix ++ c_enum_field); + + const zig_value = @intFromEnum(@field(ty, field.name)); + assert(zig_value == c_value); + } + }, + .@"struct" => |type_info| switch (type_info.layout) { + .auto => @compileError("struct must be extern or packed to be used in C"), + .@"packed" => { + const prefix_offset = std.mem.lastIndexOfScalar(u8, c_type_name, '_').?; + const c_enum_prefix = c_type_name[0 .. prefix_offset + 1]; + assert(c_type == c_uint); + + for (std.meta.fields(ty)) |field| { + if (!std.mem.eql(u8, field.name, "padding")) { + // Get the bit value in the C enum. + const c_enum_field = + stdx.to_case(to_snakecase(field.name), .UPPER_CASE); + const c_value = @field(c, c_enum_prefix ++ c_enum_field); + + // Compare the bit value to the packed struct's field. + var instance = std.mem.zeroes(ty); + @field(instance, field.name) = true; + assert(@as(type_info.backing_integer.?, @bitCast(instance)) == c_value); + } + } + }, + .@"extern" => { + // Ensure structs are effectively the same. + assert(@sizeOf(ty) == @sizeOf(c_type)); + if (@alignOf(ty) != @alignOf(c_type)) { + @compileLog(ty, c_type); + } + assert(@alignOf(ty) == @alignOf(c_type)); + + for (std.meta.fields(ty)) |field| { + // In C, packed structs and enums are replaced with integers. + var field_type = field.type; + switch (@typeInfo(field_type)) { + .@"struct" => |info| { + assert(info.layout == .@"packed"); + assert(@sizeOf(field_type) <= @sizeOf(u128)); + field_type = std.meta.Int(.unsigned, @bitSizeOf(field_type)); + }, + .@"enum" => |info| field_type = info.tag_type, + .bool => field_type = u8, + else => {}, + } + + // In C, pointers are opaque so we compare only the field sizes, + const c_field_type = @TypeOf(@field(@as(c_type, undefined), field.name)); + switch (@typeInfo(c_field_type)) { + .pointer => |info| { + assert(info.size == .c); + assert(@sizeOf(c_field_type) == @sizeOf(field_type)); + }, + else => assert(c_field_type == field_type), + } + } + }, + }, + else => |i| @compileLog("TODO", i), + } + }; +} diff --git a/ocam/src/clients/c/test.zig b/ocam/src/clients/c/test.zig new file mode 100644 index 00000000..07ca0ab1 --- /dev/null +++ b/ocam/src/clients/c/test.zig @@ -0,0 +1,455 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const testing = std.testing; + +const tb_client = @import("tb_client.zig"); +const stdx = tb_client.vsr.stdx; +const constants = @import("../../constants.zig"); +const tb = tb_client.vsr.tigerbeetle; + +const Mutex = std.Thread.Mutex; +const Condition = std.Thread.Condition; + +fn RequestContextType(comptime request_size_max: comptime_int) type { + return struct { + const RequestContext = @This(); + + completion: *Completion, + packet: tb_client.Packet, + sent_data: [request_size_max]u8 = undefined, + sent_data_size: u32, + reply: ?struct { + tb_context: usize, + tb_packet: *tb_client.Packet, + timestamp: u64, + result: ?[request_size_max]u8, + result_size: u32, + } = null, + + pub fn on_complete( + tb_context: usize, + tb_packet: *tb_client.Packet, + timestamp: u64, + result: ?[*]const u8, + result_size: u32, + ) callconv(.c) void { + var self: *RequestContext = @ptrCast(@alignCast(tb_packet.*.user_data.?)); + defer self.completion.complete(); + + self.reply = .{ + .tb_context = tb_context, + .tb_packet = tb_packet, + .timestamp = timestamp, + .result = if (result != null and result_size > 0) blk: { + // Copy the message's body to the context buffer: + assert(result_size <= request_size_max); + var writable: [request_size_max]u8 = undefined; + stdx.copy_disjoint(.inexact, u8, &writable, result.?[0..result_size]); + break :blk writable; + } else null, + .result_size = result_size, + }; + } + }; +} + +// Notifies the main thread when all pending requests are completed. +const Completion = struct { + pending: usize, + mutex: Mutex = .{}, + cond: Condition = .{}, + + pub fn complete(self: *Completion) void { + self.mutex.lock(); + defer self.mutex.unlock(); + + assert(self.pending > 0); + self.pending -= 1; + self.cond.signal(); + } + + pub fn wait_pending(self: *Completion) void { + self.mutex.lock(); + defer self.mutex.unlock(); + + while (self.pending > 0) + self.cond.wait(&self.mutex); + } +}; + +// Consistency of U128 across Zig and the language clients. +// It must be kept in sync with all platforms. +test "u128 consistency test" { + const decimal: u128 = 214850178493633095719753766415838275046; + const binary = [16]u8{ + 0xe6, 0xe5, 0xe4, 0xe3, 0xe2, 0xe1, + 0xd2, 0xd1, 0xc2, 0xc1, 0xb2, 0xb1, + 0xa4, 0xa3, 0xa2, 0xa1, + }; + const pair: extern struct { lower: u64, upper: u64 } = .{ + .lower = 15119395263638463974, + .upper = 11647051514084770242, + }; + + try testing.expectEqual(decimal, @as(u128, @bitCast(binary))); + try testing.expectEqual(binary, @as([16]u8, @bitCast(decimal))); + + try testing.expectEqual(decimal, @as(u128, @bitCast(pair))); + try testing.expectEqual(pair, @as(@TypeOf(pair), @bitCast(decimal))); +} + +// When initialized with `init_echo`, the tb_client uses a test context that echoes +// the data back without creating an actual client or connecting to a cluster. +// +// This same test should be implemented by all the target programming languages, asserting that: +// 1. the tb_client api was initialized correctly. +// 2. the application can submit messages and receive replies through the completion callback. +// 3. the data marshaling is correct, and exactly the same data sent was received back. +test "tb_client echo" { + // Using the create_accounts operation for this test. + const RequestContext = RequestContextType(constants.message_body_size_max); + + // Test multiple operations to prevent all requests from ending up in the same batch. + // Query operations are not included because the `limit` field cannot be randomized. + const operations = [_]tb_client.Operation{ + tb_client.Operation.create_accounts, + tb_client.Operation.create_transfers, + tb_client.Operation.lookup_accounts, + tb_client.Operation.lookup_transfers, + }; + + // Initializing an echo client for testing purposes. + // We ensure that the retry mechanism is being tested + // by allowing more simultaneous packets than "client_request_queue_max". + var client: tb_client.ClientInterface = undefined; + const cluster_id: u128 = 0; + const address = "3000"; + const concurrency_max: u32 = constants.client_request_queue_max * operations.len; + const tb_context: usize = 42; + try tb_client.init_echo( + testing.allocator, + &client, + cluster_id, + address, + tb_context, + RequestContext.on_complete, + ); + + defer client.deinit() catch unreachable; + + var prng = stdx.PRNG.from_seed(tb_context); + + const requests: []RequestContext = try testing.allocator.alloc( + RequestContext, + concurrency_max, + ); + defer testing.allocator.free(requests); + + // Repeating the same test multiple times to stress the + // cycle of message exhaustion followed by completions. + const repetitions_max = 100; + var repetition: u32 = 0; + var operation_current: ?tb_client.Operation = null; + while (repetition < repetitions_max) : (repetition += 1) { + var completion = Completion{ .pending = concurrency_max }; + + const operation: tb_client.Operation = operation: { + if (operation_current == null or + // Sometimes repeat the same operation for testing multi-batch. + prng.boolean()) + { + operation_current = operations[prng.index(operations)]; + } + break :operation operation_current.?; + }; + + const event_size: u32, const event_request_max: u32 = switch (operation) { + // All multi-batched operations require a minimum trailer size of one element: + .create_accounts => .{ + @sizeOf(tb.Account), + @divExact(constants.message_body_size_max, @sizeOf(tb.Account)) - 1, + }, + .create_transfers => .{ + @sizeOf(tb.Transfer), + @divExact(constants.message_body_size_max, @sizeOf(tb.Transfer)) - 1, + }, + .lookup_accounts => .{ + @sizeOf(u128), + @divExact(constants.message_body_size_max, @sizeOf(tb.Account)) - 1, + }, + .lookup_transfers => .{ + @sizeOf(u128), + @divExact(constants.message_body_size_max, @sizeOf(tb.Transfer)) - 1, + }, + else => unreachable, + }; + + // Submitting some random data to be echoed back: + for (requests) |*request| { + request.* = .{ + .packet = undefined, + .completion = &completion, + .sent_data_size = prng.range_inclusive( + u32, + 1, + event_request_max, + ) * event_size, + }; + prng.fill(request.sent_data[0..request.sent_data_size]); + + const packet = &request.packet; + packet.operation = @intFromEnum(operation); + packet.user_data = request; + packet.data = &request.sent_data; + packet.data_size = request.sent_data_size; + packet.user_tag = 0; + packet.status = .ok; + + try client.submit(packet); + } + + // Waiting until the c_client thread has processed all submitted requests: + completion.wait_pending(); + + // Checking if the received echo matches the data we sent: + for (requests) |*request| { + try testing.expect(request.reply != null); + try testing.expectEqual(tb_context, request.reply.?.tb_context); + try testing.expectEqual(tb_client.PacketStatus.ok, request.packet.status); + try testing.expectEqual( + @intFromPtr(&request.packet), + @intFromPtr(request.reply.?.tb_packet), + ); + try testing.expect(request.reply.?.result != null); + try testing.expectEqual(request.sent_data_size, request.reply.?.result_size); + + const sent_data = request.sent_data[0..request.sent_data_size]; + const reply = request.reply.?.result.?[0..request.reply.?.result_size]; + try testing.expectEqualSlices(u8, sent_data, reply); + } + } +} + +// Asserts the validation rules associated with the `init*` functions. +test "tb_client init" { + const assert_status = struct { + pub fn action( + addresses: []const u8, + expected: tb_client.InitError!void, + ) !void { + var client_out: tb_client.ClientInterface = undefined; + const cluster_id: u128 = 0; + const tb_context: usize = 0; + const result = tb_client.init_echo( + testing.allocator, + &client_out, + cluster_id, + addresses, + tb_context, + RequestContextType(0).on_complete, + ); + defer if (!std.meta.isError(result)) client_out.deinit() catch unreachable; + + try testing.expectEqual(expected, result); + } + }.action; + + // Valid addresses should return TB_STATUS_SUCCESS: + try assert_status("3000", {}); + try assert_status("127.0.0.1", {}); + try assert_status("127.0.0.1:3000", {}); + try assert_status("3000,3001,3002", {}); + try assert_status("127.0.0.1,127.0.0.2,172.0.0.3", {}); + try assert_status("127.0.0.1:3000,127.0.0.1:3002,127.0.0.1:3003", {}); + + // Invalid or empty address should return "TB_STATUS_ADDRESS_INVALID": + try assert_status("invalid", error.AddressInvalid); + try assert_status("", error.AddressInvalid); + + // More addresses than "replicas_max" should return "TB_STATUS_ADDRESS_LIMIT_EXCEEDED": + try assert_status( + ("3000," ** constants.replicas_max) ++ "3001", + error.AddressLimitExceeded, + ); + + // All other status are not testable. +} + +// Asserts the validation rules associated with the client status. +test "tb_client client status" { + const RequestContext = RequestContextType(0); + var client: tb_client.ClientInterface = undefined; + const cluster_id: u128 = 0; + const addresses = "3000"; + const tb_context: usize = 0; + try tb_client.init_echo( + testing.allocator, + &client, + cluster_id, + addresses, + tb_context, + RequestContext.on_complete, + ); + errdefer client.deinit() catch unreachable; + + var completion = Completion{ .pending = 1 }; + var request = RequestContext{ + .packet = undefined, + .completion = &completion, + .sent_data_size = 0, + }; + + const packet = &request.packet; + packet.operation = @intFromEnum(tb_client.Operation.create_accounts); + packet.user_data = &request; + packet.data = null; + packet.data_size = 0; + packet.user_tag = 0; + packet.status = .ok; + + // Sanity test to verify that the client is working. + try client.submit(packet); + completion.wait_pending(); + + // Deinit the client. + try client.deinit(); + + // Cannot submit after deinit. + try testing.expectError(error.ClientInvalid, client.submit(packet)); + + // Multiple deinit calls are safe. + try testing.expectError(error.ClientInvalid, client.deinit()); +} + +// Asserts the validation rules associated with the "PacketStatus" enum. +test "tb_client PacketStatus" { + const RequestContext = RequestContextType(constants.message_body_size_max); + + var client_out: tb_client.ClientInterface = undefined; + const cluster_id: u128 = 0; + const addresses = "3000"; + const tb_context: usize = 42; + try tb_client.init_echo( + testing.allocator, + &client_out, + cluster_id, + addresses, + tb_context, + RequestContext.on_complete, + ); + defer client_out.deinit() catch unreachable; + + const assert_result = struct { + // Asserts if the packet's status matches the expected status + // for a given operation and request_size. + pub fn action( + client: *tb_client.ClientInterface, + operation: u8, + request_size: u32, + packet_status_expected: tb_client.PacketStatus, + ) !void { + var completion = Completion{ .pending = 1 }; + var request = RequestContext{ + .packet = undefined, + .completion = &completion, + .sent_data_size = request_size, + }; + + const packet = &request.packet; + packet.operation = operation; + packet.user_data = &request; + packet.data = &request.sent_data; + packet.data_size = request_size; + packet.user_tag = 0; + packet.status = .ok; + + try client.submit(packet); + + completion.wait_pending(); + + try testing.expect(request.reply != null); + try testing.expectEqual(tb_context, request.reply.?.tb_context); + try testing.expectEqual( + @intFromPtr(&request.packet), + @intFromPtr(request.reply.?.tb_packet), + ); + try testing.expectEqual(packet_status_expected, request.packet.status); + } + }.action; + + // Messages larger than constants.message_body_size_max should return "too_much_data": + try assert_result( + &client_out, + @intFromEnum(tb_client.Operation.create_transfers), + constants.message_body_size_max + @sizeOf(tb_client.exports.tb_transfer_t), + .too_much_data, + ); + + // All reserved and unknown operations should return "invalid_operation": + try assert_result( + &client_out, + 0, + @sizeOf(u128), + .invalid_operation, + ); + try assert_result( + &client_out, + 1, + @sizeOf(u128), + .invalid_operation, + ); + try assert_result( + &client_out, + 99, + @sizeOf(u128), + .invalid_operation, + ); + try assert_result( + &client_out, + 254, + @sizeOf(u128), + .invalid_operation, + ); + + // Messages not a multiple of the event size + // should return "invalid_data_size": + try assert_result( + &client_out, + @intFromEnum(tb_client.Operation.create_transfers), + @sizeOf(tb_client.exports.tb_transfer_t) - 1, + .invalid_data_size, + ); + try assert_result( + &client_out, + @intFromEnum(tb_client.Operation.lookup_transfers), + @sizeOf(u128) + 1, + .invalid_data_size, + ); + try assert_result( + &client_out, + @intFromEnum(tb_client.Operation.lookup_accounts), + @sizeOf(u128) * 2.5, + .invalid_data_size, + ); + + // Messages with zero length or multiple of the event size are valid. + try assert_result( + &client_out, + @intFromEnum(tb_client.Operation.create_accounts), + 0, + .ok, + ); + try assert_result( + &client_out, + @intFromEnum(tb_client.Operation.create_accounts), + @sizeOf(tb_client.exports.tb_account_t), + .ok, + ); + try assert_result( + &client_out, + @intFromEnum(tb_client.Operation.create_accounts), + @sizeOf(tb_client.exports.tb_account_t) * 2, + .ok, + ); +} diff --git a/ocam/src/clients/docs_samples.zig b/ocam/src/clients/docs_samples.zig new file mode 100644 index 00000000..095cfb56 --- /dev/null +++ b/ocam/src/clients/docs_samples.zig @@ -0,0 +1,157 @@ +const Sample = @import("./docs_types.zig").Sample; + +pub const samples = [_]Sample{ + .{ + .proper_name = "Basic", + .directory = "basic", + .short_description = "Create two accounts and transfer an amount between them.", + .long_description = + \\## 1. Create accounts + \\ + \\This project starts by creating two accounts (`1` and `2`). + \\ + \\## 2. Create a transfer + \\ + \\Then it transfers `10` of an amount from account `1` to + \\account `2`. + \\ + \\## 3. Fetch and validate account balances + \\ + \\Then it fetches both accounts, checks they both exist, and + \\checks that **account `1`** has: + \\ * `debits_posted = 10` + \\ * and `credits_posted = 0` + \\ + \\And that **account `2`** has: + \\ * `debits_posted= 0` + \\ * and `credits_posted = 10` + , + }, + .{ + .proper_name = "Two-Phase Transfer", + .directory = "two-phase", + .short_description = + \\Create two accounts and start a pending transfer between + \\them, then post the transfer. + , + .long_description = + \\## 1. Create accounts + \\ + \\This project starts by creating two accounts (`1` and `2`). + \\ + \\## 2. Create pending transfer + \\ + \\Then it begins a + \\pending transfer of `500` of an amount from account `1` to + \\account `2`. + \\ + \\## 3. Fetch and validate pending account balances + \\ + \\Then it fetches both accounts and validates that **account `1`** has: + \\ * `debits_posted = 0` + \\ * `credits_posted = 0` + \\ * `debits_pending = 500` + \\ * and `credits_pending = 0` + \\ + \\And that **account `2`** has: + \\ * `debits_posted = 0` + \\ * `credits_posted = 0` + \\ * `debits_pending = 0` + \\ * and `credits_pending = 500` + \\ + \\(This is because a pending + \\transfer only affects **pending** credits and debits on accounts, + \\not **posted** credits and debits.) + \\ + \\## 4. Post pending transfer + \\ + \\Then it creates a second transfer that marks the first + \\transfer as posted. + \\ + \\## 5. Fetch and validate transfers + \\ + \\Then it fetches both transfers, validates + \\that the two transfers exist, validates that the first + \\transfer had (and still has) a `pending` flag, and validates + \\that the second transfer had (and still has) a + \\`post_pending_transfer` flag. + \\ + \\## 6. Fetch and validate final account balances + \\ + \\Finally, it fetches both accounts, validates that both exist, + \\and checks that credits and debits for both accounts are now + \\*posted*, not pending. + \\ + \\Specifically, that **account `1`** has: + \\ * `debits_posted = 500` + \\ * `credits_posted = 0` + \\ * `debits_pending = 0` + \\ * and `credits_pending = 0` + \\ + \\And that **account `2`** has: + \\ * `debits_posted = 0` + \\ * `credits_posted = 500` + \\ * `debits_pending = 0` + \\ * and `credits_pending = 0` + , + }, + .{ + .proper_name = "Many Two-Phase Transfers", + .directory = "two-phase-many", + .short_description = + \\Create two accounts and start a number of pending transfers + \\between them, posting and voiding alternating transfers. + , + .long_description = + \\## 1. Create accounts + \\ + \\This project starts by creating two accounts (`1` and `2`). + \\ + \\## 2. Create pending transfers + \\ + \\Then it begins 5 pending transfers of amounts `100` to + \\`500`, incrementing by `100` for each transfer. + \\ + \\## 3. Fetch and validate pending account balances + \\ + \\Then it fetches both accounts and validates that **account `1`** has: + \\ * `debits_posted = 0` + \\ * `credits_posted = 0` + \\ * `debits_pending = 1500` + \\ * and `credits_pending = 0` + \\ + \\And that **account `2`** has: + \\ * `debits_posted = 0` + \\ * `credits_posted = 0` + \\ * `debits_pending = 0` + \\ * and `credits_pending = 1500` + \\ + \\(This is because a pending transfer only affects **pending** + \\credits and debits on accounts, not **posted** credits and + \\debits.) + \\ + \\## 4. Post and void alternating transfers + \\ + \\Then it alternatively posts and voids each transfer, + \\checking account balances after each transfer. + \\ + \\## 6. Fetch and validate final account balances + \\ + \\Finally, it fetches both accounts, validates that both exist, + \\and checks that credits and debits for both accounts are now + \\solely *posted*, not pending. + \\ + \\Specifically, that **account `1`** has: + \\ * `debits_posted = 900` + \\ * `credits_posted = 0` + \\ * `debits_pending = 0` + \\ * and `credits_pending = 0` + \\ + \\And that **account `2`** has: + \\ * `debits_posted = 0` + \\ * `credits_posted = 900` + \\ * `debits_pending = 0` + \\ * and `credits_pending = 0` + , + }, +}; diff --git a/ocam/src/clients/docs_types.zig b/ocam/src/clients/docs_types.zig new file mode 100644 index 00000000..58185f2f --- /dev/null +++ b/ocam/src/clients/docs_types.zig @@ -0,0 +1,90 @@ +// The purpose of these types is to help in reading this doc, not +// because the types matter. +const String = []const u8; + +// All Code variables are potentially tested and run in CI. +const Code = []const u8; + +// All Markdown strings are never tested and run in CI. +const Markdown = []const u8; + +pub const Docs = struct { + // Name of the directory (relative to /src/clients) + directory: String, + + // Package name (i.e. tigerbeetle-go, tigerbeetle-node, etc.) + name: String, + + // Name for syntax highlighting (i.e. javascript for node, go for go, etc.) + markdown_name: String, + + // File extension without dot (i.e. js, go, etc.) + extension: String, + + // For the title of the page on the docs site. + proper_name: String, + + // Introduction to the client. Links to docs or build badges or + // whatnot. + description: Markdown, + + // Any libraries or languages and their required versions for + // using, not necessarily hacking on, this client. + prerequisites: Markdown, + + // If you need an additional project file like pom.xml or + // package.json. Leave blank if not needed. + project_file_name: String, + // The actual contents of the file. Leave blank if not needed. + project_file: Code, + + // If you need to override the default name of test.${extension} + // such as when file names have meaning (i.e. Java). + test_file_name: String, + + // Any setup needed for a project before compiling and running + // such as `go mod init myProject && go mod tidy` or `npm install + // tigerbeetle-node`. + install_commands: Code, + + // Commands for building and running code. + run_commands: Code, + + // If you want to include links to examples. + examples: Markdown, + + client_object_documentation: Markdown, + + create_accounts_documentation: Markdown, + + create_accounts_errors_documentation: Markdown, + + account_flags_documentation: Markdown, + + create_transfers_documentation: Markdown, + + create_transfers_errors_documentation: Markdown, + + // Good example of using batches to create transfers. + // Bad example of not using batches well to create transfers. + + transfer_flags_documentation: Markdown, + + // Optional prefix if test code must be in a certain directory + // (e.g. Java and `src/main/java`). + test_source_path: String, +}; + +pub const Sample = struct { + // Capitalized name of the sample program + proper_name: String, + + // e.g. `basic`, `two-phase`, etc. + directory: String, + + // For use in the language primary README + short_description: String, + + // For use as the introduction on the individual sample README + long_description: String, +}; diff --git a/ocam/src/clients/dotnet/.editorconfig b/ocam/src/clients/dotnet/.editorconfig new file mode 100644 index 00000000..fd3c8885 --- /dev/null +++ b/ocam/src/clients/dotnet/.editorconfig @@ -0,0 +1,10 @@ +# By default, `dotnet format` uses `\r\n` line endings on Windows. To override this behavior, we +# need this editorconfig file. +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset=utf-8 +indent_style=space +indent_size=4 diff --git a/ocam/src/clients/dotnet/.gitignore b/ocam/src/clients/dotnet/.gitignore new file mode 100644 index 00000000..57a02192 --- /dev/null +++ b/ocam/src/clients/dotnet/.gitignore @@ -0,0 +1,8 @@ +.vs/ +bin/ +obj/ +cobertura/ +TestResults/ +TigerBeetle/runtimes/ +TigerBeetle.Tests/coverage.*.json +*tigerbeetle.benchmark diff --git a/ocam/src/clients/dotnet/LICENSE.txt b/ocam/src/clients/dotnet/LICENSE.txt new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/ocam/src/clients/dotnet/LICENSE.txt @@ -0,0 +1,177 @@ + + 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 diff --git a/ocam/src/clients/dotnet/README.md b/ocam/src/clients/dotnet/README.md new file mode 100644 index 00000000..2ee397dc --- /dev/null +++ b/ocam/src/clients/dotnet/README.md @@ -0,0 +1,757 @@ + +# tigerbeetle-dotnet + +The TigerBeetle client for .NET. + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* .NET >= 8.0. + +And if you do not already have NuGet.org as a package +source, make sure to add it: + +```console +dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org +``` + +## Setup + +First, create a directory for your project and `cd` into the directory. + +Then, install the TigerBeetle client: + +```console +dotnet new console +dotnet add package tigerbeetle +``` + +Now, create `Program.cs` and copy this into it: + +```cs +using System; +using TigerBeetle; + +// Validate import works. +Console.WriteLine("SUCCESS"); +``` + +Finally, build and run: + +```console +dotnet run +``` + +Now that all prerequisites and dependencies are correctly set +up, let's dig into using TigerBeetle. + +## Sample projects + +This document is primarily a reference guide to +the client. Below are various sample projects demonstrating +features of TigerBeetle. + +* [Basic](/src/clients/dotnet/samples/basic/): Create two accounts and transfer an amount between them. +* [Two-Phase Transfer](/src/clients/dotnet/samples/two-phase/): Create two accounts and start a pending transfer between +them, then post the transfer. +* [Many Two-Phase Transfers](/src/clients/dotnet/samples/two-phase-many/): Create two accounts and start a number of pending transfers +between them, posting and voiding alternating transfers. + +## Creating a Client + +A client is created with a cluster ID and replica +addresses for all replicas in the cluster. The cluster +ID and replica addresses are both chosen by the system that +starts the TigerBeetle cluster. + +Clients are thread-safe and a single instance should be shared +between multiple concurrent tasks. This allows events to be +[automatically batched](https://docs.tigerbeetle.com/coding/requests/#batching-events). + +Multiple clients are useful when connecting to more than +one TigerBeetle cluster. + +In this example the cluster ID is `0` and there is one +replica. The address is read from the `TB_ADDRESS` +environment variable and defaults to port `3000`. + +```cs +var tbAddress = Environment.GetEnvironmentVariable("TB_ADDRESS"); +var clusterID = UInt128.Zero; +var addresses = new[] { tbAddress != null ? tbAddress : "3000" }; +using (var client = new Client(clusterID, addresses)) +{ + // Use client +} +``` + +The following are valid addresses: +* `3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1:3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1` (interpreted as `127.0.0.1:3001`, `3001` is the default port) + +## Creating Accounts + +See details for account fields in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account). + +```cs +var accounts = new[] { + new Account + { + Id = ID.Create(), // TigerBeetle time-based ID. + UserData128 = 0, + UserData64 = 0, + UserData32 = 0, + Ledger = 1, + Code = 718, + Flags = AccountFlags.None, + Timestamp = 0, + }, +}; + +var accountResults = client.CreateAccounts(accounts); +// Results handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +The `UInt128` fields like `ID`, `UserData128`, `Amount` and +account balances have a few extension methods to make it easier +to convert 128-bit little-endian unsigned integers between +`BigInteger`, `byte[]`, and `Guid`. + +See the class [UInt128Extensions](/src/clients/dotnet/TigerBeetle/UInt128Extensions.cs) +for more details. + +### Account Flags + +The account flags value is a bitfield. See details for +these flags in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account#flags). + +To toggle behavior for an account, combine enum values stored in the +`AccountFlags` object with bitwise-or: + +* `AccountFlags.None` +* `AccountFlags.Linked` +* `AccountFlags.DebitsMustNotExceedCredits` +* `AccountFlags.CreditsMustNotExceedDebits` +* `AccountFlags.History` + +For example, to link two accounts where the first account +additionally has the `debits_must_not_exceed_credits` constraint: + +```cs +var account0 = new Account +{ + Id = 100, + Ledger = 1, + Code = 1, + Flags = AccountFlags.Linked | AccountFlags.DebitsMustNotExceedCredits, +}; +var account1 = new Account +{ + Id = 101, + Ledger = 1, + Code = 1, + Flags = AccountFlags.History, +}; + +var accountResults = client.CreateAccounts(new[] { account0, account1 }); +// Results handling omitted. +``` + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each account in the request batch: +- Successfully created accounts with the status + [`created`](https://docs.tigerbeetle.com/reference/requests/create_accounts#created) + return the timestamp assigned to the `Account` object. +- Already existing accounts with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists) + return the timestamp of the original existing object. +- Failed accounts return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_accounts reference](https://docs.tigerbeetle.com/reference/requests/create_accounts#status). + +```cs +var account0 = new Account +{ + Id = 102, + Ledger = 1, + Code = 1, + Flags = AccountFlags.None, +}; +var account1 = new Account +{ + Id = 103, + Ledger = 1, + Code = 1, + Flags = AccountFlags.None, +}; +var account2 = new Account +{ + Id = 104, + Ledger = 1, + Code = 1, + Flags = AccountFlags.None, +}; + +var accountResults = client.CreateAccounts(new[] { account0, account1, account2 }); +for (int i = 0; i < accountResults.Length; i++) +{ + switch (accountResults[i].Status) + { + case CreateAccountStatus.Created: + Console.WriteLine($"Batch account at {i} successfully created with timestamp {accountResults[i].Timestamp}."); + break; + case CreateAccountStatus.Exists: + Console.WriteLine($"Batch account at {i} already exists with timestamp {accountResults[i].Timestamp}."); + break; + default: + Console.WriteLine($"Batch account at {i} failed to create: {accountResults[i].Status}"); + break; + } +} +``` + +## Account Lookup + +Account lookup is batched, like account creation. Pass +in all IDs to fetch. The account for each matched ID is returned. + +If no account matches an ID, no object is returned for +that account. So the order of accounts in the response is +not necessarily the same as the order of IDs in the +request. You can refer to the ID field in the response to +distinguish accounts. + +```cs +Account[] accounts = client.LookupAccounts(new UInt128[] { 100, 101 }); +``` + +## Create Transfers + +This creates a journal entry between two accounts. + +See details for transfer fields in the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer). + +```cs +var transfers = new[] { + new Transfer + { + Id = ID.Create(), // TigerBeetle time-based ID. + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + UserData128 = 0, + UserData64 = 0, + UserData32 = 0, + Timeout = 0, + Ledger = 1, + Code = 1, + Flags = TransferFlags.None, + Timestamp = 0, + } +}; + +var transferResults = client.CreateTransfers(transfers); +// Results handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each transfer in the request batch: +- Successfully created transfers with the result + [`created`](https://docs.tigerbeetle.com/reference/requests/create_transfers#created) + return the timestamp assigned to the `Transfer` object. +- Already existing transfers with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists) + return the timestamp of the original existing object. +- Failed transfers return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_transfers reference](https://docs.tigerbeetle.com/reference/requests/create_transfers#status). + +```cs +var transfers = new[] { + new Transfer + { + Id = 1, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.None, + }, + new Transfer + { + Id = 2, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.None, + }, + new Transfer + { + Id = 3, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.None, + }, +}; + +var transferResults = client.CreateTransfers(transfers); +for (int i = 0; i < transferResults.Length; i++) +{ + switch (transferResults[i].Status) + { + case CreateTransferStatus.Created: + Console.WriteLine($"Batch transfer at {i} successfully created with timestamp {transferResults[i].Timestamp}."); + break; + case CreateTransferStatus.Exists: + Console.WriteLine($"Batch transfer at {i} already exists with timestamp {transferResults[i].Timestamp}."); + break; + default: + Console.WriteLine($"Batch transfer at {i} failed to create: {transferResults[i].Status}"); + break; + } +} +``` + +## Batching + +TigerBeetle performance is maximized when you batch +API requests. + +A client instance shared across multiple threads/tasks can automatically +batch concurrent requests, but the application must still send as many events +as possible in a single call. + +For example, if you insert 1 million transfers sequentially, one at a time, +the insert rate will be a *fraction* of the potential, because the client will +wait for a reply between each one. +Instead, **always batch as much as you can**. + +The maximum batch size is set in the TigerBeetle server. The default is 8189. + +```cs +var batch = new Transfer[] { }; // Array of transfer to create. +var BATCH_SIZE = 8189; +for (int firstIndex = 0; firstIndex < batch.Length; firstIndex += BATCH_SIZE) +{ + var lastIndex = firstIndex + BATCH_SIZE; + if (lastIndex > batch.Length) + { + lastIndex = batch.Length; + } + var transferResults = client.CreateTransfers(batch[firstIndex..lastIndex]); + // Results handling omitted. +} +``` + +### Queues and Workers + +If you are making requests to TigerBeetle from workers +pulling jobs from a queue, you can batch requests to +TigerBeetle by having the worker act on multiple jobs from +the queue at once rather than one at a time. i.e. pulling +multiple jobs from the queue rather than just one. + +## Transfer Flags + +The transfer `flags` value is a bitfield. See details for these flags in +the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer#flags). + +To toggle behavior for an account, combine enum values stored in the +`TransferFlags` object with bitwise-or: + +* `TransferFlags.None` +* `TransferFlags.Linked` +* `TransferFlags.Pending` +* `TransferFlags.PostPendingTransfer` +* `TransferFlags.VoidPendingTransfer` + +For example, to link `transfer0` and `transfer1`: + +```cs +var transfer0 = new Transfer +{ + Id = 4, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Linked, +}; +var transfer1 = new Transfer +{ + Id = 5, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.None, +}; + +var transferResults = client.CreateTransfers(new[] { transfer0, transfer1 }); +// Results handling omitted. +``` + +### Two-Phase Transfers + +Two-phase transfers are supported natively by toggling the appropriate +flag. TigerBeetle will then adjust the `credits_pending` and +`debits_pending` fields of the appropriate accounts. A corresponding +post pending transfer then needs to be sent to post or void the +transfer. + +#### Post a Pending Transfer + +With `flags` set to `post_pending_transfer`, +TigerBeetle will post the transfer. TigerBeetle will atomically roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and apply them to the `debits_posted` and +`credits_posted` balances. + +```cs +var transfer0 = new Transfer +{ + Id = 6, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Pending, +}; + +var transferResults = client.CreateTransfers(new[] { transfer0 }); +// Results handling omitted. + +var transfer1 = new Transfer +{ + Id = 7, + // Post the entire pending amount. + Amount = Transfer.AmountMax, + PendingId = 6, + Flags = TransferFlags.PostPendingTransfer, +}; + +transferResults = client.CreateTransfers(new[] { transfer1 }); +// Results handling omitted. +``` + +#### Void a Pending Transfer + +In contrast, with `flags` set to `void_pending_transfer`, +TigerBeetle will void the transfer. TigerBeetle will roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and **not** apply them to the `debits_posted` and +`credits_posted` balances. + +```cs +var transfer0 = new Transfer +{ + Id = 8, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Pending, +}; + +var transferResults = client.CreateTransfers(new[] { transfer0 }); +// Results handling omitted. + +var transfer1 = new Transfer +{ + Id = 9, + Amount = 0, + PendingId = 8, + Flags = TransferFlags.VoidPendingTransfer, +}; + +transferResults = client.CreateTransfers(new[] { transfer1 }); +// Results handling omitted. +``` + +## Transfer Lookup + +NOTE: While transfer lookup exists, it is not a flexible query API. We +are developing query APIs and there will be new methods for querying +transfers in the future. + +Transfer lookup is batched, like transfer creation. Pass in all `id`s to +fetch, and matched transfers are returned. + +If no transfer matches an `id`, no object is returned for that +transfer. So the order of transfers in the response is not necessarily +the same as the order of `id`s in the request. You can refer to the +`id` field in the response to distinguish transfers. + +```cs +Transfer[] transfers = client.LookupTransfers(new UInt128[] { 1, 2 }); +``` + +## Get Account Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the transfers involving a given account, allowing basic filter and pagination +capabilities. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```cs +var filter = new AccountFilter +{ + AccountId = 101, + UserData128 = 0, // No filter by UserData. + UserData64 = 0, + UserData32 = 0, + Code = 0, // No filter by Code. + TimestampMin = 0, // No filter by Timestamp. + TimestampMax = 0, // No filter by Timestamp. + Limit = 10, // Limit to ten transfers at most. + Flags = AccountFilterFlags.Debits | // Include transfer from the debit side. + AccountFilterFlags.Credits | // Include transfer from the credit side. + AccountFilterFlags.Reversed, // Sort by timestamp in reverse-chronological order. +}; + +Transfer[] transfers = client.GetAccountTransfers(filter); +``` + +## Get Account Balances + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the point-in-time balances of a given account, allowing basic filter and +pagination capabilities. + +Only accounts created with the flag +[`history`](https://docs.tigerbeetle.com/reference/account#flagshistory) set retain +[historical balances](https://docs.tigerbeetle.com/reference/requests/get_account_balances). + +The balances in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```cs +var filter = new AccountFilter +{ + AccountId = 101, + UserData128 = 0, // No filter by UserData. + UserData64 = 0, + UserData32 = 0, + Code = 0, // No filter by Code. + TimestampMin = 0, // No filter by Timestamp. + TimestampMax = 0, // No filter by Timestamp. + Limit = 10, // Limit to ten balances at most. + Flags = AccountFilterFlags.Debits | // Include transfer from the debit side. + AccountFilterFlags.Credits | // Include transfer from the credit side. + AccountFilterFlags.Reversed, // Sort by timestamp in reverse-chronological order. +}; + +AccountBalance[] accountBalances = client.GetAccountBalances(filter); +``` + +## Query Accounts + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query accounts by the intersection of some fields and by timestamp range. + +The accounts in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```cs +var filter = new QueryFilter +{ + UserData128 = 1000, // Filter by UserData. + UserData64 = 100, + UserData32 = 10, + Code = 1, // Filter by Code. + Ledger = 0, // No filter by Ledger. + TimestampMin = 0, // No filter by Timestamp. + TimestampMax = 0, // No filter by Timestamp. + Limit = 10, // Limit to ten accounts at most. + Flags = QueryFilterFlags.Reversed, // Sort by timestamp in reverse-chronological order. +}; + +Account[] accounts = client.QueryAccounts(filter); +``` + +## Query Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query transfers by the intersection of some fields and by timestamp range. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```cs +var filter = new QueryFilter +{ + UserData128 = 1000, // Filter by UserData + UserData64 = 100, + UserData32 = 10, + Code = 1, // Filter by Code + Ledger = 0, // No filter by Ledger + TimestampMin = 0, // No filter by Timestamp. + TimestampMax = 0, // No filter by Timestamp. + Limit = 10, // Limit to ten transfers at most. + Flags = QueryFilterFlags.Reversed, // Sort by timestamp in reverse-chronological order. +}; + +Transfer[] transfers = client.QueryTransfers(filter); +``` + +## Linked Events + +When the `linked` flag is specified for an account when creating accounts or +a transfer when creating transfers, it links that event with the next event in the +batch, to create a chain of events, of arbitrary length, which all +succeed or fail together. The tail of a chain is denoted by the first +event without this flag. The last event in a batch may therefore never +have the `linked` flag set as this would leave a chain +open-ended. Multiple chains or individual events may coexist within a +batch to succeed or fail independently. + +Events within a chain are executed within order, or are rolled back on +error, so that the effect of each event in the chain is visible to the +next, and so that the chain is either visible or invisible as a unit +to subsequent events after the chain. The event that was the first to +break the chain will have a unique error result. Other events in the +chain will have their error result set to `linked_event_failed`. + +```cs +var batch = new System.Collections.Generic.List(); + +// An individual transfer (successful): +batch.Add(new Transfer { Id = 1, /* ... rest of transfer ... */ }); + +// A chain of 4 transfers (the last transfer in the chain closes the chain with linked=false): +batch.Add(new Transfer { Id = 2, /* ... rest of transfer ... */ Flags = TransferFlags.Linked }); // Commit/rollback. +batch.Add(new Transfer { Id = 3, /* ... rest of transfer ... */ Flags = TransferFlags.Linked }); // Commit/rollback. +batch.Add(new Transfer { Id = 2, /* ... rest of transfer ... */ Flags = TransferFlags.Linked }); // Fail with exists +batch.Add(new Transfer { Id = 4, /* ... rest of transfer ... */ }); // Fail without committing + +// An individual transfer (successful): +// This should not see any effect from the failed chain above. +batch.Add(new Transfer { Id = 2, /* ... rest of transfer ... */ }); + +// A chain of 2 transfers (the first transfer fails the chain): +batch.Add(new Transfer { Id = 2, /* ... rest of transfer ... */ Flags = TransferFlags.Linked }); +batch.Add(new Transfer { Id = 3, /* ... rest of transfer ... */ }); + +// A chain of 2 transfers (successful): +batch.Add(new Transfer { Id = 3, /* ... rest of transfer ... */ Flags = TransferFlags.Linked }); +batch.Add(new Transfer { Id = 4, /* ... rest of transfer ... */ }); + +var transferResults = client.CreateTransfers(batch.ToArray()); +// Results handling omitted. +``` + +## Imported Events + +When the `imported` flag is specified for an account when creating accounts or +a transfer when creating transfers, it allows importing historical events with +a user-defined timestamp. + +The entire batch of events must be set with the flag `imported`. + +It's recommended to submit the whole batch as a `linked` chain of events, ensuring that +if any event fails, none of them are committed, preserving the last timestamp unchanged. +This approach gives the application a chance to correct failed imported events, re-submitting +the batch again with the same user-defined timestamps. + +```cs +// External source of time +ulong historicalTimestamp = 0UL; +var historicalAccounts = new Account[] { /* Loaded from an external source */ }; +var historicalTransfers = new Transfer[] { /* Loaded from an external source */ }; + +// First, load and import all accounts with their timestamps from the historical source. +var accountsBatch = new System.Collections.Generic.List(); +for (var index = 0; index < historicalAccounts.Length; index++) +{ + var account = historicalAccounts[index]; + + // Set a unique and strictly increasing timestamp. + historicalTimestamp += 1; + account.Timestamp = historicalTimestamp; + // Set the account as `imported`. + account.Flags = AccountFlags.Imported; + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + if (index < historicalAccounts.Length - 1) + { + account.Flags |= AccountFlags.Linked; + } + + accountsBatch.Add(account); +} + +var accountResults = client.CreateAccounts(accountsBatch.ToArray()); +// Results handling omitted. + +// Then, load and import all transfers with their timestamps from the historical source. +var transfersBatch = new System.Collections.Generic.List(); +for (var index = 0; index < historicalTransfers.Length; index++) +{ + var transfer = historicalTransfers[index]; + + // Set a unique and strictly increasing timestamp. + historicalTimestamp += 1; + transfer.Timestamp = historicalTimestamp; + // Set the account as `imported`. + transfer.Flags = TransferFlags.Imported; + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + if (index < historicalTransfers.Length - 1) + { + transfer.Flags |= TransferFlags.Linked; + } + + transfersBatch.Add(transfer); +} + +var transferResults = client.CreateTransfers(transfersBatch.ToArray()); +// Results handling omitted. +// Since it is a linked chain, in case of any error the entire batch is rolled back and can be retried +// with the same historical timestamps without regressing the cluster timestamp. +``` + +## Timeouts And Cancellation + +The Client retries indefinitely and doesn't impose any per-request timeout. Cancellation is +provided as a mechanism, and the specific cancellation policy is left to the +application. A Client instance can be closed at any time. On close, all in-flight +requests are canceled and return an error to the caller. Even if an error is returned, +a request might still be processed by the TigerBeetle server. +[Reliable transaction submission](https://docs.tigerbeetle.com/coding/reliable-transaction-submission/) +explains how to make transfers retry-proof using IDs for end-to-end idempotency. diff --git a/ocam/src/clients/dotnet/TigerBeetle.Tests/BindingTests.cs b/ocam/src/clients/dotnet/TigerBeetle.Tests/BindingTests.cs new file mode 100644 index 00000000..449aea67 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle.Tests/BindingTests.cs @@ -0,0 +1,303 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; + +namespace TigerBeetle.Tests; + +[TestClass] +public class BindingTests +{ + [TestMethod] + public void Accounts() + { + var account = new Account(); + account.Id = 100; + Assert.AreEqual(account.Id, (UInt128)100); + + account.UserData128 = 101; + Assert.AreEqual(account.UserData128, (UInt128)101); + + account.UserData64 = 102; + Assert.AreEqual(account.UserData64, (ulong)102L); + + account.UserData32 = 103; + Assert.AreEqual(account.UserData32, (uint)103); + + account.Reserved = 0; + Assert.AreEqual(account.Reserved, (uint)0); + + account.Ledger = 104; + Assert.AreEqual(account.Ledger, (uint)104); + + account.Code = 105; + Assert.AreEqual(account.Code, (ushort)105); + + var flags = AccountFlags.Linked | AccountFlags.DebitsMustNotExceedCredits | AccountFlags.CreditsMustNotExceedDebits; + account.Flags = flags; + Assert.AreEqual(account.Flags, flags); + + account.DebitsPending = 1001; + Assert.AreEqual(account.DebitsPending, (UInt128)1001); + + account.CreditsPending = 1002; + Assert.AreEqual(account.CreditsPending, (UInt128)1002); + + account.DebitsPosted = 1003; + Assert.AreEqual(account.DebitsPosted, (UInt128)1003); + + account.CreditsPosted = 1004; + Assert.AreEqual(account.CreditsPosted, (UInt128)1004); + + account.Timestamp = 99_999; + Assert.AreEqual(account.Timestamp, (ulong)99_999); + } + + [TestMethod] + public void AccountsDefault() + { + var account = new Account(); ; + Assert.AreEqual(account.Id, UInt128.Zero); + Assert.AreEqual(account.UserData128, UInt128.Zero); + Assert.AreEqual(account.UserData64, (ulong)0); + Assert.AreEqual(account.UserData32, (uint)0); + Assert.AreEqual(account.Reserved, (uint)0); + Assert.AreEqual(account.Ledger, (uint)0); + Assert.AreEqual(account.Code, (ushort)0); + Assert.AreEqual(account.Flags, AccountFlags.None); + Assert.AreEqual(account.DebitsPending, (UInt128)0); + Assert.AreEqual(account.CreditsPending, (UInt128)0); + Assert.AreEqual(account.DebitsPosted, (UInt128)0); + Assert.AreEqual(account.CreditsPosted, (UInt128)0); + Assert.AreEqual(account.Timestamp, (UInt128)0); + } + + [TestMethod] + public void AccountsSerialize() + { + var expected = new byte[Account.SIZE]; + using (var writer = new BinaryWriter(new MemoryStream(expected))) + { + writer.Write(10L); // Id (lsb) + writer.Write(11L); // Id (msb) + writer.Write(100L); // DebitsPending (lsb) + writer.Write(110L); // DebitsPending (msb) + writer.Write(200L); // DebitsPosted (lsb) + writer.Write(210L); // DebitsPosted (msb) + writer.Write(300L); // CreditPending (lsb) + writer.Write(310L); // CreditPending (msb) + writer.Write(400L); // CreditsPosted (lsb) + writer.Write(410L); // CreditsPosted (msb) + writer.Write(1000L); // UserData128 (lsb) + writer.Write(1100L); // UserData128 (msb) + writer.Write(2000L); // UserData64 + writer.Write(3000); // UserData32 + writer.Write(0); // Reserved + writer.Write(720); // Ledger + writer.Write((short)1); // Code + writer.Write((short)1); // Flags + writer.Write(999L); // Timestamp + } + + var account = new Account + { + Id = new UInt128(11L, 10L), + DebitsPending = new UInt128(110L, 100L), + DebitsPosted = new UInt128(210L, 200L), + CreditsPending = new UInt128(310L, 300L), + CreditsPosted = new UInt128(410L, 400L), + UserData128 = new UInt128(1100L, 1000L), + UserData64 = 2000L, + UserData32 = 3000, + Ledger = 720, + Code = 1, + Flags = AccountFlags.Linked, + Timestamp = 999, + }; + + var serialized = MemoryMarshal.AsBytes(new Account[] { account }).ToArray(); + Assert.IsTrue(expected.SequenceEqual(serialized)); + } + + [TestMethod] + public void CreateAccountResults() + { + var result = new CreateAccountResult(); + + result.Timestamp = 1; + Assert.AreEqual(result.Timestamp, (uint)1); + + result.Status = CreateAccountStatus.Exists; + Assert.AreEqual(result.Status, CreateAccountStatus.Exists); + } + + [TestMethod] + public void CreateAccountResultsSerialize() + { + var expected = new byte[CreateAccountResult.SIZE]; + using (var writer = new BinaryWriter(new MemoryStream(expected))) + { + writer.Write(99_999L); // Timestamp + writer.Write((uint)CreateAccountStatus.IdMustNotBeIntMax); // Result + writer.Write((uint)1); // Reserved + } + + var result = new CreateAccountResult + { + Timestamp = 99_999, + Status = CreateAccountStatus.IdMustNotBeIntMax, + Reserved = 1, + }; + + var serialized = MemoryMarshal.AsBytes(new CreateAccountResult[] { result }).ToArray(); + Assert.IsTrue(expected.SequenceEqual(serialized)); + } + + [TestMethod] + public void Transfers() + { + var transfer = new Transfer(); + + transfer.Id = 100; + Assert.AreEqual(transfer.Id, (UInt128)100); + + transfer.DebitAccountId = 101; + Assert.AreEqual(transfer.DebitAccountId, (UInt128)101); + + transfer.CreditAccountId = 102; + Assert.AreEqual(transfer.CreditAccountId, (UInt128)102); + + transfer.Amount = 1001; + Assert.AreEqual(transfer.Amount, (UInt128)1001); + + transfer.PendingId = 103; + Assert.AreEqual(transfer.PendingId, (UInt128)103); + + transfer.UserData128 = 104; + Assert.AreEqual(transfer.UserData128, (UInt128)104); + + transfer.UserData64 = 105; + Assert.AreEqual(transfer.UserData64, (ulong)105); + + transfer.UserData32 = 106; + Assert.AreEqual(transfer.UserData32, (uint)106); + + transfer.Timeout = 107; + Assert.AreEqual(transfer.Timeout, (uint)107); + + transfer.Ledger = 108; + Assert.AreEqual(transfer.Ledger, (uint)108); + + transfer.Code = 109; + Assert.AreEqual(transfer.Code, (ushort)109); + + var flags = TransferFlags.Linked | TransferFlags.PostPendingTransfer | TransferFlags.VoidPendingTransfer; + transfer.Flags = flags; + Assert.AreEqual(transfer.Flags, flags); + + transfer.Timestamp = 99_999; + Assert.AreEqual(transfer.Timestamp, (ulong)99_999); + } + + [TestMethod] + public void TransferDefault() + { + var transfer = new Transfer(); + Assert.AreEqual(transfer.Id, (UInt128)0); + Assert.AreEqual(transfer.DebitAccountId, (UInt128)0); + Assert.AreEqual(transfer.CreditAccountId, (UInt128)0); + Assert.AreEqual(transfer.Amount, (UInt128)0); + Assert.AreEqual(transfer.PendingId, (UInt128)0); + Assert.AreEqual(transfer.UserData128, (UInt128)0); + Assert.AreEqual(transfer.UserData64, (ulong)0); + Assert.AreEqual(transfer.UserData32, (uint)0); + Assert.AreEqual(transfer.Timeout, (uint)0); + Assert.AreEqual(transfer.Ledger, (uint)0); + Assert.AreEqual(transfer.Code, (ushort)0); + Assert.AreEqual(transfer.Flags, TransferFlags.None); + Assert.AreEqual(transfer.Timestamp, (ulong)0); + } + + [TestMethod] + public void TransfersSerialize() + { + var expected = new byte[Transfer.SIZE]; + using (var writer = new BinaryWriter(new MemoryStream(expected))) + { + writer.Write(10L); // Id (lsb) + writer.Write(11L); // Id (msb) + writer.Write(100L); // DebitAccountId (lsb) + writer.Write(110L); // DebitAccountId (msb) + writer.Write(200L); // CreditAccountId (lsb) + writer.Write(210L); // CreditAccountId (msb) + writer.Write(300L); // Amount (lsb) + writer.Write(310L); // Amount (msb) + writer.Write(400L); // PendingId (lsb) + writer.Write(410L); // PendingId (msb) + writer.Write(1000L); // UserData128 (lsb) + writer.Write(1100L); // UserData128 (msb) + writer.Write(2000L); // UserData64 + writer.Write(3000); // UserData32 + writer.Write(999); // Timeout + writer.Write(720); // Ledger + writer.Write((short)1); // Code + writer.Write((short)1); // Flags + writer.Write(99_999L); // Timestamp + } + + var transfer = new Transfer + { + Id = new UInt128(11L, 10L), + DebitAccountId = new UInt128(110L, 100L), + CreditAccountId = new UInt128(210L, 200L), + Amount = new UInt128(310L, 300L), + PendingId = new UInt128(410L, 400L), + UserData128 = new UInt128(1100L, 1000L), + UserData64 = 2000L, + UserData32 = 3000, + Timeout = 999, + Ledger = 720, + Code = 1, + Flags = TransferFlags.Linked, + Timestamp = 99_999, + }; + + var serialized = MemoryMarshal.AsBytes(new Transfer[] { transfer }).ToArray(); + Assert.IsTrue(expected.SequenceEqual(serialized)); + } + + [TestMethod] + public void CreateTransferResults() + { + var result = new CreateTransferResult(); + + result.Timestamp = 1; + Assert.AreEqual(result.Timestamp, (uint)1); + + result.Status = CreateTransferStatus.Exists; + Assert.AreEqual(result.Status, CreateTransferStatus.Exists); + } + + [TestMethod] + public void CreateTransferResultsSerialize() + { + var expected = new byte[CreateTransferResult.SIZE]; + using (var writer = new BinaryWriter(new MemoryStream(expected))) + { + writer.Write(99_999L); // Timestamp + writer.Write((uint)CreateTransferStatus.IdMustNotBeIntMax); // Result + writer.Write((uint)1); // Reserved + } + + var result = new CreateTransferResult + { + Timestamp = 99_999, + Status = CreateTransferStatus.IdMustNotBeIntMax, + Reserved = 1, + }; + + var serialized = MemoryMarshal.AsBytes(new CreateTransferResult[] { result }).ToArray(); + Assert.IsTrue(expected.SequenceEqual(serialized)); + } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle.Tests/EchoTests.cs b/ocam/src/clients/dotnet/TigerBeetle.Tests/EchoTests.cs new file mode 100644 index 00000000..a99d12d7 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle.Tests/EchoTests.cs @@ -0,0 +1,164 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace TigerBeetle.Tests; + +[TestClass] +public class EchoTests +{ + private const int HEADER_SIZE = 256; // @sizeOf(vsr.Header) + private const int MESSAGE_SIZE_MAX = 1024 * 1024; // config.message_size_max + private static readonly int TRANSFER_SIZE = Marshal.SizeOf(typeof(Transfer)); + private static readonly int ITEMS_PER_BATCH = (MESSAGE_SIZE_MAX - HEADER_SIZE) / TRANSFER_SIZE; + + [TestMethod] + public void Accounts() + { + var rnd = new Random(1); + using var client = new EchoClient(0, new[] { "3000" }); + + var batch = GetRandom(rnd); + var reply = client.Echo(batch); + Assert.IsTrue(batch.SequenceEqual(reply)); + } + + [TestMethod] + public async Task AccountsAsync() + { + var rnd = new Random(2); + using var client = new EchoClient(0, new[] { "3000" }); + + var batch = GetRandom(rnd); + var reply = await client.EchoAsync(batch); + Assert.IsTrue(batch.SequenceEqual(reply)); + } + + [TestMethod] + public void Transfers() + { + var rnd = new Random(3); + using var client = new EchoClient(0, new[] { "3000" }); + + var batch = GetRandom(rnd); + var reply = client.Echo(batch); + Assert.IsTrue(batch.SequenceEqual(reply)); + } + + [TestMethod] + public async Task TransfersAsync() + { + var rnd = new Random(4); + using var client = new EchoClient(0, new[] { "3000" }); + + var batch = GetRandom(rnd); + var reply = await client.EchoAsync(batch); + Assert.IsTrue(batch.SequenceEqual(reply)); + } + + [TestMethod] + public async Task ConcurrentAccountsAsync() + { + const int MAX_CONCURRENCY = 64; + var rnd = new Random(5); + using var client = new EchoClient(0, new[] { "3000" }); + + const int MAX_REPETITIONS = 5; + for (int repetition = 0; repetition < MAX_REPETITIONS; repetition++) + { + var list = new List<(Account[] batch, Task task)>(); + + for (int i = 0; i < MAX_CONCURRENCY; i++) + { + var batch = GetRandom(rnd); + var task = client.EchoAsync(batch); + list.Add((batch, task)); + } + + foreach (var (batch, task) in list) + { + var reply = await task; + Assert.IsTrue(batch.SequenceEqual(reply)); + } + } + } + + [TestMethod] + public void ConcurrentTransfers() + { + const int MAX_CONCURRENCY = 64; + var rnd = new Random(6); + using var client = new EchoClient(0, new[] { "3000" }); + + const int MAX_REPETITIONS = 5; + for (int repetition = 0; repetition < MAX_REPETITIONS; repetition++) + { + var barrier = new Barrier(MAX_CONCURRENCY); + var list = new List(); + + for (int i = 0; i < MAX_CONCURRENCY; i++) + { + var batch = GetRandom(rnd); + var threadContext = new ThreadContext(client, barrier, batch); + list.Add(threadContext); + } + + foreach (var item in list) + { + var reply = item.Wait(); + Assert.IsTrue(item.Batch.SequenceEqual(reply)); + } + } + } + + private class ThreadContext + { + private readonly Thread thread; + private readonly Transfer[] batch; + private Transfer[]? reply = null; + private Exception? exception; + + public Transfer[] Batch => batch; + + public ThreadContext(EchoClient client, Barrier barrier, Transfer[] batch) + { + this.batch = batch; + this.thread = new Thread(new ParameterizedThreadStart(Run)); + this.thread.Start((client, barrier)); + } + + private void Run(object? state) + { + try + { + var (client, barrier) = ((EchoClient, Barrier))state!; + barrier.SignalAndWait(); + reply = client.Echo(batch); + } + catch (Exception exception) + { + this.exception = exception; + } + } + + public Transfer[] Wait() + { + thread.Join(); + return reply ?? throw exception!; + } + } + + private static T[] GetRandom(Random rnd) + where T : unmanaged + { + var size = rnd.Next(1, ITEMS_PER_BATCH); + + var buffer = new byte[size * Marshal.SizeOf(typeof(T))]; + rnd.NextBytes(buffer); + return MemoryMarshal.Cast(buffer).ToArray(); + } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle.Tests/ExceptionTests.cs b/ocam/src/clients/dotnet/TigerBeetle.Tests/ExceptionTests.cs new file mode 100644 index 00000000..f4e1d3f2 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle.Tests/ExceptionTests.cs @@ -0,0 +1,64 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace TigerBeetle.Tests; + +[TestClass] +public class ExceptionTests +{ + [TestMethod] + public void AssertTrue() + { + // Should not throw an exception + // when the condition is true. + AssertionException.AssertTrue(true); + } + + [TestMethod] + [ExpectedException(typeof(AssertionException))] + public void AssertFalse() + { + // Expected AssertionException. + AssertionException.AssertTrue(false); + } + + [TestMethod] + public void AssertFalseWithMessage() + { + try + { + AssertionException.AssertTrue(false, "hello {0}", "world"); + + // Should not be reachable: + Assert.Fail(); + } + catch (AssertionException exception) + { + Assert.AreEqual("hello world", exception.Message); + } + } + + [TestMethod] + public void AssertTrueWithMessage() + { + AssertionException.AssertTrue(true, "unreachable"); + } + + [TestMethod] + public void InitializationException() + { + foreach (InitializationStatus status in (InitializationStatus[])Enum.GetValues(typeof(InitializationStatus))) + { + var exception = new InitializationException(status); + var unknownMessage = "Unknown error status " + status; + if (status == InitializationStatus.Success) + { + Assert.AreEqual(unknownMessage, exception.Message); + } + else + { + Assert.AreNotEqual(unknownMessage, exception.Message); + } + } + } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle.Tests/IntegrationTests.cs b/ocam/src/clients/dotnet/TigerBeetle.Tests/IntegrationTests.cs new file mode 100644 index 00000000..bb9cdc6b --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle.Tests/IntegrationTests.cs @@ -0,0 +1,2425 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Buffers; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace TigerBeetle.Tests; + +[TestClass] +public class IntegrationTests +{ + private static Account[] GenerateAccounts() => new[] + { + new Account + { + Id = ID.Create(), + UserData128 = 1000, + UserData64 = 1001, + UserData32 = 1002, + Flags = AccountFlags.None, + Ledger = 1, + Code = 1, + }, + new Account + { + Id = ID.Create(), + UserData128 = 1000, + UserData64 = 1001, + UserData32 = 1002, + Flags = AccountFlags.None, + Ledger = 1, + Code = 2, + }, + }; + + // Created by the test initializer: + private static TBServer server = null!; + private static Client client = null!; + + [ClassInitialize] + public static void Initialize(TestContext _) + { + server = new TBServer(); + client = new Client(0, new string[] { server.Address }); + } + + [ClassCleanup] + public static void Cleanup() + { + client.Dispose(); + server.Dispose(); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void ConstructorWithNullReplicaAddresses() + { + string[]? addresses = null; + _ = new Client(0, addresses!); + } + + [TestMethod] + public void ConstructorWithNullReplicaAddressElement() + { + try + { + var addresses = new string?[] { "3000", null }; + _ = new Client(0, addresses!); + Assert.Fail(); + } + catch (InitializationException exception) + { + Assert.AreEqual(InitializationStatus.AddressInvalid, exception.Status); + } + } + + [TestMethod] + public void ConstructorWithEmptyReplicaAddresses() + { + try + { + _ = new Client(0, Array.Empty()); + Assert.Fail(); + } + catch (InitializationException exception) + { + Assert.AreEqual(InitializationStatus.AddressInvalid, exception.Status); + } + } + + [TestMethod] + public void ConstructorWithEmptyReplicaAddressElement() + { + try + { + _ = new Client(0, new string[] { "" }); + Assert.Fail(); + } + catch (InitializationException exception) + { + Assert.AreEqual(InitializationStatus.AddressInvalid, exception.Status); + } + } + + [TestMethod] + public void ConstructorWithInvalidReplicaAddresses() + { + try + { + var addresses = Enumerable.Range(3000, 3100).Select(x => x.ToString()).ToArray(); + _ = new Client(0, addresses); + Assert.Fail(); + } + catch (InitializationException exception) + { + Assert.AreEqual(InitializationStatus.AddressLimitExceeded, exception.Status); + } + } + + [TestMethod] + public void ConstructorAndFinalizer() + { + // No using here, we want to test the finalizer + var client = new Client(1, new string[] { "3000" }); + Assert.IsTrue(client.ClusterID == 1); + } + + [TestMethod] + [ExpectedException(typeof(OverflowException))] + public void CreateAccountBatchSizeOverflow() + { + var batch = new DummyMemory(int.MaxValue); + _ = client.CreateAccounts(batch.Memory.Span); + Assert.Fail(); + } + + [TestMethod] + [ExpectedException(typeof(OverflowException))] + public async Task CreateAccountBatchSizeOverflowAsync() + { + var batch = new DummyMemory(int.MaxValue); + _ = await client.CreateAccountsAsync(batch.Memory); + Assert.Fail(); + } + + [TestMethod] + public void CreateAccount() + { + var accounts = GenerateAccounts()[0..1]; + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var lookupAccount = client.LookupAccounts(new[] { accounts[0].Id }); + Assert.AreEqual(1, lookupAccount.Length); + AssertAccount(accounts[0], lookupAccount[0]); + + var existsResult = client.CreateAccounts(accounts); + Assert.AreEqual(1, existsResult.Length); + Assert.AreEqual(CreateAccountStatus.Exists, existsResult[0].Status); + } + + [TestMethod] + public async Task CreateAccountAsync() + { + var accounts = GenerateAccounts()[0..1]; + var accountResults = await client.CreateAccountsAsync(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var lookupAccount = await client.LookupAccountsAsync(new[] { accounts[0].Id }); + Assert.AreEqual(1, lookupAccount.Length); + AssertAccount(accounts[0], lookupAccount[0]); + + var existsResult = await client.CreateAccountsAsync(accounts); + Assert.AreEqual(1, existsResult.Length); + Assert.AreEqual(CreateAccountStatus.Exists, existsResult[0].Status); + } + + [TestMethod] + public void CreateAccounts() + { + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + } + + [TestMethod] + public async Task CreateAccountsAsync() + { + var accounts = GenerateAccounts(); + var accountResults = await client.CreateAccountsAsync(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + } + + [TestMethod] + public void CreateTransfers() + { + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var transfers = new Transfer[] { + new() + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Ledger = 1, + Code = 1, + }, + }; + + var transferResults = client.CreateTransfers(transfers); + Assert.AreEqual(transfers.Length, transferResults.Length); + Assert.IsTrue(transferResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(transferResults.All(x => x.Status == CreateTransferStatus.Created)); + + var lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + var lookupTransfers = client.LookupTransfers(new[] { transfers[0].Id }); + Assert.AreEqual(1, lookupTransfers.Length); + AssertTransfer(transfers[0], lookupTransfers[0]); + Assert.AreEqual(lookupTransfers[0].Timestamp, transferResults[0].Timestamp); + + Assert.AreEqual(lookupAccounts[0].CreditsPosted, transfers[0].Amount); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, transfers[0].Amount); + } + + [TestMethod] + public async Task CreateTransfersAsync() + { + var accounts = GenerateAccounts(); + var accountResults = await client.CreateAccountsAsync(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var transfers = new Transfer[] { + new() + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Ledger = 1, + Code = 1, + }, + }; + + var transferResults = await client.CreateTransfersAsync(transfers); + Assert.AreEqual(transfers.Length, transferResults.Length); + Assert.IsTrue(transferResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(transferResults.All(x => x.Status == CreateTransferStatus.Created)); + + var lookupAccounts = await client.LookupAccountsAsync(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + var lookupTransfers = await client.LookupTransfersAsync(new[] { transfers[0].Id }); + Assert.AreEqual(1, lookupTransfers.Length); + AssertTransfer(transfers[0], lookupTransfers[0]); + Assert.AreEqual(lookupTransfers[0].Timestamp, transferResults[0].Timestamp); + + Assert.AreEqual(lookupAccounts[0].CreditsPosted, transfers[0].Amount); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, transfers[0].Amount); + } + + [TestMethod] + public void CreateTransferExists() + { + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var transfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Ledger = 1, + Code = 1, + }; + + var transferResults = client.CreateTransfers(new[] { transfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + var lookupTransfers = client.LookupTransfers(new[] { transfer.Id }); + Assert.AreEqual(1, lookupTransfers.Length); + AssertTransfer(transfer, lookupTransfers[0]); + + var exitsResults = client.CreateTransfers(new[] { transfer }); + Assert.AreEqual(1, exitsResults.Length); + Assert.AreEqual(transferResults[0].Timestamp, exitsResults[0].Timestamp); + Assert.AreEqual(CreateTransferStatus.Exists, exitsResults[0].Status); + } + + [TestMethod] + public async Task CreateTransferExistsAsync() + { + var accounts = GenerateAccounts(); + var accountResults = await client.CreateAccountsAsync(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var transfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Ledger = 1, + Code = 1, + }; + + var transferResults = await client.CreateTransfersAsync(new[] { transfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + var lookupTransfers = await client.LookupTransfersAsync(new[] { transfer.Id }); + Assert.AreEqual(1, lookupTransfers.Length); + AssertTransfer(transfer, lookupTransfers[0]); + + var exitsResults = await client.CreateTransfersAsync(new[] { transfer }); + Assert.AreEqual(1, exitsResults.Length); + Assert.AreEqual(transferResults[0].Timestamp, exitsResults[0].Timestamp); + Assert.AreEqual(CreateTransferStatus.Exists, exitsResults[0].Status); + } + + [TestMethod] + public void CreatePendingTransfersAndPost() + { + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var transfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Timeout = uint.MaxValue, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Pending, + }; + + var transferResults = client.CreateTransfers(new[] { transfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + var lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + Assert.AreEqual(lookupAccounts[0].CreditsPending, transfer.Amount); + Assert.AreEqual(lookupAccounts[0].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPending, transfer.Amount); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, (UInt128)0); + + var postTransfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + PendingId = transfer.Id, + Ledger = 1, + Code = 1, + Flags = TransferFlags.PostPendingTransfer, + }; + + transferResults = client.CreateTransfers(new[] { postTransfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + Assert.AreEqual(lookupAccounts[0].CreditsPosted, transfer.Amount); + Assert.AreEqual(lookupAccounts[0].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, transfer.Amount); + Assert.AreEqual(lookupAccounts[1].DebitsPending, (UInt128)0); + } + + [TestMethod] + public async Task CreatePendingTransfersAndPostAsync() + { + var accounts = GenerateAccounts(); + var accountResults = await client.CreateAccountsAsync(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var transfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Timeout = uint.MaxValue, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Pending, + }; + + var transferResults = await client.CreateTransfersAsync(new[] { transfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + var lookupAccounts = await client.LookupAccountsAsync(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + Assert.AreEqual(lookupAccounts[0].CreditsPending, transfer.Amount); + Assert.AreEqual(lookupAccounts[0].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPending, transfer.Amount); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, (UInt128)0); + + var postTransfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + PendingId = transfer.Id, + Ledger = 1, + Code = 1, + Flags = TransferFlags.PostPendingTransfer, + }; + + transferResults = await client.CreateTransfersAsync(new[] { postTransfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + lookupAccounts = await client.LookupAccountsAsync(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + Assert.AreEqual(lookupAccounts[0].CreditsPosted, transfer.Amount); + Assert.AreEqual(lookupAccounts[0].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, transfer.Amount); + Assert.AreEqual(lookupAccounts[1].DebitsPending, (UInt128)0); + } + + [TestMethod] + public void CreatePendingTransfersAndVoid() + { + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var transfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Timeout = uint.MaxValue, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Pending, + }; + + var transferResults = client.CreateTransfers(new[] { transfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + var lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + Assert.AreEqual(lookupAccounts[0].CreditsPending, transfer.Amount); + Assert.AreEqual(lookupAccounts[0].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPending, transfer.Amount); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, (UInt128)0); + + var voidTransfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + PendingId = transfer.Id, + Ledger = 1, + Code = 1, + Flags = TransferFlags.VoidPendingTransfer, + }; + + transferResults = client.CreateTransfers(new[] { voidTransfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + Assert.AreEqual(lookupAccounts[0].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPending, (UInt128)0); + } + + [TestMethod] + public async Task CreatePendingTransfersAndVoidAsync() + { + var accounts = GenerateAccounts(); + var accountResults = await client.CreateAccountsAsync(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var transfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Timeout = uint.MaxValue, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Pending, + }; + + var transferResults = await client.CreateTransfersAsync(new[] { transfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + var lookupAccounts = await client.LookupAccountsAsync(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + Assert.AreEqual(lookupAccounts[0].CreditsPending, transfer.Amount); + Assert.AreEqual(lookupAccounts[0].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPending, transfer.Amount); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, (UInt128)0); + + var voidTransfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + PendingId = transfer.Id, + Ledger = 1, + Code = 1, + Flags = TransferFlags.VoidPendingTransfer, + }; + + transferResults = await client.CreateTransfersAsync(new[] { voidTransfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + lookupAccounts = await client.LookupAccountsAsync(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + Assert.AreEqual(lookupAccounts[0].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPending, (UInt128)0); + } + + [TestMethod] + + public void CreatePendingTransfersAndVoidExpired() + { + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var transfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Timeout = 1, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Pending, + }; + + var transferResults = client.CreateTransfers(new[] { transfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + var lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + Assert.AreEqual(lookupAccounts[0].CreditsPending, transfer.Amount); + Assert.AreEqual(lookupAccounts[0].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPending, transfer.Amount); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, (UInt128)0); + + // We need to wait 1s for the server to expire the transfer, however the + // server can pulse the expiry operation anytime after the timeout, + // so adding an extra delay to avoid flaky tests. + const long EXTRA_WAIT_TIME = 500; + Thread.Sleep(TimeSpan.FromSeconds(transfer.Timeout) + .Add(TimeSpan.FromMilliseconds(EXTRA_WAIT_TIME))); + + // Looking up the accounts again for the updated balance. + lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + Assert.AreEqual(lookupAccounts[0].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, (UInt128)0); + + // Trying to void an already expired transfer. + var voidTransfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Ledger = 1, + Code = 1, + Flags = TransferFlags.VoidPendingTransfer, + PendingId = transfer.Id, + }; + + transferResults = client.CreateTransfers(new[] { voidTransfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.PendingTransferExpired, transferResults[0].Status); + } + + [TestMethod] + public async Task CreatePendingTransfersAndVoidExpiredAsync() + { + var accounts = GenerateAccounts(); + var accountResults = await client.CreateAccountsAsync(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var transfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Timeout = 1, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Pending, + }; + + var transferResults = await client.CreateTransfersAsync(new[] { transfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + var lookupAccounts = await client.LookupAccountsAsync(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + Assert.AreEqual(lookupAccounts[0].CreditsPending, transfer.Amount); + Assert.AreEqual(lookupAccounts[0].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPending, transfer.Amount); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, (UInt128)0); + + // We need to wait 1s for the server to expire the transfer, however the + // server can pulse the expiry operation anytime after the timeout, + // so adding an extra delay to avoid flaky tests. + // Do not use Task.Delay here as it seems to be less precise. + // Waiting for the transfer to expire: + const long EXTRA_WAIT_TIME = 250; + Thread.Sleep(TimeSpan.FromSeconds(transfer.Timeout) + .Add(TimeSpan.FromMilliseconds(EXTRA_WAIT_TIME))); + + // Looking up the accounts again for the updated balance. + lookupAccounts = await client.LookupAccountsAsync(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + Assert.AreEqual(lookupAccounts[0].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, (UInt128)0); + + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPosted, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, (UInt128)0); + + // Trying to void an already expired transfer. + var voidTransfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Ledger = 1, + Code = 1, + Flags = TransferFlags.VoidPendingTransfer, + PendingId = transfer.Id, + }; + + transferResults = await client.CreateTransfersAsync(new[] { voidTransfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.PendingTransferExpired, transferResults[0].Status); + } + + + [TestMethod] + public void CreateLinkedTransfers() + { + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var transfer1 = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Linked, + }; + + var transfer2 = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[1].Id, + DebitAccountId = accounts[0].Id, + Amount = 49, + Ledger = 1, + Code = 1, + Flags = TransferFlags.None, + }; + + var transferResults = client.CreateTransfers(new[] { transfer1, transfer2 }); + Assert.AreEqual(2, transferResults.Length); + Assert.IsTrue(transferResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(transferResults.All(x => x.Status == CreateTransferStatus.Created)); + + var lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + var lookupTransfers = client.LookupTransfers(new UInt128[] { transfer1.Id, transfer2.Id }); + Assert.IsTrue(lookupTransfers.Length == 2); + AssertTransfer(transfer1, lookupTransfers[0]); + AssertTransfer(transfer2, lookupTransfers[1]); + + Assert.AreEqual(lookupAccounts[0].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].CreditsPosted, transfer1.Amount); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, transfer2.Amount); + + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPosted, transfer2.Amount); + Assert.AreEqual(lookupAccounts[1].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, transfer1.Amount); + } + + [TestMethod] + public async Task CreateLinkedTransfersAsync() + { + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var transfer1 = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Linked, + }; + + var transfer2 = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[1].Id, + DebitAccountId = accounts[0].Id, + Amount = 49, + Ledger = 1, + Code = 1, + Flags = TransferFlags.None, + }; + + var transferResults = await client.CreateTransfersAsync(new[] { transfer1, transfer2 }); + Assert.AreEqual(2, transferResults.Length); + Assert.IsTrue(transferResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(transferResults.All(x => x.Status == CreateTransferStatus.Created)); + + var lookupAccounts = await client.LookupAccountsAsync(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + + var lookupTransfers = await client.LookupTransfersAsync(new UInt128[] { transfer1.Id, transfer2.Id }); + Assert.IsTrue(lookupTransfers.Length == 2); + AssertTransfer(transfer1, lookupTransfers[0]); + AssertTransfer(transfer2, lookupTransfers[1]); + + Assert.AreEqual(lookupAccounts[0].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].CreditsPosted, transfer1.Amount); + Assert.AreEqual(lookupAccounts[0].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[0].DebitsPosted, transfer2.Amount); + + Assert.AreEqual(lookupAccounts[1].CreditsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].CreditsPosted, transfer2.Amount); + Assert.AreEqual(lookupAccounts[1].DebitsPending, (UInt128)0); + Assert.AreEqual(lookupAccounts[1].DebitsPosted, transfer1.Amount); + } + + [TestMethod] + public void CreateClosingTransfer() + { + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var closingTransfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 0, + Ledger = 1, + Code = 1, + Flags = TransferFlags.ClosingDebit | TransferFlags.ClosingCredit | TransferFlags.Pending, + }; + + var transferResults = client.CreateTransfers(new[] { closingTransfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + var lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + Assert.AreNotEqual(lookupAccounts[0].Flags, accounts[0].Flags); + Assert.IsTrue(lookupAccounts[0].Flags.HasFlag(AccountFlags.Closed)); + + Assert.AreNotEqual(lookupAccounts[1].Flags, accounts[1].Flags); + Assert.IsTrue(lookupAccounts[1].Flags.HasFlag(AccountFlags.Closed)); + + var voidingTransfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + PendingId = closingTransfer.Id, + Ledger = 1, + Code = 1, + Flags = TransferFlags.VoidPendingTransfer, + }; + + transferResults = client.CreateTransfers(new[] { voidingTransfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.IsTrue(transferResults[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + Assert.IsFalse(lookupAccounts[0].Flags.HasFlag(AccountFlags.Closed)); + Assert.IsFalse(lookupAccounts[1].Flags.HasFlag(AccountFlags.Closed)); + } + + [TestMethod] + public void CreateAccountTooMuchData() + { + const int TOO_MUCH_DATA = 10_000; + var accounts = new Account[TOO_MUCH_DATA]; + for (int i = 0; i < TOO_MUCH_DATA; i++) + { + accounts[i] = new Account + { + Id = ID.Create(), + Code = 1, + Ledger = 1 + }; + } + Assert.ThrowsException(() => _ = client.CreateAccounts(accounts)); + } + + [TestMethod] + + public async Task CreateAccountTooMuchDataAsync() + { + const int TOO_MUCH_DATA = 10_000; + var accounts = new Account[TOO_MUCH_DATA]; + for (int i = 0; i < TOO_MUCH_DATA; i++) + { + accounts[i] = new Account + { + Id = ID.Create(), + Code = 1, + Ledger = 1 + }; + } + await Assert.ThrowsExceptionAsync(() => client.CreateAccountsAsync(accounts)); + } + + [TestMethod] + public void CreateTransferTooMuchData() + { + const int TOO_MUCH_DATA = 10_000; + var transfers = new Transfer[TOO_MUCH_DATA]; + for (int i = 0; i < TOO_MUCH_DATA; i++) + { + transfers[i] = new Transfer + { + Id = ID.Create(), + Code = 1, + Ledger = 1 + }; + } + Assert.ThrowsException(() => _ = client.CreateTransfers(transfers)); + } + + [TestMethod] + public async Task CreateTransferTooMuchDataAsync() + { + const int TOO_MUCH_DATA = 10_000; + var transfers = new Transfer[TOO_MUCH_DATA]; + for (int i = 0; i < TOO_MUCH_DATA; i++) + { + transfers[i] = new Transfer + { + Id = ID.Create(), + DebitAccountId = 1, + CreditAccountId = 2, + Code = 1, + Ledger = 1, + Amount = 100, + }; + } + await Assert.ThrowsExceptionAsync(() => client.CreateTransfersAsync(transfers)); + } + + [TestMethod] + public void CreateZeroLengthAccounts() + { + var accounts = Array.Empty(); + var results = client.CreateAccounts(accounts); + Assert.IsTrue(results.Length == 0); + } + + [TestMethod] + public async Task CreateZeroLengthAccountsAsync() + { + var accounts = Array.Empty(); + var results = await client.CreateAccountsAsync(accounts); + Assert.IsTrue(results.Length == 0); + } + + [TestMethod] + public void CreateZeroLengthTransfers() + { + var transfers = Array.Empty(); + var results = client.CreateTransfers(transfers); + Assert.IsTrue(results.Length == 0); + } + + [TestMethod] + public async Task CreateZeroLengthTransfersAsync() + { + var transfers = Array.Empty(); + var results = await client.CreateTransfersAsync(transfers); + Assert.IsTrue(results.Length == 0); + } + + [TestMethod] + public void LookupZeroLengthAccounts() + { + var ids = Array.Empty(); + var accounts = client.LookupAccounts(ids); + Assert.IsTrue(accounts.Length == 0); + } + + [TestMethod] + public async Task LookupZeroLengthAccountsAsync() + { + var ids = Array.Empty(); + var accounts = await client.LookupAccountsAsync(ids); + Assert.IsTrue(accounts.Length == 0); + } + + [TestMethod] + public void LookupZeroLengthTransfers() + { + var ids = Array.Empty(); + var transfers = client.LookupTransfers(ids); + Assert.IsTrue(transfers.Length == 0); + } + + [TestMethod] + public async Task LookupZeroLengthTransfersAsync() + { + var ids = Array.Empty(); + var transfers = await client.LookupTransfersAsync(ids); + Assert.IsTrue(transfers.Length == 0); + } + + [TestMethod] + public void TestGetAccountTransfers() + { + var accounts = GenerateAccounts(); + accounts[0].Flags |= AccountFlags.History; + accounts[1].Flags |= AccountFlags.History; + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + // Creating a transfer. + var transfers = new Transfer[10]; + for (int i = 0; i < 10; i++) + { + transfers[i] = new Transfer + { + Id = ID.Create(), + + // Swap the debit and credit accounts: + CreditAccountId = i % 2 == 0 ? accounts[0].Id : accounts[1].Id, + DebitAccountId = i % 2 == 0 ? accounts[1].Id : accounts[0].Id, + + Ledger = 1, + Code = 2, + Flags = TransferFlags.None, + Amount = 100 + }; + } + + var transferResults = client.CreateTransfers(transfers); + Assert.AreEqual(transfers.Length, transferResults.Length); + Assert.IsTrue(transferResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(transferResults.All(x => x.Status == CreateTransferStatus.Created)); + + { + // Querying transfers where: + // `debit_account_id=$account1Id OR credit_account_id=$account1Id + // ORDER BY timestamp ASC`. + var filter = new AccountFilter + { + AccountId = accounts[0].Id, + TimestampMin = 0, + TimestampMax = 0, + Limit = 254, + Flags = AccountFilterFlags.Credits | AccountFilterFlags.Debits + }; + var account_transfers = client.GetAccountTransfers(filter); + var account_balances = client.GetAccountBalances(filter); + + Assert.IsTrue(account_transfers.Length == 10); + Assert.IsTrue(account_balances.Length == account_transfers.Length); + + ulong timestamp = 0; + for (int i = 0; i < account_transfers.Length; i++) + { + var transfer = account_transfers[i]; + Assert.IsTrue(transfer.Timestamp > timestamp); + timestamp = transfer.Timestamp; + + var balance = account_balances[i]; + Assert.IsTrue(balance.Timestamp == transfer.Timestamp); + } + } + + { + // Querying transfers where: + // `debit_account_id=$account2Id OR credit_account_id=$account2Id + // ORDER BY timestamp DESC`. + var filter = new AccountFilter + { + AccountId = accounts[1].Id, + TimestampMin = 0, + TimestampMax = 0, + Limit = 254, + Flags = AccountFilterFlags.Credits | AccountFilterFlags.Debits | AccountFilterFlags.Reversed + }; + var account_transfers = client.GetAccountTransfers(filter); + var account_balances = client.GetAccountBalances(filter); + + Assert.IsTrue(account_transfers.Length == 10); + Assert.IsTrue(account_balances.Length == account_transfers.Length); + + ulong timestamp = ulong.MaxValue; + for (int i = 0; i < account_transfers.Length; i++) + { + var transfer = account_transfers[i]; + Assert.IsTrue(transfer.Timestamp < timestamp); + timestamp = transfer.Timestamp; + + var balance = account_balances[i]; + Assert.IsTrue(balance.Timestamp == transfer.Timestamp); + } + } + + { + // Querying transfers where: + // `debit_account_id=$account1Id + // ORDER BY timestamp ASC`. + var filter = new AccountFilter + { + AccountId = accounts[0].Id, + TimestampMin = 0, + TimestampMax = 0, + Limit = 254, + Flags = AccountFilterFlags.Debits + }; + var account_transfers = client.GetAccountTransfers(filter); + var account_balances = client.GetAccountBalances(filter); + + Assert.IsTrue(account_transfers.Length == 5); + Assert.IsTrue(account_balances.Length == account_transfers.Length); + + ulong timestamp = 0; + for (int i = 0; i < account_transfers.Length; i++) + { + var transfer = account_transfers[i]; + Assert.IsTrue(transfer.Timestamp > timestamp); + timestamp = transfer.Timestamp; + + var balance = account_balances[i]; + Assert.IsTrue(balance.Timestamp == transfer.Timestamp); + } + } + + { + // Querying transfers where: + // `credit_account_id=$account2Id + // ORDER BY timestamp DESC`. + var filter = new AccountFilter + { + AccountId = accounts[1].Id, + TimestampMin = 1, + TimestampMax = 0, + Limit = 254, + Flags = AccountFilterFlags.Credits | AccountFilterFlags.Reversed + }; + var account_transfers = client.GetAccountTransfers(filter); + var account_balances = client.GetAccountBalances(filter); + + Assert.IsTrue(account_transfers.Length == 5); + Assert.IsTrue(account_balances.Length == account_transfers.Length); + + ulong timestamp = ulong.MaxValue; + for (int i = 0; i < account_transfers.Length; i++) + { + var transfer = account_transfers[i]; + Assert.IsTrue(transfer.Timestamp < timestamp); + timestamp = transfer.Timestamp; + + var balance = account_balances[i]; + Assert.IsTrue(balance.Timestamp == transfer.Timestamp); + } + } + + { + // Querying transfers where: + // `debit_account_id=$account1Id OR credit_account_id=$account1Id + // ORDER BY timestamp ASC LIMIT 5`. + var filter = new AccountFilter + { + AccountId = accounts[0].Id, + TimestampMin = 0, + TimestampMax = 0, + Limit = 5, + Flags = AccountFilterFlags.Credits | AccountFilterFlags.Debits + }; + + // First 5 items: + var account_transfers = client.GetAccountTransfers(filter); + var account_balances = client.GetAccountBalances(filter); + + Assert.IsTrue(account_transfers.Length == 5); + Assert.IsTrue(account_balances.Length == account_transfers.Length); + + ulong timestamp = 0; + for (int i = 0; i < account_transfers.Length; i++) + { + var transfer = account_transfers[i]; + Assert.IsTrue(transfer.Timestamp > timestamp); + timestamp = transfer.Timestamp; + + var balance = account_balances[i]; + Assert.IsTrue(balance.Timestamp == transfer.Timestamp); + } + + // Next 5 items from this timestamp: + filter.TimestampMin = timestamp + 1; + account_transfers = client.GetAccountTransfers(filter); + account_balances = client.GetAccountBalances(filter); + + Assert.IsTrue(account_transfers.Length == 5); + Assert.IsTrue(account_balances.Length == account_transfers.Length); + + for (int i = 0; i < account_transfers.Length; i++) + { + var transfer = account_transfers[i]; + Assert.IsTrue(transfer.Timestamp > timestamp); + timestamp = transfer.Timestamp; + + var balance = account_balances[i]; + Assert.IsTrue(balance.Timestamp == transfer.Timestamp); + } + + // No more pages after that: + filter.TimestampMin = timestamp + 1; + account_transfers = client.GetAccountTransfers(filter); + account_balances = client.GetAccountBalances(filter); + + Assert.IsTrue(account_transfers.Length == 0); + Assert.IsTrue(account_balances.Length == account_transfers.Length); + } + + { + // Querying transfers where: + // `debit_account_id=$account2Id OR credit_account_id=$account2Id + // ORDER BY timestamp DESC LIMIT 5`. + var filter = new AccountFilter + { + AccountId = accounts[1].Id, + TimestampMin = 0, + TimestampMax = 0, + Limit = 5, + Flags = AccountFilterFlags.Credits | AccountFilterFlags.Debits | AccountFilterFlags.Reversed + }; + + // First 5 items: + var account_transfers = client.GetAccountTransfers(filter); + var account_balances = client.GetAccountBalances(filter); + + Assert.IsTrue(account_transfers.Length == 5); + Assert.IsTrue(account_balances.Length == account_transfers.Length); + + ulong timestamp = ulong.MaxValue; + for (int i = 0; i < account_transfers.Length; i++) + { + var transfer = account_transfers[i]; + Assert.IsTrue(transfer.Timestamp < timestamp); + timestamp = transfer.Timestamp; + + var balance = account_balances[i]; + Assert.IsTrue(balance.Timestamp == transfer.Timestamp); + } + + // Next 5 items from this timestamp: + filter.TimestampMax = timestamp - 1; + account_transfers = client.GetAccountTransfers(filter); + account_balances = client.GetAccountBalances(filter); + + Assert.IsTrue(account_transfers.Length == 5); + Assert.IsTrue(account_balances.Length == account_transfers.Length); + + for (int i = 0; i < account_transfers.Length; i++) + { + var transfer = account_transfers[i]; + Assert.IsTrue(transfer.Timestamp < timestamp); + timestamp = transfer.Timestamp; + + var balance = account_balances[i]; + Assert.IsTrue(balance.Timestamp == transfer.Timestamp); + } + + // No more pages after that: + filter.TimestampMax = timestamp - 1; + account_transfers = client.GetAccountTransfers(filter); + account_balances = client.GetAccountBalances(filter); + + Assert.IsTrue(account_transfers.Length == 0); + Assert.IsTrue(account_balances.Length == account_transfers.Length); + } + + { + // Empty filter: + Assert.IsTrue(client.GetAccountTransfers(new AccountFilter { }).Length == 0); + Assert.IsTrue(client.GetAccountBalances(new AccountFilter { }).Length == 0); + + // Invalid account + var filter = new AccountFilter + { + AccountId = 0, + TimestampMin = 0, + TimestampMax = 0, + Limit = 254, + Flags = AccountFilterFlags.Credits | AccountFilterFlags.Debits, + }; + Assert.IsTrue(client.GetAccountTransfers(filter).Length == 0); + Assert.IsTrue(client.GetAccountBalances(filter).Length == 0); + + // Invalid timestamp min + filter = new AccountFilter + { + AccountId = accounts[0].Id, + TimestampMin = ulong.MaxValue, + TimestampMax = 0, + Limit = 254, + Flags = AccountFilterFlags.Credits | AccountFilterFlags.Debits, + }; + Assert.IsTrue(client.GetAccountTransfers(filter).Length == 0); + Assert.IsTrue(client.GetAccountBalances(filter).Length == 0); + + // Invalid timestamp max + filter = new AccountFilter + { + AccountId = accounts[0].Id, + TimestampMin = 0, + TimestampMax = ulong.MaxValue, + Limit = 254, + Flags = AccountFilterFlags.Credits | AccountFilterFlags.Debits, + }; + Assert.IsTrue(client.GetAccountTransfers(filter).Length == 0); + Assert.IsTrue(client.GetAccountBalances(filter).Length == 0); + + // Invalid timestamp range + filter = new AccountFilter + { + AccountId = accounts[0].Id, + TimestampMin = 2, + TimestampMax = 1, + Limit = 254, + Flags = AccountFilterFlags.Credits | AccountFilterFlags.Debits, + }; + Assert.IsTrue(client.GetAccountTransfers(filter).Length == 0); + Assert.IsTrue(client.GetAccountBalances(filter).Length == 0); + + // Zero limit + filter = new AccountFilter + { + AccountId = accounts[0].Id, + TimestampMin = 0, + TimestampMax = 0, + Limit = 0, + Flags = AccountFilterFlags.Credits | AccountFilterFlags.Debits, + }; + Assert.IsTrue(client.GetAccountTransfers(filter).Length == 0); + Assert.IsTrue(client.GetAccountBalances(filter).Length == 0); + + // TooMuchData + filter = new AccountFilter + { + AccountId = accounts[0].Id, + TimestampMin = 0, + TimestampMax = 0, + Limit = 10_000, + Flags = AccountFilterFlags.Credits | AccountFilterFlags.Debits, + }; + Assert.ThrowsException(() => _ = client.GetAccountTransfers(filter)); + Assert.ThrowsException(() => _ = client.GetAccountBalances(filter)); + + // Empty flags + filter = new AccountFilter + { + AccountId = accounts[0].Id, + TimestampMin = 0, + TimestampMax = 0, + Limit = 254, + Flags = (AccountFilterFlags)0, + }; + Assert.IsTrue(client.GetAccountTransfers(filter).Length == 0); + Assert.IsTrue(client.GetAccountBalances(filter).Length == 0); + + // Invalid flags + filter = new AccountFilter + { + AccountId = accounts[0].Id, + TimestampMin = 0, + TimestampMax = 0, + Limit = 254, + Flags = (AccountFilterFlags)0xFFFF, + }; + Assert.IsTrue(client.GetAccountTransfers(filter).Length == 0); + Assert.IsTrue(client.GetAccountBalances(filter).Length == 0); + } + } + + [TestMethod] + public void TestQueryAccounts() + { + { + // Creating accounts. + var accounts = new Account[10]; + for (int i = 0; i < 10; i++) + { + accounts[i] = new Account + { + Id = ID.Create() + }; + + if (i % 2 == 0) + { + accounts[i].UserData128 = 1000L; + accounts[i].UserData64 = 100; + accounts[i].UserData32 = 10; + } + else + { + accounts[i].UserData128 = 2000L; + accounts[i].UserData64 = 200; + accounts[i].UserData32 = 20; + } + + accounts[i].Ledger = 1; + accounts[i].Code = 999; + accounts[i].Flags = AccountFlags.None; + } + + var createAccountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, createAccountResults.Length); + Assert.IsTrue(createAccountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(createAccountResults.All(x => x.Status == CreateAccountStatus.Created)); + } + + { + // Querying accounts where: + // `user_data_128=1000 AND user_data_64=100 AND user_data_32=10 + // AND code=999 AND ledger=1 ORDER BY timestamp ASC`. + var filter = new QueryFilter + { + UserData128 = 1000, + UserData64 = 100, + UserData32 = 10, + Code = 999, + Ledger = 1, + Limit = 254, + Flags = QueryFilterFlags.None, + }; + Account[] query = client.QueryAccounts(filter); + + Assert.IsTrue(query.Length == 5); + + ulong timestamp = 0; + foreach (var transfer in query) + { + Assert.IsTrue(transfer.Timestamp > timestamp); + timestamp = transfer.Timestamp; + + Assert.AreEqual(filter.UserData128, transfer.UserData128); + Assert.AreEqual(filter.UserData64, transfer.UserData64); + Assert.AreEqual(filter.UserData32, transfer.UserData32); + Assert.AreEqual(filter.Ledger, transfer.Ledger); + Assert.AreEqual(filter.Code, transfer.Code); + } + } + + { + // Querying accounts where: + // `user_data_128=2000 AND user_data_64=200 AND user_data_32=20 + // AND code=999 AND ledger=1 ORDER BY timestamp ASC`. + var filter = new QueryFilter + { + UserData128 = 2000, + UserData64 = 200, + UserData32 = 20, + Code = 999, + Ledger = 1, + Limit = 254, + Flags = QueryFilterFlags.Reversed, + }; + Account[] query = client.QueryAccounts(filter); + + Assert.IsTrue(query.Length == 5); + + ulong timestamp = ulong.MaxValue; + foreach (var transfer in query) + { + Assert.IsTrue(transfer.Timestamp < timestamp); + timestamp = transfer.Timestamp; + + Assert.AreEqual(filter.UserData128, transfer.UserData128); + Assert.AreEqual(filter.UserData64, transfer.UserData64); + Assert.AreEqual(filter.UserData32, transfer.UserData32); + Assert.AreEqual(filter.Ledger, transfer.Ledger); + Assert.AreEqual(filter.Code, transfer.Code); + } + } + + { + // Querying account where: + // code=999 ORDER BY timestamp ASC`. + var filter = new QueryFilter + { + Code = 999, + Limit = 254, + Flags = QueryFilterFlags.None, + }; + Account[] query = client.QueryAccounts(filter); + + Assert.IsTrue(query.Length == 10); + + ulong timestamp = 0; + foreach (var transfer in query) + { + Assert.IsTrue(transfer.Timestamp > timestamp); + timestamp = transfer.Timestamp; + + Assert.AreEqual(filter.Code, transfer.Code); + } + } + + { + // Querying accounts where: + // code=999 ORDER BY timestamp DESC LIMIT 5`. + var filter = new QueryFilter + { + Code = 999, + Limit = 5, + Flags = QueryFilterFlags.Reversed, + }; + + // First 5 items: + Account[] query = client.QueryAccounts(filter); + Assert.IsTrue(query.Length == 5); + + ulong timestamp = ulong.MaxValue; + foreach (var transfer in query) + { + Assert.IsTrue(transfer.Timestamp < timestamp); + timestamp = transfer.Timestamp; + + Assert.AreEqual(filter.Code, transfer.Code); + } + + // Next 5 items: + filter.TimestampMax = timestamp - 1; + query = client.QueryAccounts(filter); + Assert.IsTrue(query.Length == 5); + + foreach (var transfer in query) + { + Assert.IsTrue(transfer.Timestamp < timestamp); + timestamp = transfer.Timestamp; + + Assert.AreEqual(filter.Code, transfer.Code); + } + + // No more results: + filter.TimestampMax = timestamp - 1; + query = client.QueryAccounts(filter); + Assert.IsTrue(query.Length == 0); + } + + { + // Not found: + var filter = new QueryFilter + { + UserData64 = 200, + UserData32 = 10, + Limit = 254, + Flags = QueryFilterFlags.None, + }; + Account[] query = client.QueryAccounts(filter); + Assert.IsTrue(query.Length == 0); + } + } + + [TestMethod] + public void TestQueryTransfers() + { + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + { + // Creating transfers. + var transfers = new Transfer[10]; + for (int i = 0; i < 10; i++) + { + transfers[i] = new Transfer + { + Id = ID.Create() + }; + + if (i % 2 == 0) + { + transfers[i].CreditAccountId = accounts[0].Id; + transfers[i].DebitAccountId = accounts[1].Id; + transfers[i].UserData128 = 1000L; + transfers[i].UserData64 = 100; + transfers[i].UserData32 = 10; + } + else + { + transfers[i].CreditAccountId = accounts[1].Id; + transfers[i].DebitAccountId = accounts[0].Id; + transfers[i].UserData128 = 2000L; + transfers[i].UserData64 = 200; + transfers[i].UserData32 = 20; + } + + transfers[i].Ledger = 1; + transfers[i].Code = 999; + transfers[i].Flags = TransferFlags.None; + transfers[i].Amount = 100; + } + + var transferResults = client.CreateTransfers(transfers); + Assert.AreEqual(transfers.Length, transferResults.Length); + Assert.IsTrue(transferResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(transferResults.All(x => x.Status == CreateTransferStatus.Created)); + } + + { + // Querying transfers where: + // `user_data_128=1000 AND user_data_64=100 AND user_data_32=10 + // AND code=999 AND ledger=1 ORDER BY timestamp ASC`. + var filter = new QueryFilter + { + UserData128 = 1000, + UserData64 = 100, + UserData32 = 10, + Code = 999, + Ledger = 1, + Limit = 254, + Flags = QueryFilterFlags.None, + }; + Transfer[] query = client.QueryTransfers(filter); + + Assert.IsTrue(query.Length == 5); + + ulong timestamp = 0; + foreach (var transfer in query) + { + Assert.IsTrue(transfer.Timestamp > timestamp); + timestamp = transfer.Timestamp; + + Assert.AreEqual(filter.UserData128, transfer.UserData128); + Assert.AreEqual(filter.UserData64, transfer.UserData64); + Assert.AreEqual(filter.UserData32, transfer.UserData32); + Assert.AreEqual(filter.Ledger, transfer.Ledger); + Assert.AreEqual(filter.Code, transfer.Code); + } + } + + { + // Querying transfers where: + // `user_data_128=2000 AND user_data_64=200 AND user_data_32=20 + // AND code=999 AND ledger=1 ORDER BY timestamp ASC`. + var filter = new QueryFilter + { + UserData128 = 2000, + UserData64 = 200, + UserData32 = 20, + Code = 999, + Ledger = 1, + Limit = 254, + Flags = QueryFilterFlags.Reversed, + }; + Transfer[] query = client.QueryTransfers(filter); + + Assert.IsTrue(query.Length == 5); + + ulong timestamp = ulong.MaxValue; + foreach (var transfer in query) + { + Assert.IsTrue(transfer.Timestamp < timestamp); + timestamp = transfer.Timestamp; + + Assert.AreEqual(filter.UserData128, transfer.UserData128); + Assert.AreEqual(filter.UserData64, transfer.UserData64); + Assert.AreEqual(filter.UserData32, transfer.UserData32); + Assert.AreEqual(filter.Ledger, transfer.Ledger); + Assert.AreEqual(filter.Code, transfer.Code); + } + } + + { + // Querying transfers where: + // code=999 ORDER BY timestamp ASC`. + var filter = new QueryFilter + { + Code = 999, + Limit = 254, + Flags = QueryFilterFlags.None, + }; + Transfer[] query = client.QueryTransfers(filter); + + Assert.IsTrue(query.Length == 10); + + ulong timestamp = 0; + foreach (var transfer in query) + { + Assert.IsTrue(transfer.Timestamp > timestamp); + timestamp = transfer.Timestamp; + + Assert.AreEqual(filter.Code, transfer.Code); + } + } + + { + // Querying transfers where: + // code=999 ORDER BY timestamp DESC LIMIT 5`. + var filter = new QueryFilter + { + Code = 999, + Limit = 5, + Flags = QueryFilterFlags.Reversed, + }; + + // First 5 items: + Transfer[] query = client.QueryTransfers(filter); + Assert.IsTrue(query.Length == 5); + + ulong timestamp = ulong.MaxValue; + foreach (var transfer in query) + { + Assert.IsTrue(transfer.Timestamp < timestamp); + timestamp = transfer.Timestamp; + + Assert.AreEqual(filter.Code, transfer.Code); + } + + // Next 5 items: + filter.TimestampMax = timestamp - 1; + query = client.QueryTransfers(filter); + Assert.IsTrue(query.Length == 5); + + foreach (var transfer in query) + { + Assert.IsTrue(transfer.Timestamp < timestamp); + timestamp = transfer.Timestamp; + + Assert.AreEqual(filter.Code, transfer.Code); + } + + // No more results: + filter.TimestampMax = timestamp - 1; + query = client.QueryTransfers(filter); + Assert.IsTrue(query.Length == 0); + } + + { + // Not found: + var filter = new QueryFilter + { + UserData64 = 200, + UserData32 = 10, + Limit = 254, + Flags = QueryFilterFlags.None, + }; + Transfer[] query = client.QueryTransfers(filter); + Assert.IsTrue(query.Length == 0); + } + } + + [TestMethod] + public void TestInvalidQueryFilter() + { + { + // Empty filter with zero limit: + Assert.IsTrue(client.QueryAccounts(new QueryFilter { }).Length == 0); + Assert.IsTrue(client.QueryTransfers(new QueryFilter { }).Length == 0); + + } + + { + // Invalid timestamp min + var filter = new QueryFilter + { + TimestampMin = ulong.MaxValue, + TimestampMax = 0, + Limit = 254, + Flags = QueryFilterFlags.None, + }; + Assert.IsTrue(client.QueryAccounts(filter).Length == 0); + Assert.IsTrue(client.QueryTransfers(filter).Length == 0); + } + + { + // Invalid timestamp max + var filter = new QueryFilter + { + TimestampMin = 0, + TimestampMax = ulong.MaxValue, + Limit = 254, + Flags = QueryFilterFlags.None, + }; + Assert.IsTrue(client.QueryAccounts(filter).Length == 0); + Assert.IsTrue(client.QueryTransfers(filter).Length == 0); + } + + { + // Invalid timestamp range + var filter = new QueryFilter + { + TimestampMin = ulong.MaxValue - 1, + TimestampMax = 1, + Limit = 254, + Flags = QueryFilterFlags.None, + }; + Assert.IsTrue(client.QueryAccounts(filter).Length == 0); + Assert.IsTrue(client.QueryTransfers(filter).Length == 0); + } + + { + // Invalid flags + var filter = new QueryFilter + { + TimestampMin = 0, + TimestampMax = 0, + Limit = 254, + Flags = (QueryFilterFlags)0xFFFF, + }; + Assert.IsTrue(client.QueryAccounts(filter).Length == 0); + Assert.IsTrue(client.QueryTransfers(filter).Length == 0); + } + { + // TooMuchData + var filter = new QueryFilter + { + TimestampMin = 0, + TimestampMax = 0, + Limit = 10_000, + Flags = (QueryFilterFlags)0xFFFF, + }; + Assert.ThrowsException(() => _ = client.QueryAccounts(filter)); + Assert.ThrowsException(() => _ = client.QueryTransfers(filter)); + } + } + + [TestMethod] + [DoNotParallelize] + public void ImportedFlag() + { + // Gets the last timestamp recorded and waits for 10ms so the + // timestamp can be used as reference for importing past movements. + var timestamp = GetTimestampLast(); + Thread.Sleep(10); + + var accounts = GenerateAccounts(); + for (int i = 0; i < accounts.Length; i++) + { + accounts[i].Flags = AccountFlags.Imported; + accounts[i].Timestamp = timestamp + (ulong)(i + 1); + } + + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + for (int i = 0; i < accounts.Length; i++) + { + Assert.AreEqual(accounts[i].Timestamp, accountResults[i].Timestamp); + Assert.AreEqual(CreateAccountStatus.Created, accountResults[i].Status); + } + + var lookupAccounts = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + for (int i = 0; i < accounts.Length; i++) + { + Assert.AreEqual(accounts[i].Timestamp, timestamp + (ulong)(i + 1)); + } + + var transfer = new Transfer + { + Id = ID.Create(), + DebitAccountId = accounts[0].Id, + CreditAccountId = accounts[1].Id, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Imported, + Amount = 10, + Timestamp = timestamp + (ulong)(accounts.Length + 1), + }; + + var transferResults = client.CreateTransfers(new[] { transfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.AreEqual(transfer.Timestamp, transferResults[0].Timestamp); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + var lookupTransfers = client.LookupTransfers(new[] { transfer.Id }); + Assert.AreEqual(1, lookupTransfers.Length); + Assert.AreEqual(transfer.Timestamp, lookupTransfers[0].Timestamp); + AssertTransfer(transfer, lookupTransfers[0]); + } + + [TestMethod] + [DoNotParallelize] + public async Task ImportedFlagAsync() + { + // Gets the last timestamp recorded and waits for 10ms so the + // timestamp can be used as reference for importing past movements. + var timestamp = GetTimestampLast(); + Thread.Sleep(10); + + var accounts = GenerateAccounts(); + for (int i = 0; i < accounts.Length; i++) + { + accounts[i].Flags = AccountFlags.Imported; + accounts[i].Timestamp = timestamp + (ulong)(i + 1); + } + + var accountResults = await client.CreateAccountsAsync(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + for (int i = 0; i < accounts.Length; i++) + { + Assert.AreEqual(accounts[i].Timestamp, accountResults[i].Timestamp); + Assert.AreEqual(CreateAccountStatus.Created, accountResults[i].Status); + } + + var lookupAccounts = await client.LookupAccountsAsync(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupAccounts); + for (int i = 0; i < accounts.Length; i++) + { + Assert.AreEqual(accounts[i].Timestamp, timestamp + (ulong)(i + 1)); + } + + var transfer = new Transfer + { + Id = ID.Create(), + DebitAccountId = accounts[0].Id, + CreditAccountId = accounts[1].Id, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Imported, + Amount = 10, + Timestamp = timestamp + (ulong)(accounts.Length + 1), + }; + + var transferResults = await client.CreateTransfersAsync(new[] { transfer }); + Assert.AreEqual(1, transferResults.Length); + Assert.AreEqual(transfer.Timestamp, transferResults[0].Timestamp); + Assert.AreEqual(CreateTransferStatus.Created, transferResults[0].Status); + + var lookupTransfers = await client.LookupTransfersAsync(new[] { transfer.Id }); + Assert.AreEqual(1, lookupTransfers.Length); + Assert.AreEqual(transfer.Timestamp, lookupTransfers[0].Timestamp); + AssertTransfer(transfer, lookupTransfers[0]); + } + + private static ulong GetTimestampLast() + { + // Inserts a dummy account just to retrieve the latest timestamp + // recorded by the cluster. + // Must be used only in "DoNotParallelize" tests. + var accounts = GenerateAccounts()[0..1]; + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var lookup = client.LookupAccounts(new[] { accounts[0].Id }); + Assert.AreEqual(1, lookup.Length); + + return lookup[0].Timestamp; + } + + /// + /// This test asserts that a single Client can be shared by multiple concurrent tasks + /// + + [TestMethod] + public void ConcurrencyTest() => ConcurrencyTest(isAsync: false); + + [TestMethod] + public void ConcurrencyTestAsync() => ConcurrencyTest(isAsync: true); + + private void ConcurrencyTest(bool isAsync) + { + using var client = new Client(0, new[] { server.Address }); + + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var tasks = new Task[isAsync ? 1_000_000 : 10_000]; + for (int i = 0; i < tasks.Length; i += 2) + { + var transfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 1, + Ledger = 1, + Code = 1, + }; + + // Starting two async requests of different operations. + if (isAsync) + { + tasks[i] = client.CreateTransfersAsync(new[] { transfer }); + tasks[i + 1] = client.LookupAccountsAsync(new[] { accounts[0].Id }); + } + else + { + tasks[i] = Task.Run(() => client.CreateTransfers(new[] { transfer })); + tasks[i + 1] = Task.Run(() => client.LookupAccounts(new[] { accounts[0].Id })); + } + } + Task.WhenAll(tasks).Wait(); + + foreach (var task in tasks) + { + switch (task) + { + case Task createAccounts: + Assert.AreEqual(1, createAccounts.Result.Length); + Assert.IsTrue(createAccounts.Result[0].Timestamp > 0); + Assert.AreEqual(CreateTransferStatus.Created, createAccounts.Result[0].Status); + break; + case Task lookupAccounts: + Assert.AreEqual(1, lookupAccounts.Result.Length); + Assert.AreEqual(accounts[0].Id, lookupAccounts.Result[0].Id); + break; + default: + Assert.Fail(); + break; + } + } + + var lookupResult = client.LookupAccounts(new[] { accounts[0].Id, accounts[1].Id }); + AssertAccounts(accounts, lookupResult); + + // Assert that all tasks ran to the conclusion + + Assert.AreEqual(lookupResult[0].CreditsPosted, (uint)tasks.Length / 2); + Assert.AreEqual(lookupResult[0].DebitsPosted, 0LU); + + Assert.AreEqual(lookupResult[1].CreditsPosted, 0LU); + Assert.AreEqual(lookupResult[1].DebitsPosted, (uint)tasks.Length / 2); + } + + /// + /// This test asserts that a linked chain is consistent across concurrent requests. + /// + + [TestMethod] + public void ConcurrentLinkedChainTest() => ConcurrentLinkedChainTest(isAsync: false); + + [TestMethod] + public void ConcurrentLinkedChainTestAsync() => ConcurrentLinkedChainTest(isAsync: true); + + private void ConcurrentLinkedChainTest(bool isAsync) + { + const int TASKS_QTY = 10_000; + + using var client = new Client(0, new[] { server.Address }); + + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var tasks = new Task[TASKS_QTY]; + + async Task asyncAction(Transfer[] transfers) + { + return await client.CreateTransfersAsync(transfers); + } + + CreateTransferResult[] syncAction(Transfer[] transfers) + { + return client.CreateTransfers(transfers); + } + + for (int i = 0; i < TASKS_QTY; i++) + { + // The Linked flag will cause the + // batch to fail due to LinkedEventChainOpen. + var flags = i % 10 == 0 ? TransferFlags.Linked : TransferFlags.None; + var transfers = new Transfer[] { + new() + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 1, + Ledger = 1, + Code = 1, + Flags = flags + }, + }; + + // Starts multiple requests. + // Wraps the syncAction into a Task for unified logic handling both async and sync tests. + tasks[i] = isAsync ? asyncAction(transfers) : Task.Run(() => syncAction(transfers)); + } + + Task.WhenAll(tasks).Wait(); + + for (int i = 0; i < tasks.Length; i++) + { + CreateTransferResult[] results = tasks[i].Result; + Assert.AreEqual(1, results.Length); + + if (i % 10 == 0) + { + Assert.AreEqual(results[0].Status, CreateTransferStatus.LinkedEventChainOpen); + } + else + { + Assert.AreEqual(results[0].Status, CreateTransferStatus.Created); + } + } + } + + /// + /// This test asserts that Client.Dispose() will wait for any ongoing request to complete + /// And new requests will fail with ObjectDisposedException. + /// + + [TestMethod] + public void ConcurrentTasksDispose() => ConcurrentTasksDispose(isAsync: false); + + [TestMethod] + public void ConcurrentTasksDisposeAsync() => ConcurrentTasksDispose(isAsync: true); + + private void ConcurrentTasksDispose(bool isAsync) + { + const int TASKS_QTY = 32; + + using var client = new Client(0, new[] { server.Address }); + + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + var tasks = new Task[TASKS_QTY]; + + for (int i = 0; i < TASKS_QTY; i++) + { + var transfers = new Transfer[] + { + new() + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Ledger = 1, + Code = 1, + }, + }; + + /// Starts multiple tasks. + var task = isAsync ? client.CreateTransfersAsync(transfers) : Task.Run(() => client.CreateTransfers(transfers)); + tasks[i] = task; + } + + // Waiting for just one task, the others may be pending. + Task.WaitAny(tasks); + + // Disposes the client, waiting all placed requests to finish. + client.Dispose(); + + try + { + // Ignoring exceptions from the tasks. + Task.WhenAll(tasks).Wait(); + } + catch { } + + // Asserting that either the task failed or succeeded, + // at least one must be succeeded. + Assert.IsTrue(tasks.Any(x => !x.IsFaulted && x.Result[0].Status == CreateTransferStatus.Created)); + Assert.IsTrue(tasks.All(x => x.IsFaulted || x.Result[0].Status == CreateTransferStatus.Created)); + } + + [TestMethod] + [ExpectedException(typeof(ClientClosedException))] + public void DisposedClient() + { + using var client = new Client(0, new[] { server.Address }); + + var accounts = GenerateAccounts(); + var accountResults = client.CreateAccounts(accounts); + Assert.AreEqual(accounts.Length, accountResults.Length); + Assert.IsTrue(accountResults.All(x => x.Timestamp > 0)); + Assert.IsTrue(accountResults.All(x => x.Status == CreateAccountStatus.Created)); + + client.Dispose(); + + var transfer = new Transfer + { + Id = ID.Create(), + CreditAccountId = accounts[0].Id, + DebitAccountId = accounts[1].Id, + Amount = 100, + Ledger = 1, + Code = 1, + }; + + _ = client.CreateTransfers(new[] { transfer }); + Assert.Fail(); + } + + private static void AssertAccounts(Account[] expected, Account[] actual) + { + Assert.AreEqual(expected.Length, actual.Length); + for (int i = 0; i < actual.Length; i++) + { + AssertAccount(actual[i], expected[i]); + } + } + + private static void AssertAccount(Account a, Account b) + { + Assert.AreEqual(a.Id, b.Id); + Assert.AreEqual(a.UserData128, b.UserData128); + Assert.AreEqual(a.UserData64, b.UserData64); + Assert.AreEqual(a.UserData32, b.UserData32); + Assert.AreEqual(a.Flags, b.Flags); + Assert.AreEqual(a.Code, b.Code); + Assert.AreEqual(a.Ledger, b.Ledger); + } + + private static void AssertTransfer(Transfer a, Transfer b) + { + Assert.AreEqual(a.Id, b.Id); + Assert.AreEqual(a.DebitAccountId, b.DebitAccountId); + Assert.AreEqual(a.CreditAccountId, b.CreditAccountId); + Assert.AreEqual(a.Amount, b.Amount); + Assert.AreEqual(a.PendingId, b.PendingId); + Assert.AreEqual(a.UserData128, b.UserData128); + Assert.AreEqual(a.UserData64, b.UserData64); + Assert.AreEqual(a.UserData32, b.UserData32); + Assert.AreEqual(a.Timeout, b.Timeout); + Assert.AreEqual(a.Flags, b.Flags); + Assert.AreEqual(a.Code, b.Code); + Assert.AreEqual(a.Ledger, b.Ledger); + } + + private static bool AssertException(Exception exception) where T : Exception + { + while (exception is AggregateException aggregateException && aggregateException.InnerException != null) + { + exception = aggregateException.InnerException; + } + + return exception is T; + } +} + +internal class TBServer : IDisposable +{ + // Path relative from /TigerBeetle.Test/bin/// : + private const string PROJECT_ROOT = "../../../../.."; + private const string TB_PATH = PROJECT_ROOT + "/../../../zig-out/bin"; + private const string TB_EXE = "tigerbeetle"; + private const string TB_SERVER = TB_PATH + "/" + TB_EXE; + + private readonly Process process; + private readonly string dataFile; + + public string Address { get; } + + public TBServer() + { + dataFile = Path.GetRandomFileName(); + + { + using var format = new Process(); + format.StartInfo.FileName = TB_SERVER; + format.StartInfo.Arguments = $"format --cluster=0 --replica=0 --replica-count=1 --development ./{dataFile}"; + format.StartInfo.RedirectStandardError = true; + format.Start(); + var formatStderr = format.StandardError.ReadToEnd(); + format.WaitForExit(); + if (format.ExitCode != 0) throw new InvalidOperationException($"format failed, ExitCode={format.ExitCode} stderr:\n{formatStderr}"); + } + + process = new Process(); + process.StartInfo.FileName = TB_SERVER; + process.StartInfo.Arguments = $"start --addresses=0 --development ./{dataFile}"; + process.StartInfo.RedirectStandardInput = true; + process.StartInfo.RedirectStandardOutput = true; + process.Start(); + + Address = process.StandardOutput.ReadLine()!.Trim(); + } + + public void Dispose() + { + process.Kill(); + process.WaitForExit(); + process.Dispose(); + File.Delete($"./{dataFile}"); + } +} + +/// +/// Dummy allocator capable of creating memory regions +/// and spans for testing purposes. +/// The contents cannot be dereferenced. +/// +sealed class DummyMemory : MemoryManager + where T : unmanaged +{ + private readonly int length; + + public DummyMemory(int length) + { + this.length = length; + } + + public override Memory Memory => base.CreateMemory(length); + + public override Span GetSpan() + { + unsafe + { + return new Span(null, length); + } + } + + public override MemoryHandle Pin(int elementIndex = 0) + { + return new MemoryHandle(); + } + + public override void Unpin() + { + } + + protected override void Dispose(bool disposing) + { + _ = disposing; + } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle.Tests/RequestTests.cs b/ocam/src/clients/dotnet/TigerBeetle.Tests/RequestTests.cs new file mode 100644 index 00000000..0779be90 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle.Tests/RequestTests.cs @@ -0,0 +1,182 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace TigerBeetle.Tests; + +[TestClass] +public class RequestTests +{ + [TestMethod] + [ExpectedException(typeof(AssertionException))] + public async Task UnexpectedOperation() + { + var callback = new CallbackSimulator( + TBOperation.LookupAccounts, + (byte)99, + null, + PacketStatus.Ok, + delay: 100, + isAsync: true + ); + var task = callback.Run(); + Assert.IsFalse(task.IsCompleted); + + _ = await task; + Assert.Fail(); + } + + [TestMethod] + [ExpectedException(typeof(AssertionException))] + public async Task InvalidSizeOperation() + { + var buffer = new byte[Account.SIZE + 1]; + var callback = new CallbackSimulator( + TBOperation.LookupAccounts, + (byte)TBOperation.LookupAccounts, + buffer, + PacketStatus.Ok, + delay: 100, + isAsync: true + ); + + var task = callback.Run(); + Assert.IsFalse(task.IsCompleted); + + _ = await task; + Assert.Fail(); + } + + [TestMethod] + public async Task PacketStatusException() + { + var expectedResults = new (PacketStatus, Type)[] + { + new(PacketStatus.TooMuchData, typeof(TooMuchDataException)), + new(PacketStatus.ClientEvicted, typeof(ClientEvictedException)), + new(PacketStatus.ClientReleaseTooHigh, typeof(ClientReleaseException)), + new(PacketStatus.ClientReleaseTooLow, typeof(ClientReleaseException)), + new(PacketStatus.ClientShutdown, typeof(ClientClosedException)), + }; + foreach (var expected in expectedResults) + { + foreach (var isAsync in new bool[] { true, false }) + { + var buffer = new byte[Account.SIZE]; + var callback = new CallbackSimulator( + TBOperation.LookupAccounts, + (byte)TBOperation.LookupAccounts, + buffer, + expected.Item1, + delay: 50, + isAsync + ); + + var task = callback.Run(); + Assert.IsFalse(task.IsCompleted); + try + { + _ = await task; + Assert.Fail(); + } + catch (Exception exception) + { + Assert.IsInstanceOfType(exception, expected.Item2); + } + } + } + } + + [TestMethod] + public async Task Success() + { + foreach (var isAsync in new bool[] { true, false }) + { + var buffer = MemoryMarshal.Cast(new Account[] + { + new Account + { + Id = 1, + UserData128 = 2, + UserData64 = 3, + UserData32 = 4, + Code = 5, + Ledger = 6, + Flags = AccountFlags.Linked, + } + }).ToArray(); + + var callback = new CallbackSimulator( + TBOperation.LookupAccounts, + (byte)TBOperation.LookupAccounts, + buffer, + PacketStatus.Ok, + delay: 100, + isAsync + ); + + var task = callback.Run(); + Assert.IsFalse(task.IsCompleted); + + var accounts = await task; + Assert.IsTrue(accounts.Length == 1); + Assert.IsTrue(accounts[0].Id == 1); + Assert.IsTrue(accounts[0].UserData128 == 2); + Assert.IsTrue(accounts[0].UserData64 == 3); + Assert.IsTrue(accounts[0].UserData32 == 4); + Assert.IsTrue(accounts[0].Code == 5); + Assert.IsTrue(accounts[0].Ledger == 6); + Assert.IsTrue(accounts[0].Flags == AccountFlags.Linked); + } + } + + private class CallbackSimulator + where TResult : unmanaged + where TBody : unmanaged + { + private readonly Request request; + private readonly byte receivedOperation; + private readonly Memory buffer; + private readonly PacketStatus status; + private readonly int delay; + + public CallbackSimulator(TBOperation operation, byte receivedOperation, Memory buffer, PacketStatus status, int delay, bool isAsync) + { + unsafe + { + this.request = isAsync ? new AsyncRequest(operation) : new BlockingRequest(operation); + this.receivedOperation = receivedOperation; + this.buffer = buffer; + this.status = status; + this.delay = delay; + } + } + + public Task Run() + { + Task.Run(() => + { + unsafe + { + Task.Delay(delay).Wait(); + request.Complete(status, receivedOperation, buffer.Span); + } + }); + + if (request is AsyncRequest asyncRequest) + { + return asyncRequest.Wait(); + } + else if (request is BlockingRequest blockingRequest) + { + return Task.Run(() => blockingRequest.Wait()); + } + else + { + throw new NotImplementedException(); + } + } + } + +} diff --git a/ocam/src/clients/dotnet/TigerBeetle.Tests/TigerBeetle.Tests.csproj b/ocam/src/clients/dotnet/TigerBeetle.Tests/TigerBeetle.Tests.csproj new file mode 100644 index 00000000..c70e926c --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle.Tests/TigerBeetle.Tests.csproj @@ -0,0 +1,33 @@ + + + net8.0 + 10 + Full + false + enable + true + true + false + LatestMajor + true + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + PreserveNewest + + + + + + + + + diff --git a/ocam/src/clients/dotnet/TigerBeetle.Tests/UInt128Tests.cs b/ocam/src/clients/dotnet/TigerBeetle.Tests/UInt128Tests.cs new file mode 100644 index 00000000..e5057a70 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle.Tests/UInt128Tests.cs @@ -0,0 +1,195 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Globalization; +using System.Linq; +using System.Numerics; +using System.Threading; +using System.Threading.Tasks; + +namespace TigerBeetle.Tests; + +[TestClass] +public class UInt128Tests +{ + /// + /// Consistency of U128 across Zig and the language clients. + /// It must be kept in sync with all platforms. + /// + [TestMethod] + public void ConsistencyTest() + { + // Decimal representation: + ulong upper = 11647051514084770242; + ulong lower = 15119395263638463974; + var u128 = new UInt128(upper, lower); + Assert.AreEqual("214850178493633095719753766415838275046", u128.ToString()); + + // Binary representation: + byte[] binary = new byte[] { + 0xe6, 0xe5, 0xe4, 0xe3, 0xe2, 0xe1, + 0xd2, 0xd1, + 0xc2, 0xc1, + 0xb2, 0xb1, + 0xa4, 0xa3, 0xa2, 0xa1, + }; + Assert.IsTrue(binary.SequenceEqual(u128.ToArray())); + + // GUID representation: + var guid = Guid.Parse("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6"); + Assert.AreEqual(guid, u128.ToGuid()); + Assert.AreEqual(u128, guid.ToUInt128()); + } + + [TestMethod] + public void GuidConversion() + { + Guid guid = Guid.Parse("A945C62A-4CC7-425B-B44A-893577632902"); + UInt128 value = guid.ToUInt128(); + + Assert.AreEqual(value, guid.ToUInt128()); + Assert.AreEqual(guid, value.ToGuid()); + } + + [TestMethod] + public void GuidMaxConversion() + { + Guid guid = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); + UInt128 value = guid.ToUInt128(); + + Assert.AreEqual(value, guid.ToUInt128()); + Assert.AreEqual(guid, value.ToGuid()); + } + + [TestMethod] + public void ArrayConversion() + { + byte[] array = new byte[16] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10 }; + UInt128 value = array.ToUInt128(); + + Assert.IsTrue(value.ToArray().SequenceEqual(array)); + Assert.IsTrue(array.SequenceEqual(value.ToArray())); + Assert.IsTrue(value.Equals(array.ToUInt128())); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void NullArrayConversion() + { + byte[] array = null!; + _ = array.ToUInt128(); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void EmptyArrayConversion() + { + byte[] array = new byte[0]; + _ = array.ToUInt128(); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void InvalidArrayConversion() + { + // Expected ArgumentException. + byte[] array = new byte[17]; + _ = array.ToUInt128(); + } + + + [TestMethod] + public void BigIntegerConversion() + { + var checkConversion = (BigInteger bigInteger) => + { + UInt128 uint128 = bigInteger.ToUInt128(); + + Assert.AreEqual(uint128, bigInteger.ToUInt128()); + Assert.AreEqual(bigInteger, uint128.ToBigInteger()); + Assert.IsTrue(uint128.Equals(bigInteger.ToUInt128())); + }; + + checkConversion(BigInteger.Parse("0")); + checkConversion(BigInteger.Parse("1")); + checkConversion(BigInteger.Parse("123456789012345678901234567890123456789")); + checkConversion(new BigInteger(uint.MaxValue)); + checkConversion(new BigInteger(ulong.MaxValue)); + } + + + + [TestMethod] + [ExpectedException(typeof(OverflowException))] + public void BigIntegerNegative() + { + // Expected OverflowException. + _ = BigInteger.MinusOne.ToUInt128(); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void BigIntegerExceedU128() + { + // Expected ArgumentOutOfRangeException. + BigInteger bigInteger = BigInteger.Parse("9999999999999999999999999999999999999999"); + _ = bigInteger.ToUInt128(); + } + + [TestMethod] + public void LittleEndian() + { + // Reference test: + // https://github.com/microsoft/windows-rs/blob/f19edde93252381b7a1789bf856a3a67df23f6db/crates/tests/core/tests/guid.rs#L25-L31 + byte[] bytes_expected = new byte[16] { + 0x8f,0x8c,0x2b,0x05,0xa4,0x53, + 0x3a,0x82, + 0xfe,0x42, + 0xd2,0xc0, + 0xef,0x3f,0xd6,0x1f, + }; + UInt128 decimal_expected = UInt128.Parse("1fd63fefc0d242fe823a53a4052b8c8f", NumberStyles.HexNumber); + BigInteger bigint_expected = BigInteger.Parse("1fd63fefc0d242fe823a53a4052b8c8f", NumberStyles.HexNumber); + Guid guid_expected = Guid.Parse("1fd63fef-c0d2-42fe-823a-53a4052b8c8f"); + + Assert.AreEqual(decimal_expected, bytes_expected.ToUInt128()); + Assert.AreEqual(decimal_expected, bigint_expected.ToUInt128()); + Assert.AreEqual(decimal_expected, guid_expected.ToUInt128()); + + Assert.IsTrue(bytes_expected.SequenceEqual(decimal_expected.ToArray())); + Assert.IsTrue(bytes_expected.SequenceEqual(bytes_expected.ToUInt128().ToArray())); + Assert.IsTrue(bytes_expected.SequenceEqual(bigint_expected.ToUInt128().ToArray())); + Assert.IsTrue(bytes_expected.SequenceEqual(guid_expected.ToUInt128().ToArray())); + } + + [TestMethod] + public void IDCreation() + { + var verifier = () => + { + UInt128 idA = ID.Create(); + for (int i = 0; i < 1_000_000; i++) + { + if (i % 1_000 == 0) + { + Thread.Sleep(1); + } + + UInt128 idB = ID.Create(); + Assert.IsTrue(idB.CompareTo(idA) > 0); + idA = idB; + } + }; + + // Verify monotonic IDs locally. + verifier(); + + // Verify monotonic IDs across multiple threads. + var concurrency = 10; + var startBarrier = new Barrier(concurrency); + Parallel.For(0, concurrency, (_, _) => + { + startBarrier.SignalAndWait(); + verifier(); + }); + } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle.sln b/ocam/src/clients/dotnet/TigerBeetle.sln new file mode 100644 index 00000000..c6f8ca67 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle.sln @@ -0,0 +1,35 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32804.467 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TigerBeetle", "TigerBeetle\TigerBeetle.csproj", "{8E3B80E6-23FD-419C-924E-87774C085AFE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TigerBeetle.Tests", "TigerBeetle.Tests\TigerBeetle.Tests.csproj", "{F39EACD7-EE9B-490F-BB06-800E6663C9D9}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8E3B80E6-23FD-419C-924E-87774C085AFE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E3B80E6-23FD-419C-924E-87774C085AFE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E3B80E6-23FD-419C-924E-87774C085AFE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E3B80E6-23FD-419C-924E-87774C085AFE}.Release|Any CPU.Build.0 = Release|Any CPU + {F39EACD7-EE9B-490F-BB06-800E6663C9D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F39EACD7-EE9B-490F-BB06-800E6663C9D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F39EACD7-EE9B-490F-BB06-800E6663C9D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F39EACD7-EE9B-490F-BB06-800E6663C9D9}.Release|Any CPU.Build.0 = Release|Any CPU + {B0089954-8024-4D5E-81BA-59A5CCB9BA68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B0089954-8024-4D5E-81BA-59A5CCB9BA68}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0089954-8024-4D5E-81BA-59A5CCB9BA68}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B0089954-8024-4D5E-81BA-59A5CCB9BA68}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D2A90B61-A8A2-4AB3-B6E9-A88863EFC55F} + EndGlobalSection +EndGlobal diff --git a/ocam/src/clients/dotnet/TigerBeetle/AssertionException.cs b/ocam/src/clients/dotnet/TigerBeetle/AssertionException.cs new file mode 100644 index 00000000..30ecb24c --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/AssertionException.cs @@ -0,0 +1,32 @@ +using System; + +namespace TigerBeetle; + +/// +/// The exception that is thrown when an assertion used for correctness checks is triggered. +/// Correctness check assertions differ from other assertions offered by the .NET ecosystem +/// i.e. Tracer.Assert and Debug.Assert, because they are not meant to be disabled or re-routed. +/// It's recommended that the application handle AssertionException as unrecoverable fatal errors. +/// +public sealed class AssertionException : Exception +{ + internal AssertionException() { } + + internal AssertionException(string format, params object[] args) : base(string.Format(format, args)) { } + + internal static void AssertTrue(bool condition, string format, params object[] args) + { + if (!condition) + { + throw new AssertionException(format, args); + } + } + + internal static void AssertTrue(bool condition) + { + if (!condition) + { + throw new AssertionException(); + } + } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle/Bindings.cs b/ocam/src/clients/dotnet/TigerBeetle/Bindings.cs new file mode 100644 index 00000000..ad007be7 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/Bindings.cs @@ -0,0 +1,1368 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by dotnet_bindings.zig // +// Do not manually modify. // +////////////////////////////////////////////////////////// + +using System; +using System.Runtime.InteropServices; + +namespace TigerBeetle; + +[Flags] +public enum AccountFlags : ushort +{ + None = 0, + + /// + /// https://docs.tigerbeetle.com/reference/account#flagslinked + /// + Linked = 1 << 0, + + /// + /// https://docs.tigerbeetle.com/reference/account#flagsdebits_must_not_exceed_credits + /// + DebitsMustNotExceedCredits = 1 << 1, + + /// + /// https://docs.tigerbeetle.com/reference/account#flagscredits_must_not_exceed_debits + /// + CreditsMustNotExceedDebits = 1 << 2, + + /// + /// https://docs.tigerbeetle.com/reference/account#flagshistory + /// + History = 1 << 3, + + /// + /// https://docs.tigerbeetle.com/reference/account#flagsimported + /// + Imported = 1 << 4, + + /// + /// https://docs.tigerbeetle.com/reference/account#flagsclosed + /// + Closed = 1 << 5, + +} + +[Flags] +public enum TransferFlags : ushort +{ + None = 0, + + /// + /// https://docs.tigerbeetle.com/reference/transfer#flagslinked + /// + Linked = 1 << 0, + + /// + /// https://docs.tigerbeetle.com/reference/transfer#flagspending + /// + Pending = 1 << 1, + + /// + /// https://docs.tigerbeetle.com/reference/transfer#flagspost_pending_transfer + /// + PostPendingTransfer = 1 << 2, + + /// + /// https://docs.tigerbeetle.com/reference/transfer#flagsvoid_pending_transfer + /// + VoidPendingTransfer = 1 << 3, + + /// + /// https://docs.tigerbeetle.com/reference/transfer#flagsbalancing_debit + /// + BalancingDebit = 1 << 4, + + /// + /// https://docs.tigerbeetle.com/reference/transfer#flagsbalancing_credit + /// + BalancingCredit = 1 << 5, + + /// + /// https://docs.tigerbeetle.com/reference/transfer#flagsclosing_debit + /// + ClosingDebit = 1 << 6, + + /// + /// https://docs.tigerbeetle.com/reference/transfer#flagsclosing_credit + /// + ClosingCredit = 1 << 7, + + /// + /// https://docs.tigerbeetle.com/reference/transfer#flagsimported + /// + Imported = 1 << 8, + +} + +[Flags] +public enum AccountFilterFlags : uint +{ + None = 0, + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#flagsdebits + /// + Debits = 1 << 0, + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#flagscredits + /// + Credits = 1 << 1, + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#flagsreversed + /// + Reversed = 1 << 2, + +} + +[Flags] +public enum QueryFilterFlags : uint +{ + None = 0, + + /// + /// https://docs.tigerbeetle.com/reference/query-filter#flagsreversed + /// + Reversed = 1 << 0, + +} + +[StructLayout(LayoutKind.Sequential, Size = SIZE)] +public struct Account +{ + public const int SIZE = 128; + + + private UInt128 id; + + private UInt128 debitsPending; + + private UInt128 debitsPosted; + + private UInt128 creditsPending; + + private UInt128 creditsPosted; + + private UInt128 userData128; + + private ulong userData64; + + private uint userData32; + + private uint reserved; + + private uint ledger; + + private ushort code; + + private AccountFlags flags; + + private ulong timestamp; + + /// + /// https://docs.tigerbeetle.com/reference/account#id + /// + public UInt128 Id { get => id; set => id = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account#debits_pending + /// + public UInt128 DebitsPending { get => debitsPending; internal set => debitsPending = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account#debits_posted + /// + public UInt128 DebitsPosted { get => debitsPosted; internal set => debitsPosted = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account#credits_pending + /// + public UInt128 CreditsPending { get => creditsPending; internal set => creditsPending = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account#credits_posted + /// + public UInt128 CreditsPosted { get => creditsPosted; internal set => creditsPosted = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account#user_data_128 + /// + public UInt128 UserData128 { get => userData128; set => userData128 = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account#user_data_64 + /// + public ulong UserData64 { get => userData64; set => userData64 = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account#user_data_32 + /// + public uint UserData32 { get => userData32; set => userData32 = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account#reserved + /// + internal uint Reserved { get => reserved; set => reserved = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account#ledger + /// + public uint Ledger { get => ledger; set => ledger = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account#code + /// + public ushort Code { get => code; set => code = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account#flags + /// + public AccountFlags Flags { get => flags; set => flags = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account#timestamp + /// + public ulong Timestamp { get => timestamp; set => timestamp = value; } + +} + +[StructLayout(LayoutKind.Sequential, Size = SIZE)] +public struct Transfer +{ + public const int SIZE = 128; + + public static UInt128 AmountMax => UInt128.MaxValue; + + private UInt128 id; + + private UInt128 debitAccountId; + + private UInt128 creditAccountId; + + private UInt128 amount; + + private UInt128 pendingId; + + private UInt128 userData128; + + private ulong userData64; + + private uint userData32; + + private uint timeout; + + private uint ledger; + + private ushort code; + + private TransferFlags flags; + + private ulong timestamp; + + /// + /// https://docs.tigerbeetle.com/reference/transfer#id + /// + public UInt128 Id { get => id; set => id = value; } + + /// + /// https://docs.tigerbeetle.com/reference/transfer#debit_account_id + /// + public UInt128 DebitAccountId { get => debitAccountId; set => debitAccountId = value; } + + /// + /// https://docs.tigerbeetle.com/reference/transfer#credit_account_id + /// + public UInt128 CreditAccountId { get => creditAccountId; set => creditAccountId = value; } + + /// + /// https://docs.tigerbeetle.com/reference/transfer#amount + /// + public UInt128 Amount { get => amount; set => amount = value; } + + /// + /// https://docs.tigerbeetle.com/reference/transfer#pending_id + /// + public UInt128 PendingId { get => pendingId; set => pendingId = value; } + + /// + /// https://docs.tigerbeetle.com/reference/transfer#user_data_128 + /// + public UInt128 UserData128 { get => userData128; set => userData128 = value; } + + /// + /// https://docs.tigerbeetle.com/reference/transfer#user_data_64 + /// + public ulong UserData64 { get => userData64; set => userData64 = value; } + + /// + /// https://docs.tigerbeetle.com/reference/transfer#user_data_32 + /// + public uint UserData32 { get => userData32; set => userData32 = value; } + + /// + /// https://docs.tigerbeetle.com/reference/transfer#timeout + /// + public uint Timeout { get => timeout; set => timeout = value; } + + /// + /// https://docs.tigerbeetle.com/reference/transfer#ledger + /// + public uint Ledger { get => ledger; set => ledger = value; } + + /// + /// https://docs.tigerbeetle.com/reference/transfer#code + /// + public ushort Code { get => code; set => code = value; } + + /// + /// https://docs.tigerbeetle.com/reference/transfer#flags + /// + public TransferFlags Flags { get => flags; set => flags = value; } + + /// + /// https://docs.tigerbeetle.com/reference/transfer#timestamp + /// + public ulong Timestamp { get => timestamp; set => timestamp = value; } + +} + +public enum CreateAccountStatus : uint +{ + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#created + /// + Created = 0xFFFFFFFF, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#linked_event_failed + /// + LinkedEventFailed = 1, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#linked_event_chain_open + /// + LinkedEventChainOpen = 2, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#imported_event_expected + /// + ImportedEventExpected = 22, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#imported_event_not_expected + /// + ImportedEventNotExpected = 23, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#timestamp_must_be_zero + /// + TimestampMustBeZero = 3, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#imported_event_timestamp_out_of_range + /// + ImportedEventTimestampOutOfRange = 24, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#imported_event_timestamp_must_not_advance + /// + ImportedEventTimestampMustNotAdvance = 25, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#reserved_field + /// + ReservedField = 4, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#reserved_flag + /// + ReservedFlag = 5, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#id_must_not_be_zero + /// + IdMustNotBeZero = 6, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#id_must_not_be_int_max + /// + IdMustNotBeIntMax = 7, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#exists_with_different_flags + /// + ExistsWithDifferentFlags = 15, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#exists_with_different_user_data_128 + /// + ExistsWithDifferentUserData128 = 16, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#exists_with_different_user_data_64 + /// + ExistsWithDifferentUserData64 = 17, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#exists_with_different_user_data_32 + /// + ExistsWithDifferentUserData32 = 18, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#exists_with_different_ledger + /// + ExistsWithDifferentLedger = 19, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#exists_with_different_code + /// + ExistsWithDifferentCode = 20, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#exists + /// + Exists = 21, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#flags_are_mutually_exclusive + /// + FlagsAreMutuallyExclusive = 8, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#debits_pending_must_be_zero + /// + DebitsPendingMustBeZero = 9, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#debits_posted_must_be_zero + /// + DebitsPostedMustBeZero = 10, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#credits_pending_must_be_zero + /// + CreditsPendingMustBeZero = 11, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#credits_posted_must_be_zero + /// + CreditsPostedMustBeZero = 12, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#ledger_must_not_be_zero + /// + LedgerMustNotBeZero = 13, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#code_must_not_be_zero + /// + CodeMustNotBeZero = 14, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_accounts#imported_event_timestamp_must_not_regress + /// + ImportedEventTimestampMustNotRegress = 26, + +} + +public enum CreateTransferStatus : uint +{ + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#created + /// + Created = 0xFFFFFFFF, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#linked_event_failed + /// + LinkedEventFailed = 1, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#linked_event_chain_open + /// + LinkedEventChainOpen = 2, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_expected + /// + ImportedEventExpected = 56, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_not_expected + /// + ImportedEventNotExpected = 57, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#timestamp_must_be_zero + /// + TimestampMustBeZero = 3, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_timestamp_out_of_range + /// + ImportedEventTimestampOutOfRange = 58, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_timestamp_must_not_advance + /// + ImportedEventTimestampMustNotAdvance = 59, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#reserved_flag + /// + ReservedFlag = 4, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#id_must_not_be_zero + /// + IdMustNotBeZero = 5, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#id_must_not_be_int_max + /// + IdMustNotBeIntMax = 6, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_flags + /// + ExistsWithDifferentFlags = 36, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_pending_id + /// + ExistsWithDifferentPendingId = 40, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_timeout + /// + ExistsWithDifferentTimeout = 44, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_debit_account_id + /// + ExistsWithDifferentDebitAccountId = 37, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_credit_account_id + /// + ExistsWithDifferentCreditAccountId = 38, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_amount + /// + ExistsWithDifferentAmount = 39, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_user_data_128 + /// + ExistsWithDifferentUserData128 = 41, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_user_data_64 + /// + ExistsWithDifferentUserData64 = 42, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_user_data_32 + /// + ExistsWithDifferentUserData32 = 43, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_ledger + /// + ExistsWithDifferentLedger = 67, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_code + /// + ExistsWithDifferentCode = 45, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exists + /// + Exists = 46, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#id_already_failed + /// + IdAlreadyFailed = 68, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#flags_are_mutually_exclusive + /// + FlagsAreMutuallyExclusive = 7, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#debit_account_id_must_not_be_zero + /// + DebitAccountIdMustNotBeZero = 8, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#debit_account_id_must_not_be_int_max + /// + DebitAccountIdMustNotBeIntMax = 9, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#credit_account_id_must_not_be_zero + /// + CreditAccountIdMustNotBeZero = 10, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#credit_account_id_must_not_be_int_max + /// + CreditAccountIdMustNotBeIntMax = 11, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#accounts_must_be_different + /// + AccountsMustBeDifferent = 12, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_id_must_be_zero + /// + PendingIdMustBeZero = 13, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_id_must_not_be_zero + /// + PendingIdMustNotBeZero = 14, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_id_must_not_be_int_max + /// + PendingIdMustNotBeIntMax = 15, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_id_must_be_different + /// + PendingIdMustBeDifferent = 16, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#timeout_reserved_for_pending_transfer + /// + TimeoutReservedForPendingTransfer = 17, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#closing_transfer_must_be_pending + /// + ClosingTransferMustBePending = 64, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#ledger_must_not_be_zero + /// + LedgerMustNotBeZero = 19, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#code_must_not_be_zero + /// + CodeMustNotBeZero = 20, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#debit_account_not_found + /// + DebitAccountNotFound = 21, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#credit_account_not_found + /// + CreditAccountNotFound = 22, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#accounts_must_have_the_same_ledger + /// + AccountsMustHaveTheSameLedger = 23, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#transfer_must_have_the_same_ledger_as_accounts + /// + TransferMustHaveTheSameLedgerAsAccounts = 24, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_not_found + /// + PendingTransferNotFound = 25, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_not_pending + /// + PendingTransferNotPending = 26, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_has_different_debit_account_id + /// + PendingTransferHasDifferentDebitAccountId = 27, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_has_different_credit_account_id + /// + PendingTransferHasDifferentCreditAccountId = 28, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_has_different_ledger + /// + PendingTransferHasDifferentLedger = 29, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_has_different_code + /// + PendingTransferHasDifferentCode = 30, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exceeds_pending_transfer_amount + /// + ExceedsPendingTransferAmount = 31, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_has_different_amount + /// + PendingTransferHasDifferentAmount = 32, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_already_posted + /// + PendingTransferAlreadyPosted = 33, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_already_voided + /// + PendingTransferAlreadyVoided = 34, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_expired + /// + PendingTransferExpired = 35, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_timestamp_must_not_regress + /// + ImportedEventTimestampMustNotRegress = 60, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_timestamp_must_postdate_debit_account + /// + ImportedEventTimestampMustPostdateDebitAccount = 61, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_timestamp_must_postdate_credit_account + /// + ImportedEventTimestampMustPostdateCreditAccount = 62, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_timeout_must_be_zero + /// + ImportedEventTimeoutMustBeZero = 63, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#debit_account_already_closed + /// + DebitAccountAlreadyClosed = 65, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#credit_account_already_closed + /// + CreditAccountAlreadyClosed = 66, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_debits_pending + /// + OverflowsDebitsPending = 47, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_credits_pending + /// + OverflowsCreditsPending = 48, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_debits_posted + /// + OverflowsDebitsPosted = 49, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_credits_posted + /// + OverflowsCreditsPosted = 50, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_debits + /// + OverflowsDebits = 51, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_credits + /// + OverflowsCredits = 52, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_timeout + /// + OverflowsTimeout = 53, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exceeds_credits + /// + ExceedsCredits = 54, + + /// + /// https://docs.tigerbeetle.com/reference/requests/create_transfers#exceeds_debits + /// + ExceedsDebits = 55, + +} + +[StructLayout(LayoutKind.Sequential, Size = SIZE)] +public struct CreateAccountResult +{ + public const int SIZE = 16; + + + private ulong timestamp; + + private CreateAccountStatus status; + + private uint reserved; + + public ulong Timestamp { get => timestamp; set => timestamp = value; } + + public CreateAccountStatus Status { get => status; set => status = value; } + + internal uint Reserved { get => reserved; set => reserved = value; } + +} + +[StructLayout(LayoutKind.Sequential, Size = SIZE)] +public struct CreateTransferResult +{ + public const int SIZE = 16; + + + private ulong timestamp; + + private CreateTransferStatus status; + + private uint reserved; + + public ulong Timestamp { get => timestamp; set => timestamp = value; } + + public CreateTransferStatus Status { get => status; set => status = value; } + + internal uint Reserved { get => reserved; set => reserved = value; } + +} + +[StructLayout(LayoutKind.Sequential, Size = SIZE)] +public struct AccountFilter +{ + public const int SIZE = 128; + + + [StructLayout(LayoutKind.Sequential, Size = ReservedData.SIZE)] + private unsafe struct ReservedData + { + public const int SIZE = 58; + private const int LENGTH = 58; + + private fixed byte raw[LENGTH]; + + public byte[] GetData() + { + fixed (void* ptr = raw) + { + return new ReadOnlySpan(ptr, LENGTH).ToArray(); + } + } + + public void SetData(byte[] value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + if (value.Length != LENGTH) + { + throw new ArgumentException( + "Expected a byte[" + LENGTH + "] array", + nameof(value)); + } + + fixed (void* ptr = raw) + { + value.CopyTo(new Span(ptr, LENGTH)); + } + } + } + + private UInt128 accountId; + + private UInt128 userData128; + + private ulong userData64; + + private uint userData32; + + private ushort code; + + private ReservedData reserved; + + private ulong timestampMin; + + private ulong timestampMax; + + private uint limit; + + private AccountFilterFlags flags; + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#account_id + /// + public UInt128 AccountId { get => accountId; set => accountId = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#user_data_128 + /// + public UInt128 UserData128 { get => userData128; set => userData128 = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#user_data_64 + /// + public ulong UserData64 { get => userData64; set => userData64 = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#user_data_32 + /// + public uint UserData32 { get => userData32; set => userData32 = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#code + /// + public ushort Code { get => code; set => code = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#reserved + /// + internal byte[] Reserved { get => reserved.GetData(); set => reserved.SetData(value); } + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#timestamp_min + /// + public ulong TimestampMin { get => timestampMin; set => timestampMin = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#timestamp_max + /// + public ulong TimestampMax { get => timestampMax; set => timestampMax = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#limit + /// + public uint Limit { get => limit; set => limit = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-filter#flags + /// + public AccountFilterFlags Flags { get => flags; set => flags = value; } + +} + +[StructLayout(LayoutKind.Sequential, Size = SIZE)] +public struct AccountBalance +{ + public const int SIZE = 128; + + + [StructLayout(LayoutKind.Sequential, Size = ReservedData.SIZE)] + private unsafe struct ReservedData + { + public const int SIZE = 56; + private const int LENGTH = 56; + + private fixed byte raw[LENGTH]; + + public byte[] GetData() + { + fixed (void* ptr = raw) + { + return new ReadOnlySpan(ptr, LENGTH).ToArray(); + } + } + + public void SetData(byte[] value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + if (value.Length != LENGTH) + { + throw new ArgumentException( + "Expected a byte[" + LENGTH + "] array", + nameof(value)); + } + + fixed (void* ptr = raw) + { + value.CopyTo(new Span(ptr, LENGTH)); + } + } + } + + private UInt128 debitsPending; + + private UInt128 debitsPosted; + + private UInt128 creditsPending; + + private UInt128 creditsPosted; + + private ulong timestamp; + + private ReservedData reserved; + + /// + /// https://docs.tigerbeetle.com/reference/account-balances#debits_pending + /// + public UInt128 DebitsPending { get => debitsPending; set => debitsPending = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-balances#debits_posted + /// + public UInt128 DebitsPosted { get => debitsPosted; set => debitsPosted = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-balances#credits_pending + /// + public UInt128 CreditsPending { get => creditsPending; set => creditsPending = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-balances#credits_posted + /// + public UInt128 CreditsPosted { get => creditsPosted; set => creditsPosted = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-balances#timestamp + /// + public ulong Timestamp { get => timestamp; set => timestamp = value; } + + /// + /// https://docs.tigerbeetle.com/reference/account-balances#reserved + /// + internal byte[] Reserved { get => reserved.GetData(); set => reserved.SetData(value); } + +} + +[StructLayout(LayoutKind.Sequential, Size = SIZE)] +public struct QueryFilter +{ + public const int SIZE = 64; + + + [StructLayout(LayoutKind.Sequential, Size = ReservedData.SIZE)] + private unsafe struct ReservedData + { + public const int SIZE = 6; + private const int LENGTH = 6; + + private fixed byte raw[LENGTH]; + + public byte[] GetData() + { + fixed (void* ptr = raw) + { + return new ReadOnlySpan(ptr, LENGTH).ToArray(); + } + } + + public void SetData(byte[] value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + if (value.Length != LENGTH) + { + throw new ArgumentException( + "Expected a byte[" + LENGTH + "] array", + nameof(value)); + } + + fixed (void* ptr = raw) + { + value.CopyTo(new Span(ptr, LENGTH)); + } + } + } + + private UInt128 userData128; + + private ulong userData64; + + private uint userData32; + + private uint ledger; + + private ushort code; + + private ReservedData reserved; + + private ulong timestampMin; + + private ulong timestampMax; + + private uint limit; + + private QueryFilterFlags flags; + + /// + /// https://docs.tigerbeetle.com/reference/query-filter#user_data_128 + /// + public UInt128 UserData128 { get => userData128; set => userData128 = value; } + + /// + /// https://docs.tigerbeetle.com/reference/query-filter#user_data_64 + /// + public ulong UserData64 { get => userData64; set => userData64 = value; } + + /// + /// https://docs.tigerbeetle.com/reference/query-filter#user_data_32 + /// + public uint UserData32 { get => userData32; set => userData32 = value; } + + /// + /// https://docs.tigerbeetle.com/reference/query-filter#ledger + /// + public uint Ledger { get => ledger; set => ledger = value; } + + /// + /// https://docs.tigerbeetle.com/reference/query-filter#code + /// + public ushort Code { get => code; set => code = value; } + + /// + /// https://docs.tigerbeetle.com/reference/query-filter#reserved + /// + internal byte[] Reserved { get => reserved.GetData(); set => reserved.SetData(value); } + + /// + /// https://docs.tigerbeetle.com/reference/query-filter#timestamp_min + /// + public ulong TimestampMin { get => timestampMin; set => timestampMin = value; } + + /// + /// https://docs.tigerbeetle.com/reference/query-filter#timestamp_max + /// + public ulong TimestampMax { get => timestampMax; set => timestampMax = value; } + + /// + /// https://docs.tigerbeetle.com/reference/query-filter#limit + /// + public uint Limit { get => limit; set => limit = value; } + + /// + /// https://docs.tigerbeetle.com/reference/query-filter#flags + /// + public QueryFilterFlags Flags { get => flags; set => flags = value; } + +} + +public enum InitializationStatus : uint +{ + Success = 0, + + Unexpected = 1, + + OutOfMemory = 2, + + AddressInvalid = 3, + + AddressLimitExceeded = 4, + + SystemResources = 5, + + NetworkSubsystem = 6, + +} + +internal enum ClientStatus : uint +{ + Ok = 0, + + Invalid = 1, + +} + +internal enum PacketStatus : byte +{ + Ok = 0, + + TooMuchData = 1, + + ClientEvicted = 2, + + ClientReleaseTooLow = 3, + + ClientReleaseTooHigh = 4, + + ClientShutdown = 5, + + InvalidOperation = 6, + + InvalidDataSize = 7, + +} + +internal enum TBOperation : byte +{ + Pulse = 128, + + GetChangeEvents = 137, + + LookupAccounts = 140, + + LookupTransfers = 141, + + GetAccountTransfers = 142, + + GetAccountBalances = 143, + + QueryAccounts = 144, + + QueryTransfers = 145, + + CreateAccounts = 146, + + CreateTransfers = 147, + +} + +[StructLayout(LayoutKind.Sequential, Size = SIZE)] +internal unsafe struct TBClient +{ + public const int SIZE = 32; + + + [StructLayout(LayoutKind.Sequential, Size = OpaqueData.SIZE)] + private unsafe struct OpaqueData + { + public const int SIZE = 32; + private const int LENGTH = 4; + + private fixed ulong raw[LENGTH]; + + public ulong[] GetData() + { + fixed (void* ptr = raw) + { + return new ReadOnlySpan(ptr, LENGTH).ToArray(); + } + } + + public void SetData(ulong[] value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + if (value.Length != LENGTH) + { + throw new ArgumentException( + "Expected a ulong[" + LENGTH + "] array", + nameof(value)); + } + + fixed (void* ptr = raw) + { + value.CopyTo(new Span(ptr, LENGTH)); + } + } + } + + private OpaqueData opaque; + +} + +[StructLayout(LayoutKind.Sequential, Size = SIZE)] +internal unsafe struct TBPacket +{ + public const int SIZE = 88; + + + [StructLayout(LayoutKind.Sequential, Size = OpaqueData.SIZE)] + private unsafe struct OpaqueData + { + public const int SIZE = 64; + private const int LENGTH = 64; + + private fixed byte raw[LENGTH]; + + public byte[] GetData() + { + fixed (void* ptr = raw) + { + return new ReadOnlySpan(ptr, LENGTH).ToArray(); + } + } + + public void SetData(byte[] value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + if (value.Length != LENGTH) + { + throw new ArgumentException( + "Expected a byte[" + LENGTH + "] array", + nameof(value)); + } + + fixed (void* ptr = raw) + { + value.CopyTo(new Span(ptr, LENGTH)); + } + } + } + + public IntPtr userData; + + public IntPtr data; + + public uint dataSize; + + public ushort userTag; + + public byte operation; + + public PacketStatus status; + + private OpaqueData opaque; + +} + +internal static class Native +{ + private const string LIB_NAME = "tb_client"; + + [DllImport(LIB_NAME, CallingConvention = CallingConvention.Cdecl)] + public static unsafe extern InitializationStatus tb_client_init( + TBClient* client_out, + UInt128Extensions.UnsafeU128* cluster_id, + byte* address_ptr, + uint address_len, + IntPtr completion_ctx, + delegate* unmanaged[Cdecl] completion_callback + ); + + [DllImport(LIB_NAME, CallingConvention = CallingConvention.Cdecl)] + public static unsafe extern InitializationStatus tb_client_init_echo( + TBClient* out_client, + UInt128Extensions.UnsafeU128* cluster_id, + byte* address_ptr, + uint address_len, + IntPtr completion_ctx, + delegate* unmanaged[Cdecl] completion_callback + ); + + [DllImport(LIB_NAME, CallingConvention = CallingConvention.Cdecl)] + public static unsafe extern ClientStatus tb_client_submit( + TBClient* client, + TBPacket* packet + ); + + [DllImport(LIB_NAME, CallingConvention = CallingConvention.Cdecl)] + public static unsafe extern ClientStatus tb_client_deinit( + TBClient* client + ); +} + diff --git a/ocam/src/clients/dotnet/TigerBeetle/Client.cs b/ocam/src/clients/dotnet/TigerBeetle/Client.cs new file mode 100644 index 00000000..a5d4d743 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/Client.cs @@ -0,0 +1,121 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +[assembly: InternalsVisibleTo("TigerBeetle.Tests")] + +namespace TigerBeetle; + +public sealed class Client : IDisposable +{ + private readonly UInt128 clusterID; + private readonly NativeClient nativeClient; + + public Client(UInt128 clusterID, string[] addresses) + { + this.nativeClient = NativeClient.Init(clusterID, addresses); + this.clusterID = clusterID; + } + + ~Client() + { + // NativeClient can be null if the constructor threw an exception. + if (nativeClient != null) + { + Close(); + } + } + + public UInt128 ClusterID => clusterID; + + public CreateAccountResult[] CreateAccounts(ReadOnlySpan batch) + { + return nativeClient.CallRequest(TBOperation.CreateAccounts, batch); + } + + public Task CreateAccountsAsync(ReadOnlyMemory batch) + { + return nativeClient.CallRequestAsync(TBOperation.CreateAccounts, batch); + } + + public CreateTransferResult[] CreateTransfers(ReadOnlySpan batch) + { + return nativeClient.CallRequest(TBOperation.CreateTransfers, batch); + } + + public Task CreateTransfersAsync(ReadOnlyMemory batch) + { + return nativeClient.CallRequestAsync(TBOperation.CreateTransfers, batch); + } + + public Account[] LookupAccounts(ReadOnlySpan batch) + { + return nativeClient.CallRequest(TBOperation.LookupAccounts, batch); + } + + public Task LookupAccountsAsync(ReadOnlyMemory batch) + { + return nativeClient.CallRequestAsync(TBOperation.LookupAccounts, batch); + } + + public Transfer[] LookupTransfers(ReadOnlySpan batch) + { + return nativeClient.CallRequest(TBOperation.LookupTransfers, batch); + } + + public Task LookupTransfersAsync(ReadOnlyMemory batch) + { + return nativeClient.CallRequestAsync(TBOperation.LookupTransfers, batch); + } + + public Transfer[] GetAccountTransfers(AccountFilter filter) + { + return nativeClient.CallRequest(TBOperation.GetAccountTransfers, new[] { filter }); + } + + public Task GetAccountTransfersAsync(AccountFilter filter) + { + return nativeClient.CallRequestAsync(TBOperation.GetAccountTransfers, new[] { filter }); + } + + public AccountBalance[] GetAccountBalances(AccountFilter filter) + { + return nativeClient.CallRequest(TBOperation.GetAccountBalances, new[] { filter }); + } + + public Task GetAccountBalancesAsync(AccountFilter filter) + { + return nativeClient.CallRequestAsync(TBOperation.GetAccountBalances, new[] { filter }); + } + + public Account[] QueryAccounts(QueryFilter filter) + { + return nativeClient.CallRequest(TBOperation.QueryAccounts, new[] { filter }); + } + + public Task QueryAccountsAsync(QueryFilter filter) + { + return nativeClient.CallRequestAsync(TBOperation.QueryAccounts, new[] { filter }); + } + + public Transfer[] QueryTransfers(QueryFilter filter) + { + return nativeClient.CallRequest(TBOperation.QueryTransfers, new[] { filter }); + } + + public Task QueryTransfersAsync(QueryFilter filter) + { + return nativeClient.CallRequestAsync(TBOperation.QueryTransfers, new[] { filter }); + } + + public void Close() + { + nativeClient.Dispose(); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + Close(); + } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle/ClientClosedException.cs b/ocam/src/clients/dotnet/TigerBeetle/ClientClosedException.cs new file mode 100644 index 00000000..c05a6c26 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/ClientClosedException.cs @@ -0,0 +1,13 @@ +using System; +namespace TigerBeetle; + +/// +/// ClientClosedException is thrown when the client instance is closed and +/// its resources have been freed. +/// +public sealed class ClientClosedException : RequestException +{ + internal ClientClosedException() { } + + public override string Message => "Client was closed."; +} diff --git a/ocam/src/clients/dotnet/TigerBeetle/ClientEvictedException.cs b/ocam/src/clients/dotnet/TigerBeetle/ClientEvictedException.cs new file mode 100644 index 00000000..f1b857c9 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/ClientEvictedException.cs @@ -0,0 +1,15 @@ +using System; +namespace TigerBeetle; + +/// +/// ClientEvictedException is thrown when the client is evicted from +/// the TigerBeetle cluster. +/// If this exception is thrown, then either there are too many clients +/// connected or the client was idle for too long. +/// +public sealed class ClientEvictedException : RequestException +{ + internal ClientEvictedException() { } + + public override string Message => "Client was evicted."; +} diff --git a/ocam/src/clients/dotnet/TigerBeetle/ClientReleaseException.cs b/ocam/src/clients/dotnet/TigerBeetle/ClientReleaseException.cs new file mode 100644 index 00000000..6e20a3c5 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/ClientReleaseException.cs @@ -0,0 +1,38 @@ +using System; +namespace TigerBeetle; + +/// +/// ClientReleaseException is thrown when the TigerBeetle client release +/// version is incompatible with the TigerBeetle cluster release. +/// See the field to check whether +/// the client is too new or too old to connect to the cluster. +/// +/// +public sealed class ClientReleaseException : RequestException +{ + public enum Reason + { + ClientReleaseTooLow, + ClientReleaseTooHigh + } + + public readonly Reason reason; + + internal ClientReleaseException(Reason reason) + { + this.reason = reason; + } + + public override string Message + { + get + { + switch (reason) + { + case Reason.ClientReleaseTooLow: return "Client was evicted: release too old."; + case Reason.ClientReleaseTooHigh: return "Client was evicted: release too new."; + default: return reason.ToString(); + } + } + } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle/EchoClient.cs b/ocam/src/clients/dotnet/TigerBeetle/EchoClient.cs new file mode 100644 index 00000000..0acd5710 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/EchoClient.cs @@ -0,0 +1,47 @@ +using System; +using System.Runtime.Serialization; +using System.Threading.Tasks; + +namespace TigerBeetle; + +internal sealed class EchoClient : IDisposable +{ + private readonly NativeClient nativeClient; + + public EchoClient(UInt128 clusterID, string[] addresses) + { + this.nativeClient = NativeClient.InitEcho(clusterID, addresses); + } + + public Account[] Echo(ReadOnlySpan batch) + { + return nativeClient.CallRequest(TBOperation.CreateAccounts, batch); + } + + public Task EchoAsync(ReadOnlyMemory batch) + { + return nativeClient.CallRequestAsync(TBOperation.CreateAccounts, batch); + } + + public Transfer[] Echo(ReadOnlySpan batch) + { + return nativeClient.CallRequest(TBOperation.CreateTransfers, batch); + } + + public Task EchoAsync(ReadOnlyMemory batch) + { + return nativeClient.CallRequestAsync(TBOperation.CreateTransfers, batch); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + Dispose(disposing: true); + } + + private void Dispose(bool disposing) + { + _ = disposing; + nativeClient.Dispose(); + } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle/InitializationException.cs b/ocam/src/clients/dotnet/TigerBeetle/InitializationException.cs new file mode 100644 index 00000000..0d950aa2 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/InitializationException.cs @@ -0,0 +1,30 @@ +using System; + +namespace TigerBeetle; + +public sealed class InitializationException : Exception +{ + public InitializationStatus Status { get; } + + internal InitializationException(InitializationStatus status) + { + Status = status; + } + + public override string Message + { + get + { + switch (Status) + { + case InitializationStatus.Unexpected: return "Unexpected internal error"; + case InitializationStatus.OutOfMemory: return "Internal client ran out of memory"; + case InitializationStatus.AddressInvalid: return "Replica addresses format is invalid"; + case InitializationStatus.AddressLimitExceeded: return "Replica addresses limit exceeded"; + case InitializationStatus.SystemResources: return "Internal client ran out of system resources"; + case InitializationStatus.NetworkSubsystem: return "Internal client had unexpected networking issues"; + default: return "Unknown error status " + Status; + } + } + } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle/NativeClient.cs b/ocam/src/clients/dotnet/TigerBeetle/NativeClient.cs new file mode 100644 index 00000000..6fe9a9ec --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/NativeClient.cs @@ -0,0 +1,163 @@ +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; +using static TigerBeetle.AssertionException; +using static TigerBeetle.Native; + +namespace TigerBeetle; + +internal sealed class NativeClient : IDisposable +{ + /// + /// Pinned single-element array, created with `GC.AllocateUninitializedArray`. + /// + private readonly TBClient[] tb_client; + + private unsafe delegate InitializationStatus InitFunction( + TBClient* out_client, + UInt128Extensions.UnsafeU128* cluster_id, + byte* address_ptr, + uint address_len, + IntPtr completion_ctx, + delegate* unmanaged[Cdecl] completion_callback + ); + + private NativeClient(TBClient[] tb_client) + { + AssertTrue(tb_client.Length == 1); + this.tb_client = tb_client; + } + + private static byte[] GetBytes(string[] addresses) + { + if (addresses == null) throw new ArgumentNullException(nameof(addresses)); + return Encoding.UTF8.GetBytes(string.Join(',', addresses) + "\0"); + } + + public static NativeClient Init(UInt128 clusterID, string[] addresses) + { + unsafe + { + return CallInit(tb_client_init, clusterID, addresses); + } + } + + public static NativeClient InitEcho(UInt128 clusterID, string[] addresses) + { + unsafe + { + return CallInit(tb_client_init_echo, clusterID, addresses); + } + } + + private static NativeClient CallInit(InitFunction initFunction, UInt128Extensions.UnsafeU128 clusterID, string[] addresses) + { + var addressesBytes = GetBytes(addresses); + unsafe + { + // Creating a pinned, single-item array to hold the client handle. + // Although pinned, this memory will still be freed by the GC when + // no longer referenced. + var tb_client = GC.AllocateUninitializedArray(1, pinned: true); + fixed (TBClient* client = &tb_client[0]) + fixed (byte* addressPtr = addressesBytes) + { + var status = initFunction( + client, + &clusterID, + addressPtr, + (uint)addressesBytes.Length - 1, + IntPtr.Zero, + &OnCompletionCallback + ); + + if (status != InitializationStatus.Success) + { + throw new InitializationException(status); + } + + return new NativeClient(tb_client); + } + } + } + + public TResult[] CallRequest(TBOperation operation, ReadOnlySpan batch) + where TResult : unmanaged + where TBody : unmanaged + { + unsafe + { + fixed (void* pointer = batch) + { + var blockingRequest = new BlockingRequest(operation); + blockingRequest.Submit(this, pointer, batch.Length); + return blockingRequest.Wait(); + } + } + } + + public async Task CallRequestAsync(TBOperation operation, ReadOnlyMemory batch) + where TResult : unmanaged + where TBody : unmanaged + { + using (var memoryHandler = batch.Pin()) + { + var asyncRequest = new AsyncRequest(operation); + + unsafe + { + asyncRequest.Submit(this, memoryHandler.Pointer, batch.Length); + } + + return await asyncRequest.Wait().ConfigureAwait(continueOnCapturedContext: false); + } + } + + public unsafe void Submit(TBPacket* packet) + { + unsafe + { + fixed (TBClient* client = &tb_client[0]) + { + var status = tb_client_submit(client, packet); + if (status != ClientStatus.Ok) throw new ClientClosedException(); + } + } + } + + public void Dispose() + { + unsafe + { + fixed (TBClient* client = &tb_client[0]) + { + _ = tb_client_deinit(client); + } + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private unsafe static void OnCompletionCallback(IntPtr ctx, TBPacket* packet, ulong timestamp, byte* result, uint resultLen) + { + _ = timestamp; + + try + { + AssertTrue(ctx == IntPtr.Zero); + OnComplete(packet, result, resultLen); + } + catch (Exception e) + { + // The caller is unmanaged code, so if an exception occurs here we should force panic. + Environment.FailFast("Failed to process a packet in the OnCompletionCallback", e); + } + } + + private unsafe static void OnComplete(TBPacket* packet, byte* result, uint resultLen) + { + var span = resultLen > 0 ? new ReadOnlySpan(result, (int)resultLen) : ReadOnlySpan.Empty; + NativeRequest.OnComplete(packet, span); + } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle/Request.cs b/ocam/src/clients/dotnet/TigerBeetle/Request.cs new file mode 100644 index 00000000..0318ac80 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/Request.cs @@ -0,0 +1,224 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using static TigerBeetle.AssertionException; + +namespace TigerBeetle; + + +internal abstract class NativeRequest +{ + private GCHandle? packetHandle = null; + + protected unsafe void Submit(NativeClient nativeClient, TBOperation operation, void* data, int len) + { + // Create a handle to the request to ensure it's not GC'd while the packet is submitted. + var requestHandle = GCHandle.Alloc(this, GCHandleType.Normal); + + // Create a handle to the packet itself to keep it pinned in memory for the C client. + AssertTrue(packetHandle == null, "Submitting from an already pending NativeRequest"); + packetHandle = GCHandle.Alloc(new TBPacket + { + userData = GCHandle.ToIntPtr(requestHandle), + userTag = 0, + operation = (byte)operation, + data = (IntPtr)data, + dataSize = (uint)len, + }, GCHandleType.Pinned); + + try + { + nativeClient.Submit((TBPacket*)packetHandle.Value.AddrOfPinnedObject()); + } + catch + { + requestHandle.Free(); + packetHandle.Value.Free(); + throw; + } + } + + public static unsafe void OnComplete(TBPacket* packet, ReadOnlySpan result) + { + // Extract info from the packet before freeing it. + var status = packet->status; + var operation = packet->operation; + var requestHandle = GCHandle.FromIntPtr(packet->userData); + + // Extract the request from the requestHandle. + AssertTrue(requestHandle.IsAllocated && requestHandle.Target != null, "Invalid GCHandle given to NativeRequest.Complete packet"); + var request = (NativeRequest)requestHandle.Target!; + requestHandle.Free(); + + // Free the packet. + AssertTrue(request.packetHandle != null, "NativeRequest completed without a valid packet"); + AssertTrue((IntPtr)packet == request.packetHandle!.Value.AddrOfPinnedObject(), "Mismatching packet tied to a NativeRequest"); + request.packetHandle.Value.Free(); + request.packetHandle = null; + + request.Complete(status, operation, result); + } + + public abstract void Complete(PacketStatus status, byte operation, ReadOnlySpan result); +} + +internal abstract class Request : NativeRequest + where TResult : unmanaged + where TBody : unmanaged +{ + private readonly TBOperation operation; + + public Request(TBOperation operation) : base() + { + this.operation = operation; + } + + public unsafe void Submit(NativeClient nativeClient, void* body, int bodyCount) + { + this.Submit(nativeClient, this.operation, body, checked(bodyCount * sizeof(TBody))); + } + + public override void Complete(PacketStatus status, byte operation, ReadOnlySpan result) + { + TResult[]? array = null; + Exception? exception = null; + + try + { + switch (status) + { + case PacketStatus.Ok: + unsafe + { + AssertTrue( + (byte)this.operation == operation, + "Unexpected callback operation: expected={0}, actual={1}", + (byte)this.operation, + operation + ); + + AssertTrue(result.Length % sizeof(TResult) == 0, + "Invalid received data: result.Length={0}, SizeOf({1})={2}", + result.Length, + typeof(TResult).Name, + sizeof(TResult) + ); + + array = new TResult[result.Length / sizeof(TResult)]; + MemoryMarshal.Cast(result).CopyTo(array); + break; + } + + case PacketStatus.TooMuchData: + throw new TooMuchDataException(); + + case PacketStatus.ClientEvicted: + throw new ClientEvictedException(); + + case PacketStatus.ClientReleaseTooLow: + throw new ClientReleaseException(ClientReleaseException.Reason.ClientReleaseTooLow); + + case PacketStatus.ClientReleaseTooHigh: + throw new ClientReleaseException(ClientReleaseException.Reason.ClientReleaseTooHigh); + + case PacketStatus.ClientShutdown: + throw new ClientClosedException(); + + case PacketStatus.InvalidOperation: // Not expected. + case PacketStatus.InvalidDataSize: // Not expected. + default: + // Panic, as this would be an error in the TigerBeetle client. + Environment.FailFast($"Invalid PacketStatus {status}"); + break; + } + } + catch (Exception any) + { + exception = any; + } + + if (exception != null) + { + SetException(exception!); + } + else + { + SetResult(array!); + } + } + + protected abstract void SetResult(TResult[] result); + + protected abstract void SetException(Exception exception); +} + +internal sealed class AsyncRequest : Request + where TResult : unmanaged + where TBody : unmanaged +{ + private readonly TaskCompletionSource completionSource; + + public AsyncRequest(TBOperation operation) : base(operation) + { + // Hints the TPL to execute the continuation on its own thread pool thread, instead of the unamaged's callback thread: + this.completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public Task Wait() => completionSource.Task; + + protected override void SetResult(TResult[] result) => completionSource.SetResult(result); + + protected override void SetException(Exception exception) => completionSource.SetException(exception); + +} + +internal sealed class BlockingRequest : Request + where TResult : unmanaged + where TBody : unmanaged +{ + private volatile TResult[]? result = null; + private volatile Exception? exception = null; + + private bool Completed => result != null || exception != null; + + public BlockingRequest(TBOperation operation) : base(operation) + { + } + + public TResult[] Wait() + { + if (!Completed) + { + lock (this) + { + if (!Completed) + { + _ = Monitor.Wait(this); + } + } + } + + return result ?? throw exception!; + } + + protected override void SetResult(TResult[] result) + { + lock (this) + { + this.result = result; + this.exception = null; + Monitor.Pulse(this); + } + } + + protected override void SetException(Exception exception) + { + lock (this) + { + this.exception = exception; + this.result = null; + Monitor.Pulse(this); + } + } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle/RequestException.cs b/ocam/src/clients/dotnet/TigerBeetle/RequestException.cs new file mode 100644 index 00000000..f7510224 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/RequestException.cs @@ -0,0 +1,8 @@ +using System; + +namespace TigerBeetle; + +public abstract class RequestException : Exception +{ + internal RequestException() { } +} diff --git a/ocam/src/clients/dotnet/TigerBeetle/TigerBeetle.csproj b/ocam/src/clients/dotnet/TigerBeetle/TigerBeetle.csproj new file mode 100644 index 00000000..e0529818 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/TigerBeetle.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + AnyCPU + 10 + enable + TigerBeetle + true + LatestMajor + true + true + 1591 + + + + runtimes + true + + + runtimes + true + + + runtimes + true + + + diff --git a/ocam/src/clients/dotnet/TigerBeetle/TigerBeetle.props b/ocam/src/clients/dotnet/TigerBeetle/TigerBeetle.props new file mode 100644 index 00000000..323135db --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/TigerBeetle.props @@ -0,0 +1,13 @@ + + + The TigerBeetle client for C# and .NET + TigerBeetle Inc. + TigerBeetle Inc. + en-US + tigerbeetle + TigerBeetle + https://github.com/tigerbeetle/tigerbeetle + Apache-2.0 + true + + \ No newline at end of file diff --git a/ocam/src/clients/dotnet/TigerBeetle/TooMuchDataException.cs b/ocam/src/clients/dotnet/TigerBeetle/TooMuchDataException.cs new file mode 100644 index 00000000..c586f35c --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/TooMuchDataException.cs @@ -0,0 +1,14 @@ +using System; +namespace TigerBeetle; + +/// +/// TooMuchDataException is thrown when the number of events or expected results +/// exceeds the maximum message size. +/// If this exception is thrown, then either there are too many elements in a batch, +/// or the limit of a query is too large to be fulfilled in a single request. +/// +public sealed class TooMuchDataException : Exception +{ + internal TooMuchDataException() { } + public override string Message => "Too much data was sent or requested in this batch."; +} diff --git a/ocam/src/clients/dotnet/TigerBeetle/UInt128Extensions.cs b/ocam/src/clients/dotnet/TigerBeetle/UInt128Extensions.cs new file mode 100644 index 00000000..3d6525a3 --- /dev/null +++ b/ocam/src/clients/dotnet/TigerBeetle/UInt128Extensions.cs @@ -0,0 +1,186 @@ +using System; +using System.Buffers.Binary; +using System.Numerics; +using System.Runtime.InteropServices; +using System.Security.Cryptography; + +namespace TigerBeetle; + +/// +/// Conversion functions between UInt128 and commonly used types such as Guid, BigInteger and byte[]. +/// +public static class UInt128Extensions +{ + /// + /// Unsafe representation of a tb_uint128_t used only internally + /// for P/Invove in methods marked with the [DllImport] attribute. + /// It's necessary only because P/Invoke's marshaller does not support System.UInt128. + /// + [StructLayout(LayoutKind.Explicit, Size = SIZE)] + internal unsafe struct UnsafeU128 + { + [FieldOffset(0)] + fixed byte raw[SIZE]; + + /// + /// Reinterprets memory, casting the managed UInt128 directly to the unsafe representation. + /// + public static implicit operator UnsafeU128(UInt128 value) => *(UnsafeU128*)&value; + } + + internal const int SIZE = 16; + + public static Guid ToGuid(this UInt128 value) + { + Span data = stackalloc byte[SIZE]; + MemoryMarshal.Write(data, in value); + + // The GUID layout is big endian. + // This is important to preserve the string representation. + return new Guid( + BinaryPrimitives.ReadInt32LittleEndian(data[12..16]), + BinaryPrimitives.ReadInt16LittleEndian(data[10..12]), + BinaryPrimitives.ReadInt16LittleEndian(data[8..10]), + data[7], + data[6], + data[5], + data[4], + data[3], + data[2], + data[1], + data[0]); + } + + public static UInt128 ToUInt128(this Guid value) + { + // Converting from big endian to little endian: + Span data = stackalloc byte[SIZE]; + _ = value.TryWriteBytes(data, bigEndian: true, bytesWritten: out _); + + ulong upper = BinaryPrimitives.ReadUInt64BigEndian(data[0..8]); + ulong lower = BinaryPrimitives.ReadUInt64BigEndian(data[8..16]); + return new UInt128(upper, lower); + } + + public static byte[] ToArray(this UInt128 value) + { + unsafe + { + var span = new ReadOnlySpan(&value, SIZE); + return span.ToArray(); + } + } + + public static UInt128 ToUInt128(this ReadOnlySpan memory) + { + if (memory.Length != SIZE) throw new ArgumentException(nameof(memory)); + + unsafe + { + fixed (void* ptr = memory) + { + return *(UInt128*)ptr; + } + } + } + + public static UInt128 ToUInt128(this byte[] array) + { + if (array == null) throw new ArgumentNullException(nameof(array)); + if (array.Length != SIZE) throw new ArgumentException(nameof(array)); + return new ReadOnlySpan(array, 0, SIZE).ToUInt128(); + } + + public static BigInteger ToBigInteger(this UInt128 value) + { + unsafe + { + return new BigInteger(new ReadOnlySpan(&value, SIZE), isUnsigned: true, isBigEndian: false); + } + } + + public static UInt128 ToUInt128(this BigInteger value) + { + unsafe + { + UInt128 ret = UInt128.Zero; + if (!value.TryWriteBytes(new Span(&ret, SIZE), out int _, isUnsigned: true, isBigEndian: false)) + { + throw new ArgumentOutOfRangeException(); + } + + return ret; + } + } +} + +/// +/// Universally Unique and Binary-Sortable Identifiers as UInt128s based on +/// ULID +/// +public static class ID +{ + private static long idLastTimestamp = 0L; + private static readonly byte[] idLastRandom = new byte[10]; + + /// + /// Generates a universally unique identifier as a UInt128. + /// IDs are guaranteed to be monotonically increasing from the last. + /// This function is thread-safe and monotonicity is sequentially consistent. + /// + public static UInt128 Create() + { + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + ulong randomLo; + ushort randomHi; + + lock (idLastRandom) + { + if (timestamp <= idLastTimestamp) + { + timestamp = idLastTimestamp; + } + else + { + idLastTimestamp = timestamp; + RandomNumberGenerator.Fill(idLastRandom); + } + + Span lastRandom = idLastRandom; + randomLo = BitConverter.ToUInt64(lastRandom.Slice(0)); + randomHi = BitConverter.ToUInt16(lastRandom.Slice(8)); + + // Increment the u80 stored in lastRandom using a u64 increment then u16 increment. + // If both overflow, increment timestamp too. + // We rely on unsigned arithmetic wrapping on overflow by detecting for zero after inc. + // Unsigned types wrap by default but can be overridden by compiler flag so be explicit. + unchecked + { + randomLo += 1; + if (randomLo == 0) + { + randomHi += 1; + if (randomHi == 0) + { + timestamp += 1; + idLastTimestamp = timestamp; + if (timestamp == 1 << 48) + { + throw new OverflowException("Timestamp bits overflow on monotonic increment"); + } + } + } + } + + BitConverter.TryWriteBytes(lastRandom.Slice(0), randomLo); + BitConverter.TryWriteBytes(lastRandom.Slice(8), randomHi); + } + + Span bytes = stackalloc byte[16]; + BitConverter.TryWriteBytes(bytes.Slice(0), randomLo); + BitConverter.TryWriteBytes(bytes.Slice(8), randomHi); + BitConverter.TryWriteBytes(bytes.Slice(10), (ushort)(timestamp)); + BitConverter.TryWriteBytes(bytes.Slice(12), (uint)(timestamp >> 16)); + return ((ReadOnlySpan)bytes).ToUInt128(); + } +} diff --git a/ocam/src/clients/dotnet/ci.zig b/ocam/src/clients/dotnet/ci.zig new file mode 100644 index 00000000..6b74f184 --- /dev/null +++ b/ocam/src/clients/dotnet/ci.zig @@ -0,0 +1,224 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const log = std.log; +const assert = std.debug.assert; + +const Shell = @import("stdx").Shell; +const TmpTigerBeetle = @import("../../testing/tmp_tigerbeetle.zig"); + +pub fn tests(shell: *Shell, gpa: std.mem.Allocator) !void { + assert(shell.file_exists("TigerBeetle.sln")); + + try shell.exec_zig("build clients:dotnet -Drelease", .{}); + try shell.exec_zig("build -Drelease", .{}); + + try shell.exec("dotnet restore", .{}); + try shell.exec("dotnet format --no-restore --verify-no-changes", .{}); + + // Unit tests. + try shell.exec("dotnet build --no-restore --configuration Release", .{}); + // Disable coverage on CI, as it is flaky, see + // + try shell.exec( + \\dotnet test --no-restore + \\ --logger:{logger} + \\ /p:CollectCoverage=false + \\ /p:Threshold={threshold} + \\ /p:ThresholdType={threshold_type} + , .{ + // Dotnet wants quotes inside the argument. + .logger = "\"console;verbosity=detailed\"", + .threshold = "\"95,85,95\"", + .threshold_type = "\"line,branch,method\"", + }); + + // Integration tests. + inline for (.{ "basic", "two-phase", "two-phase-many", "walkthrough" }) |sample| { + log.info("testing sample '{s}'", .{sample}); + + try shell.pushd("./samples/" ++ sample); + defer shell.popd(); + + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + try shell.exec("dotnet run", .{}); + } + + // Container smoke tests. + if (builtin.target.os.tag == .linux) { + // Here, we want to check that our package does not break horrible on upstream containers + // due to missing runtime dependencies, mismatched glibc ABI and similar issues. + // + // We don't necessary want to be able to _build_ code inside such a container, we only + // need to check that pre-built code runs successfully. So, build a package on host, + // mount it inside the container and smoke test. + // + // We run an sh script inside a container, because it is trivial. If it grows larger, + // we should consider running a proper zig program inside. + try shell.exec("dotnet pack --configuration Release", .{}); + + const image_tags = .{ + "8.0", "8.0-alpine", + }; + + inline for (image_tags) |image_tag| { + const image = "mcr.microsoft.com/dotnet/sdk:" ++ image_tag; + log.info("testing docker image: '{s}'", .{image}); + + for (0..5) |attempt| { + if (attempt > 0) std.time.sleep(1 * std.time.ns_per_min); + if (shell.exec("docker image pull {image}", .{ .image = image })) { + break; + } else |_| {} + } + + try shell.exec( + \\docker run + \\--security-opt seccomp=unconfined + \\--volume ./TigerBeetle/bin/Release:/host + \\{image} + \\sh + \\-c {script} + , .{ + .image = image, + .script = + \\set -ex + \\mkdir test-project && cd test-project + \\dotnet nuget add source /host + \\dotnet new console + \\dotnet add package tigerbeetle --source /host > /dev/null + \\cat < Program.cs + \\using System; + \\using TigerBeetle; + \\public class Program { + \\ public static void Main() { + \\ new Client(UInt128.Zero, new [] {"3001"}).Dispose(); + \\ Console.WriteLine("SUCCESS"); + \\ } + \\} + \\EOF + \\dotnet run + , + }); + } + } +} + +pub fn validate_release_package(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, +}) !void { + _ = shell; + _ = gpa; + _ = options; +} + +pub fn validate_release_sample(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, + tigerbeetle: []const u8, +}) !void { + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + .prebuilt = options.tigerbeetle, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const base_dir = shell.cwd; + try shell.pushd_dir(tmp_dir.dir); + defer shell.popd(); + + try shell.exec("dotnet new console", .{}); + + // NuGet may take a few minutes to make the new package available for download. + for (0..9) |_| { + if (try nuget_install(shell, .{ .version = options.release }) == .ok) break; + log.warn("waiting for 5 minutes for the {s} version to appear in nuget.org", .{ + options.release, + }); + std.time.sleep(5 * std.time.ns_per_min); + } else { + switch (try nuget_install(shell, .{ .version = options.release })) { + .ok => {}, + .retry => |err| { + log.err("package is not available in nuget.org", .{}); + return err; + }, + } + } + + try Shell.copy_path( + base_dir, + "src/clients/dotnet/samples/basic/Program.cs", + shell.cwd, + "Program.cs", + ); + try shell.exec("dotnet run", .{}); +} + +fn nuget_install(shell: *Shell, options: struct { + version: []const u8, +}) !union(enum) { ok, retry: anyerror } { + const command: []const u8 = "dotnet add package tigerbeetle --version {version}"; + if (shell.exec(command, options)) { + return .ok; + } else |err| { + const exec_result = try shell.exec_raw(command, options); + switch (exec_result.term) { + .Exited => |code| if (code == 0) return .ok, + else => {}, + } + + // Error message: + // NU1102: Unable to find package tigerbeetle with version (>= {version}). + const package_missing = std.mem.indexOf( + u8, + exec_result.stdout, + "NU1102", + ) != null; + if (package_missing) return .{ .retry = err }; + return err; + } +} + +pub fn release_published_latest(shell: *Shell) ![]const u8 { + const DotnetSearch = struct { + const SearchResult = struct { + const Package = struct { + id: []const u8, + version: []const u8, + }; + packages: []Package, + }; + searchResult: []SearchResult, + }; + + const output = try shell.exec_stdout( + "dotnet package search tigerbeetle --exact-match --format json", + .{}, + ); + const dotnet_search_results = try std.json.parseFromSliceLeaky( + DotnetSearch, + shell.arena.allocator(), + output, + .{ .ignore_unknown_fields = true }, + ); + + assert(dotnet_search_results.searchResult.len == 1); + assert(dotnet_search_results.searchResult[0].packages.len >= 1); + const package_count = dotnet_search_results.searchResult[0].packages.len; + const package_last = dotnet_search_results.searchResult[0].packages[package_count - 1]; + + assert(std.mem.eql(u8, package_last.id, "tigerbeetle")); + + return package_last.version; +} diff --git a/ocam/src/clients/dotnet/docs.zig b/ocam/src/clients/dotnet/docs.zig new file mode 100644 index 00000000..43a365a4 --- /dev/null +++ b/ocam/src/clients/dotnet/docs.zig @@ -0,0 +1,79 @@ +const Docs = @import("../docs_types.zig").Docs; + +pub const DotnetDocs = Docs{ + .directory = "dotnet", + + .markdown_name = "cs", + .extension = "cs", + .proper_name = ".NET", + + .test_source_path = "", + + .name = "tigerbeetle-dotnet", + .description = + \\The TigerBeetle client for .NET. + , + + .prerequisites = + \\* .NET >= 8.0. + \\ + \\And if you do not already have NuGet.org as a package + \\source, make sure to add it: + \\ + \\```console + \\dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org + \\``` + , + + .project_file_name = "", + .project_file = "", + + .test_file_name = "Program", + + .install_commands = + \\dotnet new console + \\dotnet add package tigerbeetle + , + .run_commands = "dotnet run", + + .examples = "", + + .client_object_documentation = "", + .create_accounts_documentation = + \\The `UInt128` fields like `ID`, `UserData128`, `Amount` and + \\account balances have a few extension methods to make it easier + \\to convert 128-bit little-endian unsigned integers between + \\`BigInteger`, `byte[]`, and `Guid`. + \\ + \\See the class [UInt128Extensions](/src/clients/dotnet/TigerBeetle/UInt128Extensions.cs) + \\for more details. + , + + .account_flags_documentation = + \\To toggle behavior for an account, combine enum values stored in the + \\`AccountFlags` object with bitwise-or: + \\ + \\* `AccountFlags.None` + \\* `AccountFlags.Linked` + \\* `AccountFlags.DebitsMustNotExceedCredits` + \\* `AccountFlags.CreditsMustNotExceedDebits` + \\* `AccountFlags.History` + , + + .create_accounts_errors_documentation = "", + + .create_transfers_documentation = "", + + .create_transfers_errors_documentation = "", + + .transfer_flags_documentation = + \\To toggle behavior for an account, combine enum values stored in the + \\`TransferFlags` object with bitwise-or: + \\ + \\* `TransferFlags.None` + \\* `TransferFlags.Linked` + \\* `TransferFlags.Pending` + \\* `TransferFlags.PostPendingTransfer` + \\* `TransferFlags.VoidPendingTransfer` + , +}; diff --git a/ocam/src/clients/dotnet/dotnet_bindings.zig b/ocam/src/clients/dotnet/dotnet_bindings.zig new file mode 100644 index 00000000..5993b7b7 --- /dev/null +++ b/ocam/src/clients/dotnet/dotnet_bindings.zig @@ -0,0 +1,528 @@ +const std = @import("std"); +const vsr = @import("vsr"); + +const assert = std.debug.assert; +const stdx = vsr.stdx; +const tb = vsr.tigerbeetle; +const exports = vsr.tb_client.exports; + +const TypeMapping = struct { + name: []const u8, + visibility: enum { public, internal }, + private_fields: []const []const u8 = &.{}, + readonly_fields: []const []const u8 = &.{}, + docs_link: ?[]const u8 = null, + constants: []const u8 = "", + + pub fn is_private(comptime self: @This(), name: []const u8) bool { + inline for (self.private_fields) |field| { + if (std.mem.eql(u8, field, name)) { + return true; + } + } else return false; + } + + pub fn is_read_only(comptime self: @This(), name: []const u8) bool { + inline for (self.readonly_fields) |field| { + if (std.mem.eql(u8, field, name)) { + return true; + } + } else return false; + } +}; + +const type_mappings = .{ + .{ tb.AccountFlags, TypeMapping{ + .name = "AccountFlags", + .visibility = .public, + .private_fields = &.{"padding"}, + .docs_link = "reference/account#flags", + } }, + .{ tb.TransferFlags, TypeMapping{ + .name = "TransferFlags", + .visibility = .public, + .private_fields = &.{"padding"}, + .docs_link = "reference/transfer#flags", + } }, + .{ tb.AccountFilterFlags, TypeMapping{ + .name = "AccountFilterFlags", + .visibility = .public, + .private_fields = &.{"padding"}, + .docs_link = "reference/account-filter#flags", + } }, + .{ tb.QueryFilterFlags, TypeMapping{ + .name = "QueryFilterFlags", + .visibility = .public, + .private_fields = &.{"padding"}, + .docs_link = "reference/query-filter#flags", + } }, + .{ tb.Account, TypeMapping{ + .name = "Account", + .visibility = .public, + .private_fields = &.{"reserved"}, + .readonly_fields = &.{ + "debits_pending", + "credits_pending", + "debits_posted", + "credits_posted", + }, + .docs_link = "reference/account#", + } }, + .{ + tb.Transfer, TypeMapping{ + .name = "Transfer", + .visibility = .public, + .private_fields = &.{"reserved"}, + .readonly_fields = &.{}, + .docs_link = "reference/transfer#", + .constants = + \\ public static UInt128 AmountMax => UInt128.MaxValue; + \\ + , + }, + }, + .{ tb.CreateAccountStatus, TypeMapping{ + .name = "CreateAccountStatus", + .visibility = .public, + .docs_link = "reference/requests/create_accounts#", + } }, + .{ tb.CreateTransferStatus, TypeMapping{ + .name = "CreateTransferStatus", + .visibility = .public, + .docs_link = "reference/requests/create_transfers#", + } }, + .{ tb.CreateAccountResult, TypeMapping{ + .name = "CreateAccountResult", + .visibility = .public, + .private_fields = &.{"reserved"}, + } }, + .{ tb.CreateTransferResult, TypeMapping{ + .name = "CreateTransferResult", + .visibility = .public, + .private_fields = &.{"reserved"}, + } }, + .{ tb.AccountFilter, TypeMapping{ + .name = "AccountFilter", + .visibility = .public, + .private_fields = &.{"reserved"}, + .docs_link = "reference/account-filter#", + } }, + .{ tb.AccountBalance, TypeMapping{ + .name = "AccountBalance", + .visibility = .public, + .private_fields = &.{"reserved"}, + .docs_link = "reference/account-balances#", + } }, + .{ tb.QueryFilter, TypeMapping{ + .name = "QueryFilter", + .visibility = .public, + .private_fields = &.{"reserved"}, + .docs_link = "reference/query-filter#", + } }, + .{ exports.tb_init_status, TypeMapping{ + .name = "InitializationStatus", + .visibility = .public, + } }, + .{ exports.tb_client_status, TypeMapping{ + .name = "ClientStatus", + .visibility = .internal, + } }, + .{ exports.tb_packet_status, TypeMapping{ + .name = "PacketStatus", + .visibility = .internal, + } }, + .{ exports.tb_operation, TypeMapping{ + .name = "TBOperation", + .visibility = .internal, + .private_fields = &.{ "reserved", "root", "register" }, + } }, + .{ exports.tb_client_t, TypeMapping{ + .name = "TBClient", + .visibility = .internal, + .private_fields = &.{"opaque"}, + } }, + .{ exports.tb_packet_t, TypeMapping{ + .name = "TBPacket", + .visibility = .internal, + .private_fields = &.{"opaque"}, + } }, +}; + +fn dotnet_type(comptime Type: type) []const u8 { + switch (@typeInfo(Type)) { + .@"enum", .@"struct" => return comptime get_mapped_type_name(Type) orelse + @compileError("Type " ++ @typeName(Type) ++ " not mapped."), + .bool => return "byte", + .int => |info| { + assert(info.signedness == .unsigned); + return switch (info.bits) { + 8 => "byte", + 16 => "ushort", + 32 => "uint", + 64 => "ulong", + 128 => "UInt128", + else => @compileError("invalid int type"), + }; + }, + .optional => |info| switch (@typeInfo(info.child)) { + .pointer => return dotnet_type(info.child), + else => @compileError("Unsupported optional type: " ++ @typeName(Type)), + }, + .pointer => |info| { + assert(info.size != .slice); + assert(!info.is_allowzero); + + return if (comptime get_mapped_type_name(info.child)) |name| + name ++ "*" + else + dotnet_type(info.child); + }, + .void, .@"opaque" => return "IntPtr", + else => @compileError("Unhandled type: " ++ @typeName(Type)), + } +} + +fn get_mapped_type_name(comptime Type: type) ?[]const u8 { + inline for (type_mappings) |type_mapping| { + if (Type == type_mapping[0]) { + return type_mapping[1].name; + } + } else return null; +} + +fn emit_enum( + buffer: *std.ArrayList(u8), + comptime Type: type, + comptime type_info: anytype, + comptime mapping: TypeMapping, + comptime int_type: []const u8, +) !void { + const is_packed_struct = @TypeOf(type_info) == std.builtin.Type.Struct; + if (is_packed_struct) { + assert(type_info.layout == .@"packed"); + // Packed structs represented as Enum needs a Flags attribute: + try buffer.writer().print("[Flags]\n", .{}); + } + + try buffer.writer().print( + \\{s} enum {s} : {s} + \\{{ + \\ + , .{ + @tagName(mapping.visibility), + mapping.name, + int_type, + }); + + if (is_packed_struct) { + // Packed structs represented as Enum needs a ZERO value: + try buffer.writer().print( + \\ None = 0, + \\ + \\ + , .{}); + } + + inline for (type_info.fields, 0..) |field, i| { + if (comptime mapping.is_private(field.name)) continue; + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + + try emit_docs(buffer, mapping, field.name); + if (is_packed_struct) { + try buffer.writer().print(" {s} = 1 << {},\n\n", .{ + stdx.to_case(field.name, .PascalCase), + i, + }); + } else { + const int_value = @intFromEnum(@field(Type, field.name)); + try buffer.writer().print(" {s} = {s},\n\n", .{ + stdx.to_case(field.name, .PascalCase), + if (int_value == std.math.maxInt(@TypeOf(int_value))) + std.fmt.comptimePrint("0x{X}", .{int_value}) + else + std.fmt.comptimePrint("{}", .{int_value}), + }); + } + } + + try buffer.writer().print( + \\}} + \\ + \\ + , .{}); +} + +fn emit_struct( + buffer: *std.ArrayList(u8), + comptime type_info: anytype, + comptime mapping: TypeMapping, + comptime size: usize, +) !void { + try buffer.writer().print( + \\[StructLayout(LayoutKind.Sequential, Size = SIZE)] + \\{s} {s}struct {s} + \\{{ + \\ public const int SIZE = {}; + \\ + \\{s} + \\ + , .{ + @tagName(mapping.visibility), + if (mapping.visibility == .internal) "unsafe " else "", + mapping.name, + size, + mapping.constants, + }); + + // Fixed len array are exposed as internal structs with stackalloc fields + // It's more efficient than exposing heap-allocated arrays using + // [MarshalAs(UnmanagedType.ByValArray)] attribute. + inline for (type_info.fields) |field| { + switch (@typeInfo(field.type)) { + .array => |array| { + try buffer.writer().print( + \\ [StructLayout(LayoutKind.Sequential, Size = {[name]s}Data.SIZE)] + \\ private unsafe struct {[name]s}Data + \\ {{ + \\ public const int SIZE = {[size]}; + \\ private const int LENGTH = {[len]}; + \\ + \\ private fixed {[child_type]s} raw[LENGTH]; + \\ + \\ public {[child_type]s}[] GetData() + \\ {{ + \\ fixed (void* ptr = raw) + \\ {{ + \\ return new ReadOnlySpan<{[child_type]s}>(ptr, LENGTH).ToArray(); + \\ }} + \\ }} + \\ + \\ public void SetData({[child_type]s}[] value) + \\ {{ + \\ if (value == null) throw new ArgumentNullException(nameof(value)); + \\ if (value.Length != LENGTH) + \\ {{ + \\ throw new ArgumentException( + \\ "Expected a {[child_type]s}[" + LENGTH + "] array", + \\ nameof(value)); + \\ }} + \\ + \\ fixed (void* ptr = raw) + \\ {{ + \\ value.CopyTo(new Span<{[child_type]s}>(ptr, LENGTH)); + \\ }} + \\ }} + \\ }} + \\ + \\ + , .{ + .name = stdx.to_case(field.name, .PascalCase), + .size = array.len * @sizeOf(array.child), + .len = array.len, + .child_type = dotnet_type(array.child), + }); + }, + else => {}, + } + } + + // Fields + inline for (type_info.fields) |field| { + const is_private = comptime mapping.is_private(field.name); + + switch (@typeInfo(field.type)) { + .array => try buffer.writer().print( + \\ {s} {s}Data {s}; + \\ + \\ + , + .{ + if (mapping.visibility == .internal and !is_private) "public" else "private", + stdx.to_case(field.name, .PascalCase), + stdx.to_case(field.name, .camelCase), + }, + ), + else => try buffer.writer().print( + \\ {s} {s} {s}; + \\ + \\ + , + .{ + if (mapping.visibility == .internal and !is_private) "public" else "private", + dotnet_type(field.type), + stdx.to_case(field.name, .camelCase), + }, + ), + } + } + + if (mapping.visibility == .public) { + + // Properties + inline for (type_info.fields) |field| { + try emit_docs(buffer, mapping, field.name); + + const is_private = comptime mapping.is_private(field.name); + const is_read_only = comptime mapping.is_read_only(field.name); + + switch (@typeInfo(field.type)) { + .array => try buffer.writer().print( + \\ {s} byte[] {s} {{ get => {s}.GetData(); {s}set => {s}.SetData(value); }} + \\ + \\ + , .{ + if (is_private) "internal" else "public", + stdx.to_case(field.name, .PascalCase), + stdx.to_case(field.name, .camelCase), + if (is_read_only and !is_private) "internal " else "", + stdx.to_case(field.name, .camelCase), + }), + else => try buffer.writer().print( + \\ {s} {s} {s} {{ get => {s}; {s}set => {s} = value; }} + \\ + \\ + , .{ + if (is_private) "internal" else "public", + dotnet_type(field.type), + stdx.to_case(field.name, .PascalCase), + stdx.to_case(field.name, .camelCase), + if (is_read_only and !is_private) "internal " else "", + stdx.to_case(field.name, .camelCase), + }), + } + } + } + + try buffer.writer().print( + \\}} + \\ + \\ + , .{}); +} + +fn emit_docs(buffer: anytype, comptime mapping: TypeMapping, comptime field: ?[]const u8) !void { + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ /// + \\ /// https://docs.tigerbeetle.com/{s}{s} + \\ /// + \\ + , .{ + docs_link, + field orelse "", + }); + } +} + +pub fn generate_bindings(buffer: *std.ArrayList(u8)) !void { + @setEvalBranchQuota(100_000); + + try buffer.writer().print( + \\////////////////////////////////////////////////////////// + \\// This file was auto-generated by dotnet_bindings.zig // + \\// Do not manually modify. // + \\////////////////////////////////////////////////////////// + \\ + \\using System; + \\using System.Runtime.InteropServices; + \\ + \\namespace TigerBeetle; + \\ + \\ + , .{}); + + // Emit C# declarations. + inline for (type_mappings) |type_mapping| { + const ZigType = type_mapping[0]; + const mapping = type_mapping[1]; + + switch (@typeInfo(ZigType)) { + .@"struct" => |info| switch (info.layout) { + .auto => @compileError( + "Only packed or extern structs are supported: " ++ @typeName(ZigType), + ), + .@"packed" => try emit_enum( + buffer, + ZigType, + info, + mapping, + comptime dotnet_type( + std.meta.Int(.unsigned, @bitSizeOf(ZigType)), + ), + ), + .@"extern" => try emit_struct( + buffer, + info, + mapping, + @sizeOf(ZigType), + ), + }, + .@"enum" => |info| try emit_enum( + buffer, + ZigType, + info, + mapping, + comptime dotnet_type(std.meta.Int(.unsigned, @bitSizeOf(ZigType))), + ), + else => @compileError("Type cannot be represented: " ++ @typeName(ZigType)), + } + } + + // Emit function declarations. + // TODO: use `std.meta.declaractions` and generate with pub + export functions. + // Zig 0.9.1 has `decl.data.Fn.arg_names` but it's currently/incorrectly a zero-sized slice. + try buffer.writer().print( + \\internal static class Native + \\{{ + \\ private const string LIB_NAME = "tb_client"; + \\ + \\ [DllImport(LIB_NAME, CallingConvention = CallingConvention.Cdecl)] + \\ public static unsafe extern InitializationStatus tb_client_init( + \\ TBClient* client_out, + \\ UInt128Extensions.UnsafeU128* cluster_id, + \\ byte* address_ptr, + \\ uint address_len, + \\ IntPtr completion_ctx, + \\ delegate* unmanaged[Cdecl] completion_callback + \\ ); + \\ + \\ [DllImport(LIB_NAME, CallingConvention = CallingConvention.Cdecl)] + \\ public static unsafe extern InitializationStatus tb_client_init_echo( + \\ TBClient* out_client, + \\ UInt128Extensions.UnsafeU128* cluster_id, + \\ byte* address_ptr, + \\ uint address_len, + \\ IntPtr completion_ctx, + \\ delegate* unmanaged[Cdecl] completion_callback + \\ ); + \\ + \\ [DllImport(LIB_NAME, CallingConvention = CallingConvention.Cdecl)] + \\ public static unsafe extern ClientStatus tb_client_submit( + \\ TBClient* client, + \\ TBPacket* packet + \\ ); + \\ + \\ [DllImport(LIB_NAME, CallingConvention = CallingConvention.Cdecl)] + \\ public static unsafe extern ClientStatus tb_client_deinit( + \\ TBClient* client + \\ ); + \\}} + \\ + \\ + , .{}); +} + +pub fn main() !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var buffer = std.ArrayList(u8).init(allocator); + try generate_bindings(&buffer); + + try std.io.getStdOut().writeAll(buffer.items); +} diff --git a/ocam/src/clients/dotnet/samples/basic/Basic.csproj b/ocam/src/clients/dotnet/samples/basic/Basic.csproj new file mode 100644 index 00000000..915694c0 --- /dev/null +++ b/ocam/src/clients/dotnet/samples/basic/Basic.csproj @@ -0,0 +1,20 @@ + + + Exe + net8.0 + enable + enable + LatestMajor + true + false + true + + + + + PreserveNewest + + + + + diff --git a/ocam/src/clients/dotnet/samples/basic/Program.cs b/ocam/src/clients/dotnet/samples/basic/Program.cs new file mode 100644 index 00000000..ea54c1d9 --- /dev/null +++ b/ocam/src/clients/dotnet/samples/basic/Program.cs @@ -0,0 +1,69 @@ +using System; +using System.Diagnostics; + +using TigerBeetle; + +var tbAddress = Environment.GetEnvironmentVariable("TB_ADDRESS"); +using (var client = new Client( + clusterID: UInt128.Zero, + addresses: new[] { tbAddress != null ? tbAddress : "3000" } + )) +{ + + // Create two accounts + var accounts = new[] { + new Account + { + Id = 1, + Ledger= 1, + Code = 1, + }, + new Account + { + Id = 2, + Ledger = 1, + Code = 1, + }, + }; + + var accountResults = client.CreateAccounts(accounts); + Debug.Assert(accountResults.Length == 2); + Debug.Assert(accountResults[0].Status == CreateAccountStatus.Created); + Debug.Assert(accountResults[1].Status == CreateAccountStatus.Created); + + var transfers = new[] { + new Transfer + { + Id = 1, + DebitAccountId = 1, + CreditAccountId = 2, + Ledger = 1, + Code = 1, + Amount = 10, + } + }; + + var transferResults = client.CreateTransfers(transfers); + Debug.Assert(transferResults.Length == 1); + Debug.Assert(transferResults[0].Status == CreateTransferStatus.Created); + + accounts = client.LookupAccounts(new UInt128[] { 1, 2 }); + Debug.Assert(accounts.Length == 2); + foreach (var account in accounts) + { + if (account.Id == 1) + { + Debug.Assert(account.DebitsPosted == 10); + Debug.Assert(account.CreditsPosted == 0); + } + else if (account.Id == 2) + { + Debug.Assert(account.DebitsPosted == 0); + Debug.Assert(account.CreditsPosted == 10); + } + else + { + throw new Exception("Unexpected account"); + } + } +} diff --git a/ocam/src/clients/dotnet/samples/basic/README.md b/ocam/src/clients/dotnet/samples/basic/README.md new file mode 100644 index 00000000..9b0392a5 --- /dev/null +++ b/ocam/src/clients/dotnet/samples/basic/README.md @@ -0,0 +1,69 @@ + +# Basic .NET Sample + +Code for this sample is in [./Program.cs](./Program.cs). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* .NET >= 8.0. + +And if you do not already have NuGet.org as a package +source, make sure to add it: + +```console +dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org +``` + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/dotnet/samples/basic`. + +Then, install the TigerBeetle client: + +```console +dotnet new console +dotnet add package tigerbeetle +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +dotnet run +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create a transfer + +Then it transfers `10` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate account balances + +Then it fetches both accounts, checks they both exist, and +checks that **account `1`** has: + * `debits_posted = 10` + * and `credits_posted = 0` + +And that **account `2`** has: + * `debits_posted= 0` + * and `credits_posted = 10` diff --git a/ocam/src/clients/dotnet/samples/two-phase-many/Program.cs b/ocam/src/clients/dotnet/samples/two-phase-many/Program.cs new file mode 100644 index 00000000..efd8edf7 --- /dev/null +++ b/ocam/src/clients/dotnet/samples/two-phase-many/Program.cs @@ -0,0 +1,328 @@ +using System; +using System.Diagnostics; + +using TigerBeetle; + +var tbAddress = Environment.GetEnvironmentVariable("TB_ADDRESS"); +using (var client = new Client( + clusterID: UInt128.Zero, + addresses: new[] { tbAddress != null ? tbAddress : "3000" } + )) +{ + + // Create two accounts + var accounts = new[] { + new Account + { + Id = 1, + Ledger= 1, + Code = 1, + }, + new Account + { + Id = 2, + Ledger = 1, + Code = 1, + }, + }; + + var accountResults = client.CreateAccounts(accounts); + Debug.Assert(accountResults.Length == 2); + Debug.Assert(accountResults[0].Status == CreateAccountStatus.Created); + Debug.Assert(accountResults[1].Status == CreateAccountStatus.Created); + + // Start five pending transfers. + var transferResults = client.CreateTransfers(new[] { + new Transfer + { + Id = 1, + DebitAccountId = 1, + CreditAccountId = 2, + Ledger = 1, + Code = 1, + Amount = 100, + Flags = TransferFlags.Pending, + }, + new Transfer + { + Id = 2, + DebitAccountId = 1, + CreditAccountId = 2, + Ledger = 1, + Code = 1, + Amount = 200, + Flags = TransferFlags.Pending, + }, + new Transfer + { + Id = 3, + DebitAccountId = 1, + CreditAccountId = 2, + Ledger = 1, + Code = 1, + Amount = 300, + Flags = TransferFlags.Pending, + }, + new Transfer + { + Id = 4, + DebitAccountId = 1, + CreditAccountId = 2, + Ledger = 1, + Code = 1, + Amount = 400, + Flags = TransferFlags.Pending, + }, + new Transfer + { + Id = 5, + DebitAccountId = 1, + CreditAccountId = 2, + Ledger = 1, + Code = 1, + Amount = 500, + Flags = TransferFlags.Pending, + } + }); + Debug.Assert(transferResults.Length == 5); + foreach (var result in transferResults) + { + Debug.Assert(result.Status == CreateTransferStatus.Created); + } + + // Validate accounts pending and posted debits/credits before + // finishing the two-phase transfer. + accounts = client.LookupAccounts(new UInt128[] { 1, 2 }); + Debug.Assert(accounts.Length == 2); + foreach (var account in accounts) + { + if (account.Id == 1) + { + Debug.Assert(account.DebitsPosted == 0); + Debug.Assert(account.CreditsPosted == 0); + Debug.Assert(account.DebitsPending == 1500); + Debug.Assert(account.CreditsPending == 0); + } + else if (account.Id == 2) + { + Debug.Assert(account.DebitsPosted == 0); + Debug.Assert(account.CreditsPosted == 0); + Debug.Assert(account.DebitsPending == 0); + Debug.Assert(account.CreditsPending == 1500); + } + else + { + throw new Exception("Unexpected account"); + } + } + + // Create a 6th transfer posting the 1st transfer. + transferResults = client.CreateTransfers(new[] { + new Transfer + { + Id = 6, + PendingId = 1, + DebitAccountId = 1, + CreditAccountId = 2, + Ledger = 1, + Code = 1, + Amount = 100, + Flags = TransferFlags.PostPendingTransfer, + } + }); + Debug.Assert(transferResults.Length == 1); + Debug.Assert(transferResults[0].Status == CreateTransferStatus.Created); + + // Validate account balances after posting 1st pending transfer. + accounts = client.LookupAccounts(new UInt128[] { 1, 2 }); + Debug.Assert(accounts.Length == 2); + foreach (var account in accounts) + { + if (account.Id == 1) + { + Debug.Assert(account.DebitsPosted == 100); + Debug.Assert(account.CreditsPosted == 0); + Debug.Assert(account.DebitsPending == 1400); + Debug.Assert(account.CreditsPending == 0); + } + else if (account.Id == 2) + { + Debug.Assert(account.DebitsPosted == 0); + Debug.Assert(account.CreditsPosted == 100); + Debug.Assert(account.DebitsPending == 0); + Debug.Assert(account.CreditsPending == 1400); + } + else + { + throw new Exception("Unexpected account"); + } + } + + // Create a 7th transfer voiding the 2nd transfer. + transferResults = client.CreateTransfers(new[] { + new Transfer + { + Id = 7, + PendingId = 2, + DebitAccountId = 1, + CreditAccountId = 2, + Ledger = 1, + Code = 1, + Amount = 200, + Flags = TransferFlags.VoidPendingTransfer, + } + }); + Debug.Assert(transferResults.Length == 1); + Debug.Assert(transferResults[0].Status == CreateTransferStatus.Created); + + // Validate account balances after voiding 2nd pending transfer. + accounts = client.LookupAccounts(new UInt128[] { 1, 2 }); + Debug.Assert(accounts.Length == 2); + foreach (var account in accounts) + { + if (account.Id == 1) + { + Debug.Assert(account.DebitsPosted == 100); + Debug.Assert(account.CreditsPosted == 0); + Debug.Assert(account.DebitsPending == 1200); + Debug.Assert(account.CreditsPending == 0); + } + else if (account.Id == 2) + { + Debug.Assert(account.DebitsPosted == 0); + Debug.Assert(account.CreditsPosted == 100); + Debug.Assert(account.DebitsPending == 0); + Debug.Assert(account.CreditsPending == 1200); + } + else + { + throw new Exception("Unexpected account"); + } + } + + // Create an 8th transfer posting the 3rd transfer. + transferResults = client.CreateTransfers(new[] { + new Transfer + { + Id = 8, + PendingId = 3, + DebitAccountId = 1, + CreditAccountId = 2, + Ledger = 1, + Code = 1, + Amount = 300, + Flags = TransferFlags.PostPendingTransfer, + } + }); + Debug.Assert(transferResults.Length == 1); + Debug.Assert(transferResults[0].Status == CreateTransferStatus.Created); + + // Validate account balances after posting 3rd pending transfer. + accounts = client.LookupAccounts(new UInt128[] { 1, 2 }); + Debug.Assert(accounts.Length == 2); + foreach (var account in accounts) + { + if (account.Id == 1) + { + Debug.Assert(account.DebitsPosted == 400); + Debug.Assert(account.CreditsPosted == 0); + Debug.Assert(account.DebitsPending == 900); + Debug.Assert(account.CreditsPending == 0); + } + else if (account.Id == 2) + { + Debug.Assert(account.DebitsPosted == 0); + Debug.Assert(account.CreditsPosted == 400); + Debug.Assert(account.DebitsPending == 0); + Debug.Assert(account.CreditsPending == 900); + } + else + { + throw new Exception("Unexpected account"); + } + } + + // Create a 9th transfer voiding the 4th transfer. + transferResults = client.CreateTransfers(new[] { + new Transfer + { + Id = 9, + PendingId = 4, + DebitAccountId = 1, + CreditAccountId = 2, + Ledger = 1, + Code = 1, + Amount = 400, + Flags = TransferFlags.VoidPendingTransfer, + } + }); + Debug.Assert(transferResults.Length == 1); + Debug.Assert(transferResults[0].Status == CreateTransferStatus.Created); + + // Validate account balances after voiding 4th pending transfer. + accounts = client.LookupAccounts(new UInt128[] { 1, 2 }); + Debug.Assert(accounts.Length == 2); + foreach (var account in accounts) + { + if (account.Id == 1) + { + Debug.Assert(account.DebitsPosted == 400); + Debug.Assert(account.CreditsPosted == 0); + Debug.Assert(account.DebitsPending == 500); + Debug.Assert(account.CreditsPending == 0); + } + else if (account.Id == 2) + { + Debug.Assert(account.DebitsPosted == 0); + Debug.Assert(account.CreditsPosted == 400); + Debug.Assert(account.DebitsPending == 0); + Debug.Assert(account.CreditsPending == 500); + } + else + { + throw new Exception("Unexpected account"); + } + } + + // Create a 10th transfer posting the 5th transfer. + transferResults = client.CreateTransfers(new[] { + new Transfer + { + Id = 10, + PendingId = 5, + DebitAccountId = 1, + CreditAccountId = 2, + Ledger = 1, + Code = 1, + Amount = 500, + Flags = TransferFlags.PostPendingTransfer, + } + }); + Debug.Assert(transferResults.Length == 1); + Debug.Assert(transferResults[0].Status == CreateTransferStatus.Created); + + // Validate account balances after posting 5th pending transfer. + accounts = client.LookupAccounts(new UInt128[] { 1, 2 }); + Debug.Assert(accounts.Length == 2); + foreach (var account in accounts) + { + if (account.Id == 1) + { + Debug.Assert(account.DebitsPosted == 900); + Debug.Assert(account.CreditsPosted == 0); + Debug.Assert(account.DebitsPending == 0); + Debug.Assert(account.CreditsPending == 0); + } + else if (account.Id == 2) + { + Debug.Assert(account.DebitsPosted == 0); + Debug.Assert(account.CreditsPosted == 900); + Debug.Assert(account.DebitsPending == 0); + Debug.Assert(account.CreditsPending == 0); + } + else + { + throw new Exception("Unexpected account"); + } + } +} diff --git a/ocam/src/clients/dotnet/samples/two-phase-many/README.md b/ocam/src/clients/dotnet/samples/two-phase-many/README.md new file mode 100644 index 00000000..a2652707 --- /dev/null +++ b/ocam/src/clients/dotnet/samples/two-phase-many/README.md @@ -0,0 +1,99 @@ + +# Many Two-Phase Transfers .NET Sample + +Code for this sample is in [./Program.cs](./Program.cs). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* .NET >= 8.0. + +And if you do not already have NuGet.org as a package +source, make sure to add it: + +```console +dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org +``` + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/dotnet/samples/two-phase-many`. + +Then, install the TigerBeetle client: + +```console +dotnet new console +dotnet add package tigerbeetle +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +dotnet run +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfers + +Then it begins 5 pending transfers of amounts `100` to +`500`, incrementing by `100` for each transfer. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 1500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 1500` + +(This is because a pending transfer only affects **pending** +credits and debits on accounts, not **posted** credits and +debits.) + +## 4. Post and void alternating transfers + +Then it alternatively posts and voids each transfer, +checking account balances after each transfer. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +solely *posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 900` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 900` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/dotnet/samples/two-phase-many/TwoPhaseMany.csproj b/ocam/src/clients/dotnet/samples/two-phase-many/TwoPhaseMany.csproj new file mode 100644 index 00000000..915694c0 --- /dev/null +++ b/ocam/src/clients/dotnet/samples/two-phase-many/TwoPhaseMany.csproj @@ -0,0 +1,20 @@ + + + Exe + net8.0 + enable + enable + LatestMajor + true + false + true + + + + + PreserveNewest + + + + + diff --git a/ocam/src/clients/dotnet/samples/two-phase/Program.cs b/ocam/src/clients/dotnet/samples/two-phase/Program.cs new file mode 100644 index 00000000..9478d477 --- /dev/null +++ b/ocam/src/clients/dotnet/samples/two-phase/Program.cs @@ -0,0 +1,135 @@ +using System; +using System.Diagnostics; + +using TigerBeetle; + +var tbAddress = Environment.GetEnvironmentVariable("TB_ADDRESS"); +using (var client = new Client( + clusterID: UInt128.Zero, + addresses: new[] { tbAddress != null ? tbAddress : "3000" } + )) +{ + + // Create two accounts + var accounts = new[] { + new Account + { + Id = 1, + Ledger= 1, + Code = 1, + }, + new Account + { + Id = 2, + Ledger = 1, + Code = 1, + }, + }; + + var accountResults = client.CreateAccounts(accounts); + Debug.Assert(accountResults.Length == 2); + Debug.Assert(accountResults[0].Status == CreateAccountStatus.Created); + Debug.Assert(accountResults[1].Status == CreateAccountStatus.Created); + + // Start a pending transfer + var transferResults = client.CreateTransfers(new[] { + new Transfer + { + Id = 1, + DebitAccountId = 1, + CreditAccountId = 2, + Amount = 500, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Pending, + } + }); + Debug.Assert(transferResults.Length == 1); + Debug.Assert(transferResults[0].Status == CreateTransferStatus.Created); + + // Validate accounts pending and posted debits/credits before finishing the two-phase transfer + accounts = client.LookupAccounts(new UInt128[] { 1, 2 }); + Debug.Assert(accounts.Length == 2); + foreach (var account in accounts) + { + if (account.Id == 1) + { + Debug.Assert(account.DebitsPosted == 0); + Debug.Assert(account.CreditsPosted == 0); + Debug.Assert(account.DebitsPending == 500); + Debug.Assert(account.CreditsPending == 0); + } + else if (account.Id == 2) + { + Debug.Assert(account.DebitsPosted == 0); + Debug.Assert(account.CreditsPosted == 0); + Debug.Assert(account.DebitsPending == 0); + Debug.Assert(account.CreditsPending == 500); + } + else + { + throw new Exception("Unexpected account"); + } + } + + // Create a second transfer simply posting the first transfer + transferResults = client.CreateTransfers(new[] { + new Transfer + { + Id = 2, + PendingId = 1, + DebitAccountId = 1, + CreditAccountId = 2, + Ledger = 1, + Code = 1, + Amount = 500, + Flags = TransferFlags.PostPendingTransfer, + } + }); + Debug.Assert(transferResults.Length == 1); + Debug.Assert(transferResults[0].Status == CreateTransferStatus.Created); + + // Validate the contents of all transfers + var transfers = client.LookupTransfers(new UInt128[] { 1, 2 }); + Debug.Assert(transfers.Length == 2); + foreach (var transfer in transfers) + { + if (transfer.Id == 1) + { + Debug.Assert(transfer.Flags.HasFlag(TransferFlags.Pending)); + } + else if (transfer.Id == 2) + { + Debug.Assert(transfer.Flags.HasFlag(TransferFlags.PostPendingTransfer)); + } + else + { + throw new Exception("Unexpected transfer"); + } + } + + // Validate accounts pending and posted debits/credits after finishing the two-phase transfer + accounts = client.LookupAccounts(new UInt128[] { 1, 2 }); + Debug.Assert(accounts.Length == 2); + foreach (var account in accounts) + { + if (account.Id == 1) + { + Debug.Assert(account.DebitsPosted == 500); + Debug.Assert(account.CreditsPosted == 0); + Debug.Assert(account.DebitsPending == 0); + Debug.Assert(account.CreditsPending == 0); + } + else if (account.Id == 2) + { + Debug.Assert(account.DebitsPosted == 0); + Debug.Assert(account.CreditsPosted == 500); + Debug.Assert(account.DebitsPending == 0); + Debug.Assert(account.CreditsPending == 0); + } + else + { + throw new Exception("Unexpected account"); + } + } +} diff --git a/ocam/src/clients/dotnet/samples/two-phase/README.md b/ocam/src/clients/dotnet/samples/two-phase/README.md new file mode 100644 index 00000000..e57caf7f --- /dev/null +++ b/ocam/src/clients/dotnet/samples/two-phase/README.md @@ -0,0 +1,108 @@ + +# Two-Phase Transfer .NET Sample + +Code for this sample is in [./Program.cs](./Program.cs). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* .NET >= 8.0. + +And if you do not already have NuGet.org as a package +source, make sure to add it: + +```console +dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org +``` + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/dotnet/samples/two-phase`. + +Then, install the TigerBeetle client: + +```console +dotnet new console +dotnet add package tigerbeetle +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +dotnet run +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfer + +Then it begins a +pending transfer of `500` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 500` + +(This is because a pending +transfer only affects **pending** credits and debits on accounts, +not **posted** credits and debits.) + +## 4. Post pending transfer + +Then it creates a second transfer that marks the first +transfer as posted. + +## 5. Fetch and validate transfers + +Then it fetches both transfers, validates +that the two transfers exist, validates that the first +transfer had (and still has) a `pending` flag, and validates +that the second transfer had (and still has) a +`post_pending_transfer` flag. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +*posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 500` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 500` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/dotnet/samples/two-phase/TwoPhase.csproj b/ocam/src/clients/dotnet/samples/two-phase/TwoPhase.csproj new file mode 100644 index 00000000..915694c0 --- /dev/null +++ b/ocam/src/clients/dotnet/samples/two-phase/TwoPhase.csproj @@ -0,0 +1,20 @@ + + + Exe + net8.0 + enable + enable + LatestMajor + true + false + true + + + + + PreserveNewest + + + + + diff --git a/ocam/src/clients/dotnet/samples/walkthrough/Program.cs b/ocam/src/clients/dotnet/samples/walkthrough/Program.cs new file mode 100644 index 00000000..dfed8e16 --- /dev/null +++ b/ocam/src/clients/dotnet/samples/walkthrough/Program.cs @@ -0,0 +1,509 @@ +// section:imports +using System; +using TigerBeetle; + +// Validate import works. +Console.WriteLine("SUCCESS"); +// endsection:imports + +// section:client +var tbAddress = Environment.GetEnvironmentVariable("TB_ADDRESS"); +var clusterID = UInt128.Zero; +var addresses = new[] { tbAddress != null ? tbAddress : "3000" }; +using (var client = new Client(clusterID, addresses)) +{ + // Use client +} +// endsection:client + +// The examples currently throws because the batch is actually invalid (most of fields are +// undefined). Ideally, we prepare a correct batch here while keeping the syntax compact, +// for the example, but for the time being lets prioritize a readable example and just +// swallow the error. + +using (var client = new Client(clusterID, addresses)) +{ + try + { + // section:create-accounts + var accounts = new[] { + new Account + { + Id = ID.Create(), // TigerBeetle time-based ID. + UserData128 = 0, + UserData64 = 0, + UserData32 = 0, + Ledger = 1, + Code = 718, + Flags = AccountFlags.None, + Timestamp = 0, + }, + }; + + var accountResults = client.CreateAccounts(accounts); + // Results handling omitted. + // endsection:create-accounts + } + catch { } + + try + { + // section:account-flags + var account0 = new Account + { + Id = 100, + Ledger = 1, + Code = 1, + Flags = AccountFlags.Linked | AccountFlags.DebitsMustNotExceedCredits, + }; + var account1 = new Account + { + Id = 101, + Ledger = 1, + Code = 1, + Flags = AccountFlags.History, + }; + + var accountResults = client.CreateAccounts(new[] { account0, account1 }); + // Results handling omitted. + // endsection:account-flags + } + catch { } + + try + { + // section:create-accounts-errors + var account0 = new Account + { + Id = 102, + Ledger = 1, + Code = 1, + Flags = AccountFlags.None, + }; + var account1 = new Account + { + Id = 103, + Ledger = 1, + Code = 1, + Flags = AccountFlags.None, + }; + var account2 = new Account + { + Id = 104, + Ledger = 1, + Code = 1, + Flags = AccountFlags.None, + }; + + var accountResults = client.CreateAccounts(new[] { account0, account1, account2 }); + for (int i = 0; i < accountResults.Length; i++) + { + switch (accountResults[i].Status) + { + case CreateAccountStatus.Created: + Console.WriteLine($"Batch account at {i} successfully created with timestamp {accountResults[i].Timestamp}."); + break; + case CreateAccountStatus.Exists: + Console.WriteLine($"Batch account at {i} already exists with timestamp {accountResults[i].Timestamp}."); + break; + default: + Console.WriteLine($"Batch account at {i} failed to create: {accountResults[i].Status}"); + break; + } + } + // endsection:create-accounts-errors + } + catch { } + + try + { + // section:lookup-accounts + Account[] accounts = client.LookupAccounts(new UInt128[] { 100, 101 }); + // endsection:lookup-accounts + } + catch { } + + try + { + // section:create-transfers + var transfers = new[] { + new Transfer + { + Id = ID.Create(), // TigerBeetle time-based ID. + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + UserData128 = 0, + UserData64 = 0, + UserData32 = 0, + Timeout = 0, + Ledger = 1, + Code = 1, + Flags = TransferFlags.None, + Timestamp = 0, + } + }; + + var transferResults = client.CreateTransfers(transfers); + // Results handling omitted. + // endsection:create-transfers + } + catch { } + + try + { + // section:create-transfers-errors + var transfers = new[] { + new Transfer + { + Id = 1, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.None, + }, + new Transfer + { + Id = 2, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.None, + }, + new Transfer + { + Id = 3, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.None, + }, + }; + + var transferResults = client.CreateTransfers(transfers); + for (int i = 0; i < transferResults.Length; i++) + { + switch (transferResults[i].Status) + { + case CreateTransferStatus.Created: + Console.WriteLine($"Batch transfer at {i} successfully created with timestamp {transferResults[i].Timestamp}."); + break; + case CreateTransferStatus.Exists: + Console.WriteLine($"Batch transfer at {i} already exists with timestamp {transferResults[i].Timestamp}."); + break; + default: + Console.WriteLine($"Batch transfer at {i} failed to create: {transferResults[i].Status}"); + break; + } + } + // endsection:create-transfers-errors + } + catch { } + + try + { + // section:batch + var batch = new Transfer[] { }; // Array of transfer to create. + var BATCH_SIZE = 8189; + for (int firstIndex = 0; firstIndex < batch.Length; firstIndex += BATCH_SIZE) + { + var lastIndex = firstIndex + BATCH_SIZE; + if (lastIndex > batch.Length) + { + lastIndex = batch.Length; + } + var transferResults = client.CreateTransfers(batch[firstIndex..lastIndex]); + // Results handling omitted. + } + // endsection:batch + } + catch { } + + try + { + // section:transfer-flags-link + var transfer0 = new Transfer + { + Id = 4, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Linked, + }; + var transfer1 = new Transfer + { + Id = 5, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.None, + }; + + var transferResults = client.CreateTransfers(new[] { transfer0, transfer1 }); + // Results handling omitted. + // endsection:transfer-flags-link + } + catch { } + + try + { + // section:transfer-flags-post + var transfer0 = new Transfer + { + Id = 6, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Pending, + }; + + var transferResults = client.CreateTransfers(new[] { transfer0 }); + // Results handling omitted. + + var transfer1 = new Transfer + { + Id = 7, + // Post the entire pending amount. + Amount = Transfer.AmountMax, + PendingId = 6, + Flags = TransferFlags.PostPendingTransfer, + }; + + transferResults = client.CreateTransfers(new[] { transfer1 }); + // Results handling omitted. + // endsection:transfer-flags-post + } + catch { } + + try + { + // section:transfer-flags-void + var transfer0 = new Transfer + { + Id = 8, + DebitAccountId = 102, + CreditAccountId = 103, + Amount = 10, + Ledger = 1, + Code = 1, + Flags = TransferFlags.Pending, + }; + + var transferResults = client.CreateTransfers(new[] { transfer0 }); + // Results handling omitted. + + var transfer1 = new Transfer + { + Id = 9, + Amount = 0, + PendingId = 8, + Flags = TransferFlags.VoidPendingTransfer, + }; + + transferResults = client.CreateTransfers(new[] { transfer1 }); + // Results handling omitted. + // endsection:transfer-flags-void + } + catch { } + + try + { + // section:lookup-transfers + Transfer[] transfers = client.LookupTransfers(new UInt128[] { 1, 2 }); + // endsection:lookup-transfers + } + catch { } + + try + { + // section:get-account-transfers + var filter = new AccountFilter + { + AccountId = 101, + UserData128 = 0, // No filter by UserData. + UserData64 = 0, + UserData32 = 0, + Code = 0, // No filter by Code. + TimestampMin = 0, // No filter by Timestamp. + TimestampMax = 0, // No filter by Timestamp. + Limit = 10, // Limit to ten transfers at most. + Flags = AccountFilterFlags.Debits | // Include transfer from the debit side. + AccountFilterFlags.Credits | // Include transfer from the credit side. + AccountFilterFlags.Reversed, // Sort by timestamp in reverse-chronological order. + }; + + Transfer[] transfers = client.GetAccountTransfers(filter); + // endsection:get-account-transfers + } + catch { } + + try + { + // section:get-account-balances + var filter = new AccountFilter + { + AccountId = 101, + UserData128 = 0, // No filter by UserData. + UserData64 = 0, + UserData32 = 0, + Code = 0, // No filter by Code. + TimestampMin = 0, // No filter by Timestamp. + TimestampMax = 0, // No filter by Timestamp. + Limit = 10, // Limit to ten balances at most. + Flags = AccountFilterFlags.Debits | // Include transfer from the debit side. + AccountFilterFlags.Credits | // Include transfer from the credit side. + AccountFilterFlags.Reversed, // Sort by timestamp in reverse-chronological order. + }; + + AccountBalance[] accountBalances = client.GetAccountBalances(filter); + // endsection:get-account-balances + } + catch { } + + try + { + // section:query-accounts + var filter = new QueryFilter + { + UserData128 = 1000, // Filter by UserData. + UserData64 = 100, + UserData32 = 10, + Code = 1, // Filter by Code. + Ledger = 0, // No filter by Ledger. + TimestampMin = 0, // No filter by Timestamp. + TimestampMax = 0, // No filter by Timestamp. + Limit = 10, // Limit to ten accounts at most. + Flags = QueryFilterFlags.Reversed, // Sort by timestamp in reverse-chronological order. + }; + + Account[] accounts = client.QueryAccounts(filter); + // endsection:query-accounts + } + catch { } + + try + { + // section:query-transfers + var filter = new QueryFilter + { + UserData128 = 1000, // Filter by UserData + UserData64 = 100, + UserData32 = 10, + Code = 1, // Filter by Code + Ledger = 0, // No filter by Ledger + TimestampMin = 0, // No filter by Timestamp. + TimestampMax = 0, // No filter by Timestamp. + Limit = 10, // Limit to ten transfers at most. + Flags = QueryFilterFlags.Reversed, // Sort by timestamp in reverse-chronological order. + }; + + Transfer[] transfers = client.QueryTransfers(filter); + // endsection:query-transfers + } + catch { } + + try + { + // section:linked-events + var batch = new System.Collections.Generic.List(); + + // An individual transfer (successful): + batch.Add(new Transfer { Id = 1, /* ... rest of transfer ... */ }); + + // A chain of 4 transfers (the last transfer in the chain closes the chain with linked=false): + batch.Add(new Transfer { Id = 2, /* ... rest of transfer ... */ Flags = TransferFlags.Linked }); // Commit/rollback. + batch.Add(new Transfer { Id = 3, /* ... rest of transfer ... */ Flags = TransferFlags.Linked }); // Commit/rollback. + batch.Add(new Transfer { Id = 2, /* ... rest of transfer ... */ Flags = TransferFlags.Linked }); // Fail with exists + batch.Add(new Transfer { Id = 4, /* ... rest of transfer ... */ }); // Fail without committing + + // An individual transfer (successful): + // This should not see any effect from the failed chain above. + batch.Add(new Transfer { Id = 2, /* ... rest of transfer ... */ }); + + // A chain of 2 transfers (the first transfer fails the chain): + batch.Add(new Transfer { Id = 2, /* ... rest of transfer ... */ Flags = TransferFlags.Linked }); + batch.Add(new Transfer { Id = 3, /* ... rest of transfer ... */ }); + + // A chain of 2 transfers (successful): + batch.Add(new Transfer { Id = 3, /* ... rest of transfer ... */ Flags = TransferFlags.Linked }); + batch.Add(new Transfer { Id = 4, /* ... rest of transfer ... */ }); + + var transferResults = client.CreateTransfers(batch.ToArray()); + // Results handling omitted. + // endsection:linked-events + } + catch { } + + try + { + // section:imported-events + // External source of time + ulong historicalTimestamp = 0UL; + var historicalAccounts = new Account[] { /* Loaded from an external source */ }; + var historicalTransfers = new Transfer[] { /* Loaded from an external source */ }; + + // First, load and import all accounts with their timestamps from the historical source. + var accountsBatch = new System.Collections.Generic.List(); + for (var index = 0; index < historicalAccounts.Length; index++) + { + var account = historicalAccounts[index]; + + // Set a unique and strictly increasing timestamp. + historicalTimestamp += 1; + account.Timestamp = historicalTimestamp; + // Set the account as `imported`. + account.Flags = AccountFlags.Imported; + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + if (index < historicalAccounts.Length - 1) + { + account.Flags |= AccountFlags.Linked; + } + + accountsBatch.Add(account); + } + + var accountResults = client.CreateAccounts(accountsBatch.ToArray()); + // Results handling omitted. + + // Then, load and import all transfers with their timestamps from the historical source. + var transfersBatch = new System.Collections.Generic.List(); + for (var index = 0; index < historicalTransfers.Length; index++) + { + var transfer = historicalTransfers[index]; + + // Set a unique and strictly increasing timestamp. + historicalTimestamp += 1; + transfer.Timestamp = historicalTimestamp; + // Set the account as `imported`. + transfer.Flags = TransferFlags.Imported; + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + if (index < historicalTransfers.Length - 1) + { + transfer.Flags |= TransferFlags.Linked; + } + + transfersBatch.Add(transfer); + } + + var transferResults = client.CreateTransfers(transfersBatch.ToArray()); + // Results handling omitted. + // Since it is a linked chain, in case of any error the entire batch is rolled back and can be retried + // with the same historical timestamps without regressing the cluster timestamp. + // endsection:imported-events + } + catch { } +} diff --git a/ocam/src/clients/dotnet/samples/walkthrough/README.md b/ocam/src/clients/dotnet/samples/walkthrough/README.md new file mode 100644 index 00000000..b657597b --- /dev/null +++ b/ocam/src/clients/dotnet/samples/walkthrough/README.md @@ -0,0 +1 @@ +Code from the [top-level README.md](../../README.md) collected into a single runnable project. diff --git a/ocam/src/clients/dotnet/samples/walkthrough/Walkthrough.csproj b/ocam/src/clients/dotnet/samples/walkthrough/Walkthrough.csproj new file mode 100644 index 00000000..915694c0 --- /dev/null +++ b/ocam/src/clients/dotnet/samples/walkthrough/Walkthrough.csproj @@ -0,0 +1,20 @@ + + + Exe + net8.0 + enable + enable + LatestMajor + true + false + true + + + + + PreserveNewest + + + + + diff --git a/ocam/src/clients/go/.gitignore b/ocam/src/clients/go/.gitignore new file mode 100644 index 00000000..bcfac77f --- /dev/null +++ b/ocam/src/clients/go/.gitignore @@ -0,0 +1,12 @@ +zig/ +zig-cache/ +zig-out/ + +tb +zigcc* +*.tigerbeetle +main +main.exe + +native/*.a +native/*.lib \ No newline at end of file diff --git a/ocam/src/clients/go/LICENSE b/ocam/src/clients/go/LICENSE new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/ocam/src/clients/go/LICENSE @@ -0,0 +1,177 @@ + + 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 diff --git a/ocam/src/clients/go/README.md b/ocam/src/clients/go/README.md new file mode 100644 index 00000000..b60519bf --- /dev/null +++ b/ocam/src/clients/go/README.md @@ -0,0 +1,747 @@ + +# tigerbeetle-go + +The TigerBeetle client for Go. + +[![Go Reference](https://pkg.go.dev/badge/github.com/tigerbeetle/tigerbeetle-go.svg)](https://pkg.go.dev/github.com/tigerbeetle/tigerbeetle-go) + +Make sure to import `github.com/tigerbeetle/tigerbeetle-go`, not +this repo and subdirectory. + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Go >= 1.21 + +**Additionally on Windows**: you must install [Zig +0.14.1](https://ziglang.org/download/#release-0.14.1) and set the +`CC` environment variable to `zig.exe cc`. Use the full path for +`zig.exe`. + +## Setup + +First, create a directory for your project and `cd` into the directory. + +Then, install the TigerBeetle client: + +```console +go mod init tbtest +go get github.com/tigerbeetle/tigerbeetle-go +``` + +Now, create `main.go` and copy this into it: + +```go +package main + +import ( + "fmt" + "log" + "os" + + . "github.com/tigerbeetle/tigerbeetle-go" +) + +func main() { + fmt.Println("Import ok!") +} + +``` + +Finally, build and run: + +```console +go run main.go +``` + +Now that all prerequisites and dependencies are correctly set +up, let's dig into using TigerBeetle. + +## Sample projects + +This document is primarily a reference guide to +the client. Below are various sample projects demonstrating +features of TigerBeetle. + +* [Basic](/src/clients/go/samples/basic/): Create two accounts and transfer an amount between them. +* [Two-Phase Transfer](/src/clients/go/samples/two-phase/): Create two accounts and start a pending transfer between +them, then post the transfer. +* [Many Two-Phase Transfers](/src/clients/go/samples/two-phase-many/): Create two accounts and start a number of pending transfers +between them, posting and voiding alternating transfers. + +## Creating a Client + +A client is created with a cluster ID and replica +addresses for all replicas in the cluster. The cluster +ID and replica addresses are both chosen by the system that +starts the TigerBeetle cluster. + +Clients are thread-safe and a single instance should be shared +between multiple concurrent tasks. This allows events to be +[automatically batched](https://docs.tigerbeetle.com/coding/requests/#batching-events). + +Multiple clients are useful when connecting to more than +one TigerBeetle cluster. + +In this example the cluster ID is `0` and there is one +replica. The address is read from the `TB_ADDRESS` +environment variable and defaults to port `3000`. + +```go +tbAddress := os.Getenv("TB_ADDRESS") +if len(tbAddress) == 0 { + tbAddress = "3000" +} +client, err := NewClient(ToUint128(0), []string{tbAddress}) +if err != nil { + log.Printf("Error creating client: %s", err) + return +} +defer client.Close() +``` + +The following are valid addresses: +* `3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1:3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1` (interpreted as `127.0.0.1:3001`, `3001` is the default port) + +## Creating Accounts + +See details for account fields in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account). + +```go +accountResults, err := client.CreateAccounts([]Account{ + { + ID: ID(), // TigerBeetle time-based ID. + UserData128: ToUint128(0), + UserData64: 0, + UserData32: 0, + Ledger: 1, + Code: 718, + Flags: 0, + Timestamp: 0, + }, +}) +// Results handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +The `Uint128` fields like `ID`, `UserData128`, `Amount` and +account balances have a few helper functions to make it easier +to convert 128-bit little-endian unsigned integers between +`string`, `math/big.Int`, and `[]byte`. + +See the type [Uint128](https://pkg.go.dev/github.com/tigerbeetle/tigerbeetle-go/pkg/types#Uint128) for more details. + +### Account Flags + +The account flags value is a bitfield. See details for +these flags in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account#flags). + +To toggle behavior for an account, use the `types.AccountFlags` struct +to combine enum values and generate a `uint16`. Here are a +few examples: + +* `AccountFlags{Linked: true}.ToUint16()` +* `AccountFlags{DebitsMustNotExceedCredits: true}.ToUint16()` +* `AccountFlags{CreditsMustNotExceedDebits: true}.ToUint16()` +* `AccountFlags{History: true}.ToUint16()` + +For example, to link two accounts where the first account +additionally has the `debits_must_not_exceed_credits` constraint: + +```go +account0 := Account{ + ID: ToUint128(100), + Ledger: 1, + Code: 718, + Flags: AccountFlags{ + DebitsMustNotExceedCredits: true, + Linked: true, + }.ToUint16(), +} +account1 := Account{ + ID: ToUint128(101), + Ledger: 1, + Code: 718, + Flags: AccountFlags{ + History: true, + }.ToUint16(), +} + +accountResults, err := client.CreateAccounts([]Account{account0, account1}) +// Results handling omitted. +``` + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each account in the request batch: +- Successfully created accounts with the status + [`created`](https://docs.tigerbeetle.com/reference/requests/create_accounts#created) + return the timestamp assigned to the `Account` object. +- Already existing accounts with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists) + return the timestamp of the original existing object. +- Failed accounts return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_accounts reference](https://docs.tigerbeetle.com/reference/requests/create_accounts#status). + +```go +account0 := Account{ + ID: ToUint128(102), + Ledger: 1, + Code: 718, + Flags: 0, +} +account1 := Account{ + ID: ToUint128(103), + Ledger: 1, + Code: 718, + Flags: 0, +} +account2 := Account{ + ID: ToUint128(104), + Ledger: 1, + Code: 718, + Flags: 0, +} + +accountResults, err := client.CreateAccounts([]Account{account0, account1, account2}) +if err != nil { + log.Printf("Error creating accounts: %s", err) + return +} + +for i, result := range accountResults { + switch result.Status { + case AccountCreated: + log.Printf("Batch account at %d successfully created with timestamp %d.", i, result.Timestamp) + case AccountExists: + log.Printf("Batch account at %d already exists with timestamp %d.", i, result.Timestamp) + default: + log.Printf("Batch account at %d failed to create: %s", i, result.Status) + } +} +``` + +## Account Lookup + +Account lookup is batched, like account creation. Pass +in all IDs to fetch. The account for each matched ID is returned. + +If no account matches an ID, no object is returned for +that account. So the order of accounts in the response is +not necessarily the same as the order of IDs in the +request. You can refer to the ID field in the response to +distinguish accounts. + +```go +accounts, err := client.LookupAccounts([]Uint128{ToUint128(100), ToUint128(101)}) +``` + +## Create Transfers + +This creates a journal entry between two accounts. + +See details for transfer fields in the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer). + +```go +transfers := []Transfer{{ + ID: ID(), // TigerBeetle time-based ID. + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: 0, + Timestamp: 0, +}} + +transferResults, err := client.CreateTransfers(transfers) +// Results handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each transfer in the request batch: +- Successfully created transfers with the result + [`created`](https://docs.tigerbeetle.com/reference/requests/create_transfers#created) + return the timestamp assigned to the `Transfer` object. +- Already existing transfers with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists) + return the timestamp of the original existing object. +- Failed transfers return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_transfers reference](https://docs.tigerbeetle.com/reference/requests/create_transfers#status). + +```go +transfers := []Transfer{{ + ID: ToUint128(1), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: 0, +}, { + ID: ToUint128(2), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: 0, +}, { + ID: ToUint128(3), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: 0, +}} + +transferResults, err := client.CreateTransfers(transfers) +if err != nil { + log.Printf("Error creating transfers: %s", err) + return +} + +for i, result := range transferResults { + switch result.Status { + case TransferCreated: + log.Printf("Batch transfer at %d successfully created with timestamp %d.", i, result.Timestamp) + case TransferExists: + log.Printf("Batch transfer at %d already exists with timestamp %d.", i, result.Timestamp) + default: + log.Printf("Batch transfer at %d failed to create: %s", i, result.Status) + } +} +``` + +## Batching + +TigerBeetle performance is maximized when you batch +API requests. + +A client instance shared across multiple threads/tasks can automatically +batch concurrent requests, but the application must still send as many events +as possible in a single call. + +For example, if you insert 1 million transfers sequentially, one at a time, +the insert rate will be a *fraction* of the potential, because the client will +wait for a reply between each one. +Instead, **always batch as much as you can**. + +The maximum batch size is set in the TigerBeetle server. The default is 8189. + +```go +batch := []Transfer{} +BATCH_SIZE := 8189 +for i := 0; i < len(batch); i += BATCH_SIZE { + size := BATCH_SIZE + if i+BATCH_SIZE > len(batch) { + size = len(batch) - i + } + transferResults, err := client.CreateTransfers(batch[i : i+size]) + // Results handling omitted. + _, _ = transferResults, err +} +``` + +### Queues and Workers + +If you are making requests to TigerBeetle from workers +pulling jobs from a queue, you can batch requests to +TigerBeetle by having the worker act on multiple jobs from +the queue at once rather than one at a time. i.e. pulling +multiple jobs from the queue rather than just one. + +## Transfer Flags + +The transfer `flags` value is a bitfield. See details for these flags in +the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer#flags). + +To toggle behavior for an account, use the `types.TransferFlags` struct +to combine enum values and generate a `uint16`. Here are a +few examples: + +* `TransferFlags{Linked: true}.ToUint16()` +* `TransferFlags{Pending: true}.ToUint16()` +* `TransferFlags{PostPendingTransfer: true}.ToUint16()` +* `TransferFlags{VoidPendingTransfer: true}.ToUint16()` + +For example, to link `transfer0` and `transfer1`: + +```go +transfer0 := Transfer{ + ID: ToUint128(4), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: TransferFlags{Linked: true}.ToUint16(), +} +transfer1 := Transfer{ + ID: ToUint128(5), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: 0, +} + +transferResults, err := client.CreateTransfers([]Transfer{transfer0, transfer1}) +// Results handling omitted. +``` + +### Two-Phase Transfers + +Two-phase transfers are supported natively by toggling the appropriate +flag. TigerBeetle will then adjust the `credits_pending` and +`debits_pending` fields of the appropriate accounts. A corresponding +post pending transfer then needs to be sent to post or void the +transfer. + +#### Post a Pending Transfer + +With `flags` set to `post_pending_transfer`, +TigerBeetle will post the transfer. TigerBeetle will atomically roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and apply them to the `debits_posted` and +`credits_posted` balances. + +```go +transfer0 := Transfer{ + ID: ToUint128(6), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: TransferFlags{Pending: true}.ToUint16(), +} + +transferResults, err := client.CreateTransfers([]Transfer{transfer0}) +// Results handling omitted. + +transfer1 := Transfer{ + ID: ToUint128(7), + // Post the entire pending amount. + Amount: AmountMax, + PendingID: ToUint128(6), + Flags: TransferFlags{PostPendingTransfer: true}.ToUint16(), +} + +transferResults, err = client.CreateTransfers([]Transfer{transfer1}) +// Results handling omitted. +``` + +#### Void a Pending Transfer + +In contrast, with `flags` set to `void_pending_transfer`, +TigerBeetle will void the transfer. TigerBeetle will roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and **not** apply them to the `debits_posted` and +`credits_posted` balances. + +```go +transfer0 := Transfer{ + ID: ToUint128(8), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Timeout: 0, + Ledger: 1, + Code: 1, + Flags: TransferFlags{Pending: true}.ToUint16(), +} + +transferResults, err := client.CreateTransfers([]Transfer{transfer0}) +// Results handling omitted. + +transfer1 := Transfer{ + ID: ToUint128(9), + Amount: ToUint128(0), + PendingID: ToUint128(8), + Flags: TransferFlags{VoidPendingTransfer: true}.ToUint16(), +} + +transferResults, err = client.CreateTransfers([]Transfer{transfer1}) +// Results handling omitted. +``` + +## Transfer Lookup + +NOTE: While transfer lookup exists, it is not a flexible query API. We +are developing query APIs and there will be new methods for querying +transfers in the future. + +Transfer lookup is batched, like transfer creation. Pass in all `id`s to +fetch, and matched transfers are returned. + +If no transfer matches an `id`, no object is returned for that +transfer. So the order of transfers in the response is not necessarily +the same as the order of `id`s in the request. You can refer to the +`id` field in the response to distinguish transfers. + +```go +transfers, err := client.LookupTransfers([]Uint128{ToUint128(1), ToUint128(2)}) +``` + +## Get Account Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the transfers involving a given account, allowing basic filter and pagination +capabilities. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```go +filter := AccountFilter{ + AccountID: ToUint128(2), + UserData128: ToUint128(0), // No filter by UserData. + UserData64: 0, + UserData32: 0, + Code: 0, // No filter by Code. + TimestampMin: 0, // No filter by Timestamp. + TimestampMax: 0, // No filter by Timestamp. + Limit: 10, // Limit to ten transfers at most. + Flags: AccountFilterFlags{ + Debits: true, // Include transfer from the debit side. + Credits: true, // Include transfer from the credit side. + Reversed: true, // Sort by timestamp in reverse-chronological order. + }.ToUint32(), +} + +transfers, err := client.GetAccountTransfers(filter) +``` + +## Get Account Balances + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the point-in-time balances of a given account, allowing basic filter and +pagination capabilities. + +Only accounts created with the flag +[`history`](https://docs.tigerbeetle.com/reference/account#flagshistory) set retain +[historical balances](https://docs.tigerbeetle.com/reference/requests/get_account_balances). + +The balances in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```go +filter := AccountFilter{ + AccountID: ToUint128(2), + UserData128: ToUint128(0), // No filter by UserData. + UserData64: 0, + UserData32: 0, + Code: 0, // No filter by Code. + TimestampMin: 0, // No filter by Timestamp. + TimestampMax: 0, // No filter by Timestamp. + Limit: 10, // Limit to ten balances at most. + Flags: AccountFilterFlags{ + Debits: true, // Include transfer from the debit side. + Credits: true, // Include transfer from the credit side. + Reversed: true, // Sort by timestamp in reverse-chronological order. + }.ToUint32(), +} + +account_balances, err := client.GetAccountBalances(filter) +``` + +## Query Accounts + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query accounts by the intersection of some fields and by timestamp range. + +The accounts in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```go +filter := QueryFilter{ + UserData128: ToUint128(1000), // Filter by UserData + UserData64: 100, + UserData32: 10, + Code: 1, // Filter by Code + Ledger: 0, // No filter by Ledger + TimestampMin: 0, // No filter by Timestamp. + TimestampMax: 0, // No filter by Timestamp. + Limit: 10, // Limit to ten accounts at most. + Flags: QueryFilterFlags{ + Reversed: true, // Sort by timestamp in reverse-chronological order. + }.ToUint32(), +} + +accounts, err := client.QueryAccounts(filter) +``` + +## Query Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query transfers by the intersection of some fields and by timestamp range. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```go +filter := QueryFilter{ + UserData128: ToUint128(1000), // Filter by UserData. + UserData64: 100, + UserData32: 10, + Code: 1, // Filter by Code. + Ledger: 0, // No filter by Ledger. + TimestampMin: 0, // No filter by Timestamp. + TimestampMax: 0, // No filter by Timestamp. + Limit: 10, // Limit to ten transfers at most. + Flags: QueryFilterFlags{ + Reversed: true, // Sort by timestamp in reverse-chronological order. + }.ToUint32(), +} + +transfers, err := client.QueryTransfers(filter) +``` + +## Linked Events + +When the `linked` flag is specified for an account when creating accounts or +a transfer when creating transfers, it links that event with the next event in the +batch, to create a chain of events, of arbitrary length, which all +succeed or fail together. The tail of a chain is denoted by the first +event without this flag. The last event in a batch may therefore never +have the `linked` flag set as this would leave a chain +open-ended. Multiple chains or individual events may coexist within a +batch to succeed or fail independently. + +Events within a chain are executed within order, or are rolled back on +error, so that the effect of each event in the chain is visible to the +next, and so that the chain is either visible or invisible as a unit +to subsequent events after the chain. The event that was the first to +break the chain will have a unique error result. Other events in the +chain will have their error result set to `linked_event_failed`. + +```go +batch := []Transfer{} +linkedFlag := TransferFlags{Linked: true}.ToUint16() + +// An individual transfer (successful): +batch = append(batch, Transfer{ID: ToUint128(1) /* ... rest of transfer ... */}) + +// A chain of 4 transfers (the last transfer in the chain closes the chain with linked=false): +batch = append(batch, Transfer{ID: ToUint128(2) /* ... , */, Flags: linkedFlag}) // Commit/rollback. +batch = append(batch, Transfer{ID: ToUint128(3) /* ... , */, Flags: linkedFlag}) // Commit/rollback. +batch = append(batch, Transfer{ID: ToUint128(2) /* ... , */, Flags: linkedFlag}) // Fail with exists +batch = append(batch, Transfer{ID: ToUint128(4) /* ... , */}) // Fail without committing + +// An individual transfer (successful): +// This should not see any effect from the failed chain above. +batch = append(batch, Transfer{ID: ToUint128(2) /* ... rest of transfer ... */}) + +// A chain of 2 transfers (the first transfer fails the chain): +batch = append(batch, Transfer{ID: ToUint128(2) /* ... rest of transfer ... */, Flags: linkedFlag}) +batch = append(batch, Transfer{ID: ToUint128(3) /* ... rest of transfer ... */}) + +// A chain of 2 transfers (successful): +batch = append(batch, Transfer{ID: ToUint128(3) /* ... rest of transfer ... */, Flags: linkedFlag}) +batch = append(batch, Transfer{ID: ToUint128(4) /* ... rest of transfer ... */}) + +transferResults, err := client.CreateTransfers(batch) +// Results handling omitted. +``` + +## Imported Events + +When the `imported` flag is specified for an account when creating accounts or +a transfer when creating transfers, it allows importing historical events with +a user-defined timestamp. + +The entire batch of events must be set with the flag `imported`. + +It's recommended to submit the whole batch as a `linked` chain of events, ensuring that +if any event fails, none of them are committed, preserving the last timestamp unchanged. +This approach gives the application a chance to correct failed imported events, re-submitting +the batch again with the same user-defined timestamps. + +```go +// External source of time. +var historicalTimestamp uint64 = 0 +historicalAccounts := []Account{ /* Loaded from an external source. */ } +historicalTransfers := []Transfer{ /* Loaded from an external source. */ } + +// First, load and import all accounts with their timestamps from the historical source. +accountsBatch := []Account{} +for index, account := range historicalAccounts { + // Set a unique and strictly increasing timestamp. + historicalTimestamp += 1 + account.Timestamp = historicalTimestamp + + account.Flags = AccountFlags{ + // Set the account as `imported`. + Imported: true, + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + Linked: index < len(historicalAccounts)-1, + }.ToUint16() + + accountsBatch = append(accountsBatch, account) +} + +accountResults, err := client.CreateAccounts(accountsBatch) +// Results handling omitted. + +// Then, load and import all transfers with their timestamps from the historical source. +transfersBatch := []Transfer{} +for index, transfer := range historicalTransfers { + // Set a unique and strictly increasing timestamp. + historicalTimestamp += 1 + transfer.Timestamp = historicalTimestamp + + transfer.Flags = TransferFlags{ + // Set the transfer as `imported`. + Imported: true, + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + Linked: index < len(historicalAccounts)-1, + }.ToUint16() + + transfersBatch = append(transfersBatch, transfer) +} + +transferResults, err := client.CreateTransfers(transfersBatch) +// Results handling omitted.. +// Since it is a linked chain, in case of any error the entire batch is rolled back and can be retried +// with the same historical timestamps without regressing the cluster timestamp. +``` + +## Timeouts And Cancellation + +The Client retries indefinitely and doesn't impose any per-request timeout. Cancellation is +provided as a mechanism, and the specific cancellation policy is left to the +application. A Client instance can be closed at any time. On close, all in-flight +requests are canceled and return an error to the caller. Even if an error is returned, +a request might still be processed by the TigerBeetle server. +[Reliable transaction submission](https://docs.tigerbeetle.com/coding/reliable-transaction-submission/) +explains how to make transfers retry-proof using IDs for end-to-end idempotency. diff --git a/ocam/src/clients/go/assert/assert.go b/ocam/src/clients/go/assert/assert.go new file mode 100644 index 00000000..06aeb8c5 --- /dev/null +++ b/ocam/src/clients/go/assert/assert.go @@ -0,0 +1,117 @@ +package assert + +import ( + "bytes" + "reflect" + "testing" +) + +func isEmpty(obj interface{}) bool { + if obj == nil { + return true + } + + value := reflect.ValueOf(obj) + if value.IsNil() { + return true + } + switch value.Kind() { + case reflect.Chan, reflect.Map, reflect.Slice: + return value.Len() == 0 + default: + return false + } +} + +func Empty(t *testing.T, obj interface{}) { + if !isEmpty(obj) { + t.Errorf("%v is not empty", obj) + } +} + +func getLength(obj interface{}) (ok bool, length int) { + value := reflect.ValueOf(obj) + defer func() { + if e := recover(); e != nil { + ok = false + } + }() + return true, value.Len() +} + +func Len(t *testing.T, obj interface{}, size int) { + ok, length := getLength(obj) + if !ok || length != size { + t.Errorf("%v doesn't have size %v", obj, size) + } +} + +func isObjectEqual(a, b interface{}) bool { + if a == nil || b == nil { + return a == b + } + + binaryA, ok := a.([]byte) + if !ok { + return reflect.DeepEqual(a, b) + } + + binaryB, ok := b.([]byte) + if !ok { + return false + } + + if binaryA == nil || binaryB == nil { + return binaryA == nil && binaryB == nil + } + + return bytes.Equal(binaryA, binaryB) +} + +func isEqual(a, b interface{}) bool { + if isObjectEqual(a, b) { + return true + } + + typeB := reflect.TypeOf(b) + if typeB == nil { + return false + } + + valueA := reflect.ValueOf(a) + if valueA.IsValid() && valueA.Type().ConvertibleTo(typeB) { + return reflect.DeepEqual(valueA.Convert(typeB).Interface(), b) + } + + return false +} + +func Equal(t *testing.T, a, b interface{}) { + if !isEqual(a, b) { + t.Errorf("%v is not equal to %v", a, b) + } +} + +func NotEqual(t *testing.T, a, b interface{}) { + if isEqual(a, b) { + t.Errorf("%v is equal to %v", a, b) + } +} + +func isGreater(a, b interface{}) bool { + a64, okA := a.(uint64) + b64, okB := b.(uint64) + return okA && okB && (a64 > b64) +} + +func Greater(t *testing.T, a, b interface{}) { + if !isGreater(a, b) { + t.Errorf("%v is not greater than %v", a, b) + } +} + +func True(t *testing.T, condition bool) { + if !condition { + t.Errorf("condition is not true") + } +} diff --git a/ocam/src/clients/go/bindings.go b/ocam/src/clients/go/bindings.go new file mode 100644 index 00000000..bf3dda1c --- /dev/null +++ b/ocam/src/clients/go/bindings.go @@ -0,0 +1,640 @@ +/////////////////////////////////////////////////////// +// This file was auto-generated by go_bindings.zig // +// Do not manually modify. // +/////////////////////////////////////////////////////// + +package tigerbeetle_go + +/* +#include "./native/tb_client.h" +*/ +import "C" +import "strconv" + +type AccountFlags struct { + Linked bool + DebitsMustNotExceedCredits bool + CreditsMustNotExceedDebits bool + History bool + Imported bool + Closed bool +} + +func (f AccountFlags) ToUint16() uint16 { + var ret uint16 = 0 + + if f.Linked { + ret |= (1 << 0) + } + + if f.DebitsMustNotExceedCredits { + ret |= (1 << 1) + } + + if f.CreditsMustNotExceedDebits { + ret |= (1 << 2) + } + + if f.History { + ret |= (1 << 3) + } + + if f.Imported { + ret |= (1 << 4) + } + + if f.Closed { + ret |= (1 << 5) + } + + return ret +} + +type TransferFlags struct { + Linked bool + Pending bool + PostPendingTransfer bool + VoidPendingTransfer bool + BalancingDebit bool + BalancingCredit bool + ClosingDebit bool + ClosingCredit bool + Imported bool +} + +func (f TransferFlags) ToUint16() uint16 { + var ret uint16 = 0 + + if f.Linked { + ret |= (1 << 0) + } + + if f.Pending { + ret |= (1 << 1) + } + + if f.PostPendingTransfer { + ret |= (1 << 2) + } + + if f.VoidPendingTransfer { + ret |= (1 << 3) + } + + if f.BalancingDebit { + ret |= (1 << 4) + } + + if f.BalancingCredit { + ret |= (1 << 5) + } + + if f.ClosingDebit { + ret |= (1 << 6) + } + + if f.ClosingCredit { + ret |= (1 << 7) + } + + if f.Imported { + ret |= (1 << 8) + } + + return ret +} + +type AccountFilterFlags struct { + Debits bool + Credits bool + Reversed bool +} + +func (f AccountFilterFlags) ToUint32() uint32 { + var ret uint32 = 0 + + if f.Debits { + ret |= (1 << 0) + } + + if f.Credits { + ret |= (1 << 1) + } + + if f.Reversed { + ret |= (1 << 2) + } + + return ret +} + +type QueryFilterFlags struct { + Reversed bool +} + +func (f QueryFilterFlags) ToUint32() uint32 { + var ret uint32 = 0 + + if f.Reversed { + ret |= (1 << 0) + } + + return ret +} + +type Account struct { + ID Uint128 + DebitsPending Uint128 + DebitsPosted Uint128 + CreditsPending Uint128 + CreditsPosted Uint128 + UserData128 Uint128 + UserData64 uint64 + UserData32 uint32 + Reserved uint32 + Ledger uint32 + Code uint16 + Flags uint16 + Timestamp uint64 +} + +func (o Account) AccountFlags() AccountFlags { + var f AccountFlags + f.Linked = ((o.Flags >> 0) & 0x1) == 1 + f.DebitsMustNotExceedCredits = ((o.Flags >> 1) & 0x1) == 1 + f.CreditsMustNotExceedDebits = ((o.Flags >> 2) & 0x1) == 1 + f.History = ((o.Flags >> 3) & 0x1) == 1 + f.Imported = ((o.Flags >> 4) & 0x1) == 1 + f.Closed = ((o.Flags >> 5) & 0x1) == 1 + return f +} + +type Transfer struct { + ID Uint128 + DebitAccountID Uint128 + CreditAccountID Uint128 + Amount Uint128 + PendingID Uint128 + UserData128 Uint128 + UserData64 uint64 + UserData32 uint32 + Timeout uint32 + Ledger uint32 + Code uint16 + Flags uint16 + Timestamp uint64 +} + +func (o Transfer) TransferFlags() TransferFlags { + var f TransferFlags + f.Linked = ((o.Flags >> 0) & 0x1) == 1 + f.Pending = ((o.Flags >> 1) & 0x1) == 1 + f.PostPendingTransfer = ((o.Flags >> 2) & 0x1) == 1 + f.VoidPendingTransfer = ((o.Flags >> 3) & 0x1) == 1 + f.BalancingDebit = ((o.Flags >> 4) & 0x1) == 1 + f.BalancingCredit = ((o.Flags >> 5) & 0x1) == 1 + f.ClosingDebit = ((o.Flags >> 6) & 0x1) == 1 + f.ClosingCredit = ((o.Flags >> 7) & 0x1) == 1 + f.Imported = ((o.Flags >> 8) & 0x1) == 1 + return f +} + +type CreateAccountStatus uint32 + +const ( + AccountCreated CreateAccountStatus = 0xFFFFFFFF + AccountLinkedEventFailed CreateAccountStatus = 1 + AccountLinkedEventChainOpen CreateAccountStatus = 2 + AccountImportedEventExpected CreateAccountStatus = 22 + AccountImportedEventNotExpected CreateAccountStatus = 23 + AccountTimestampMustBeZero CreateAccountStatus = 3 + AccountImportedEventTimestampOutOfRange CreateAccountStatus = 24 + AccountImportedEventTimestampMustNotAdvance CreateAccountStatus = 25 + AccountReservedField CreateAccountStatus = 4 + AccountReservedFlag CreateAccountStatus = 5 + AccountIDMustNotBeZero CreateAccountStatus = 6 + AccountIDMustNotBeIntMax CreateAccountStatus = 7 + AccountExistsWithDifferentFlags CreateAccountStatus = 15 + AccountExistsWithDifferentUserData128 CreateAccountStatus = 16 + AccountExistsWithDifferentUserData64 CreateAccountStatus = 17 + AccountExistsWithDifferentUserData32 CreateAccountStatus = 18 + AccountExistsWithDifferentLedger CreateAccountStatus = 19 + AccountExistsWithDifferentCode CreateAccountStatus = 20 + AccountExists CreateAccountStatus = 21 + AccountFlagsAreMutuallyExclusive CreateAccountStatus = 8 + AccountDebitsPendingMustBeZero CreateAccountStatus = 9 + AccountDebitsPostedMustBeZero CreateAccountStatus = 10 + AccountCreditsPendingMustBeZero CreateAccountStatus = 11 + AccountCreditsPostedMustBeZero CreateAccountStatus = 12 + AccountLedgerMustNotBeZero CreateAccountStatus = 13 + AccountCodeMustNotBeZero CreateAccountStatus = 14 + AccountImportedEventTimestampMustNotRegress CreateAccountStatus = 26 +) + +func (i CreateAccountStatus) String() string { + switch i { + case AccountCreated: + return "AccountCreated" + case AccountLinkedEventFailed: + return "AccountLinkedEventFailed" + case AccountLinkedEventChainOpen: + return "AccountLinkedEventChainOpen" + case AccountImportedEventExpected: + return "AccountImportedEventExpected" + case AccountImportedEventNotExpected: + return "AccountImportedEventNotExpected" + case AccountTimestampMustBeZero: + return "AccountTimestampMustBeZero" + case AccountImportedEventTimestampOutOfRange: + return "AccountImportedEventTimestampOutOfRange" + case AccountImportedEventTimestampMustNotAdvance: + return "AccountImportedEventTimestampMustNotAdvance" + case AccountReservedField: + return "AccountReservedField" + case AccountReservedFlag: + return "AccountReservedFlag" + case AccountIDMustNotBeZero: + return "AccountIDMustNotBeZero" + case AccountIDMustNotBeIntMax: + return "AccountIDMustNotBeIntMax" + case AccountExistsWithDifferentFlags: + return "AccountExistsWithDifferentFlags" + case AccountExistsWithDifferentUserData128: + return "AccountExistsWithDifferentUserData128" + case AccountExistsWithDifferentUserData64: + return "AccountExistsWithDifferentUserData64" + case AccountExistsWithDifferentUserData32: + return "AccountExistsWithDifferentUserData32" + case AccountExistsWithDifferentLedger: + return "AccountExistsWithDifferentLedger" + case AccountExistsWithDifferentCode: + return "AccountExistsWithDifferentCode" + case AccountExists: + return "AccountExists" + case AccountFlagsAreMutuallyExclusive: + return "AccountFlagsAreMutuallyExclusive" + case AccountDebitsPendingMustBeZero: + return "AccountDebitsPendingMustBeZero" + case AccountDebitsPostedMustBeZero: + return "AccountDebitsPostedMustBeZero" + case AccountCreditsPendingMustBeZero: + return "AccountCreditsPendingMustBeZero" + case AccountCreditsPostedMustBeZero: + return "AccountCreditsPostedMustBeZero" + case AccountLedgerMustNotBeZero: + return "AccountLedgerMustNotBeZero" + case AccountCodeMustNotBeZero: + return "AccountCodeMustNotBeZero" + case AccountImportedEventTimestampMustNotRegress: + return "AccountImportedEventTimestampMustNotRegress" + } + return "CreateAccountStatus(" + strconv.FormatInt(int64(i+1), 10) + ")" +} + +type CreateTransferStatus uint32 + +const ( + TransferCreated CreateTransferStatus = 0xFFFFFFFF + TransferLinkedEventFailed CreateTransferStatus = 1 + TransferLinkedEventChainOpen CreateTransferStatus = 2 + TransferImportedEventExpected CreateTransferStatus = 56 + TransferImportedEventNotExpected CreateTransferStatus = 57 + TransferTimestampMustBeZero CreateTransferStatus = 3 + TransferImportedEventTimestampOutOfRange CreateTransferStatus = 58 + TransferImportedEventTimestampMustNotAdvance CreateTransferStatus = 59 + TransferReservedFlag CreateTransferStatus = 4 + TransferIDMustNotBeZero CreateTransferStatus = 5 + TransferIDMustNotBeIntMax CreateTransferStatus = 6 + TransferExistsWithDifferentFlags CreateTransferStatus = 36 + TransferExistsWithDifferentPendingID CreateTransferStatus = 40 + TransferExistsWithDifferentTimeout CreateTransferStatus = 44 + TransferExistsWithDifferentDebitAccountID CreateTransferStatus = 37 + TransferExistsWithDifferentCreditAccountID CreateTransferStatus = 38 + TransferExistsWithDifferentAmount CreateTransferStatus = 39 + TransferExistsWithDifferentUserData128 CreateTransferStatus = 41 + TransferExistsWithDifferentUserData64 CreateTransferStatus = 42 + TransferExistsWithDifferentUserData32 CreateTransferStatus = 43 + TransferExistsWithDifferentLedger CreateTransferStatus = 67 + TransferExistsWithDifferentCode CreateTransferStatus = 45 + TransferExists CreateTransferStatus = 46 + TransferIDAlreadyFailed CreateTransferStatus = 68 + TransferFlagsAreMutuallyExclusive CreateTransferStatus = 7 + TransferDebitAccountIDMustNotBeZero CreateTransferStatus = 8 + TransferDebitAccountIDMustNotBeIntMax CreateTransferStatus = 9 + TransferCreditAccountIDMustNotBeZero CreateTransferStatus = 10 + TransferCreditAccountIDMustNotBeIntMax CreateTransferStatus = 11 + TransferAccountsMustBeDifferent CreateTransferStatus = 12 + TransferPendingIDMustBeZero CreateTransferStatus = 13 + TransferPendingIDMustNotBeZero CreateTransferStatus = 14 + TransferPendingIDMustNotBeIntMax CreateTransferStatus = 15 + TransferPendingIDMustBeDifferent CreateTransferStatus = 16 + TransferTimeoutReservedForPendingTransfer CreateTransferStatus = 17 + TransferClosingTransferMustBePending CreateTransferStatus = 64 + TransferLedgerMustNotBeZero CreateTransferStatus = 19 + TransferCodeMustNotBeZero CreateTransferStatus = 20 + TransferDebitAccountNotFound CreateTransferStatus = 21 + TransferCreditAccountNotFound CreateTransferStatus = 22 + TransferAccountsMustHaveTheSameLedger CreateTransferStatus = 23 + TransferTransferMustHaveTheSameLedgerAsAccounts CreateTransferStatus = 24 + TransferPendingTransferNotFound CreateTransferStatus = 25 + TransferPendingTransferNotPending CreateTransferStatus = 26 + TransferPendingTransferHasDifferentDebitAccountID CreateTransferStatus = 27 + TransferPendingTransferHasDifferentCreditAccountID CreateTransferStatus = 28 + TransferPendingTransferHasDifferentLedger CreateTransferStatus = 29 + TransferPendingTransferHasDifferentCode CreateTransferStatus = 30 + TransferExceedsPendingTransferAmount CreateTransferStatus = 31 + TransferPendingTransferHasDifferentAmount CreateTransferStatus = 32 + TransferPendingTransferAlreadyPosted CreateTransferStatus = 33 + TransferPendingTransferAlreadyVoided CreateTransferStatus = 34 + TransferPendingTransferExpired CreateTransferStatus = 35 + TransferImportedEventTimestampMustNotRegress CreateTransferStatus = 60 + TransferImportedEventTimestampMustPostdateDebitAccount CreateTransferStatus = 61 + TransferImportedEventTimestampMustPostdateCreditAccount CreateTransferStatus = 62 + TransferImportedEventTimeoutMustBeZero CreateTransferStatus = 63 + TransferDebitAccountAlreadyClosed CreateTransferStatus = 65 + TransferCreditAccountAlreadyClosed CreateTransferStatus = 66 + TransferOverflowsDebitsPending CreateTransferStatus = 47 + TransferOverflowsCreditsPending CreateTransferStatus = 48 + TransferOverflowsDebitsPosted CreateTransferStatus = 49 + TransferOverflowsCreditsPosted CreateTransferStatus = 50 + TransferOverflowsDebits CreateTransferStatus = 51 + TransferOverflowsCredits CreateTransferStatus = 52 + TransferOverflowsTimeout CreateTransferStatus = 53 + TransferExceedsCredits CreateTransferStatus = 54 + TransferExceedsDebits CreateTransferStatus = 55 +) + +func (i CreateTransferStatus) String() string { + switch i { + case TransferCreated: + return "TransferCreated" + case TransferLinkedEventFailed: + return "TransferLinkedEventFailed" + case TransferLinkedEventChainOpen: + return "TransferLinkedEventChainOpen" + case TransferImportedEventExpected: + return "TransferImportedEventExpected" + case TransferImportedEventNotExpected: + return "TransferImportedEventNotExpected" + case TransferTimestampMustBeZero: + return "TransferTimestampMustBeZero" + case TransferImportedEventTimestampOutOfRange: + return "TransferImportedEventTimestampOutOfRange" + case TransferImportedEventTimestampMustNotAdvance: + return "TransferImportedEventTimestampMustNotAdvance" + case TransferReservedFlag: + return "TransferReservedFlag" + case TransferIDMustNotBeZero: + return "TransferIDMustNotBeZero" + case TransferIDMustNotBeIntMax: + return "TransferIDMustNotBeIntMax" + case TransferExistsWithDifferentFlags: + return "TransferExistsWithDifferentFlags" + case TransferExistsWithDifferentPendingID: + return "TransferExistsWithDifferentPendingID" + case TransferExistsWithDifferentTimeout: + return "TransferExistsWithDifferentTimeout" + case TransferExistsWithDifferentDebitAccountID: + return "TransferExistsWithDifferentDebitAccountID" + case TransferExistsWithDifferentCreditAccountID: + return "TransferExistsWithDifferentCreditAccountID" + case TransferExistsWithDifferentAmount: + return "TransferExistsWithDifferentAmount" + case TransferExistsWithDifferentUserData128: + return "TransferExistsWithDifferentUserData128" + case TransferExistsWithDifferentUserData64: + return "TransferExistsWithDifferentUserData64" + case TransferExistsWithDifferentUserData32: + return "TransferExistsWithDifferentUserData32" + case TransferExistsWithDifferentLedger: + return "TransferExistsWithDifferentLedger" + case TransferExistsWithDifferentCode: + return "TransferExistsWithDifferentCode" + case TransferExists: + return "TransferExists" + case TransferIDAlreadyFailed: + return "TransferIDAlreadyFailed" + case TransferFlagsAreMutuallyExclusive: + return "TransferFlagsAreMutuallyExclusive" + case TransferDebitAccountIDMustNotBeZero: + return "TransferDebitAccountIDMustNotBeZero" + case TransferDebitAccountIDMustNotBeIntMax: + return "TransferDebitAccountIDMustNotBeIntMax" + case TransferCreditAccountIDMustNotBeZero: + return "TransferCreditAccountIDMustNotBeZero" + case TransferCreditAccountIDMustNotBeIntMax: + return "TransferCreditAccountIDMustNotBeIntMax" + case TransferAccountsMustBeDifferent: + return "TransferAccountsMustBeDifferent" + case TransferPendingIDMustBeZero: + return "TransferPendingIDMustBeZero" + case TransferPendingIDMustNotBeZero: + return "TransferPendingIDMustNotBeZero" + case TransferPendingIDMustNotBeIntMax: + return "TransferPendingIDMustNotBeIntMax" + case TransferPendingIDMustBeDifferent: + return "TransferPendingIDMustBeDifferent" + case TransferTimeoutReservedForPendingTransfer: + return "TransferTimeoutReservedForPendingTransfer" + case TransferClosingTransferMustBePending: + return "TransferClosingTransferMustBePending" + case TransferLedgerMustNotBeZero: + return "TransferLedgerMustNotBeZero" + case TransferCodeMustNotBeZero: + return "TransferCodeMustNotBeZero" + case TransferDebitAccountNotFound: + return "TransferDebitAccountNotFound" + case TransferCreditAccountNotFound: + return "TransferCreditAccountNotFound" + case TransferAccountsMustHaveTheSameLedger: + return "TransferAccountsMustHaveTheSameLedger" + case TransferTransferMustHaveTheSameLedgerAsAccounts: + return "TransferTransferMustHaveTheSameLedgerAsAccounts" + case TransferPendingTransferNotFound: + return "TransferPendingTransferNotFound" + case TransferPendingTransferNotPending: + return "TransferPendingTransferNotPending" + case TransferPendingTransferHasDifferentDebitAccountID: + return "TransferPendingTransferHasDifferentDebitAccountID" + case TransferPendingTransferHasDifferentCreditAccountID: + return "TransferPendingTransferHasDifferentCreditAccountID" + case TransferPendingTransferHasDifferentLedger: + return "TransferPendingTransferHasDifferentLedger" + case TransferPendingTransferHasDifferentCode: + return "TransferPendingTransferHasDifferentCode" + case TransferExceedsPendingTransferAmount: + return "TransferExceedsPendingTransferAmount" + case TransferPendingTransferHasDifferentAmount: + return "TransferPendingTransferHasDifferentAmount" + case TransferPendingTransferAlreadyPosted: + return "TransferPendingTransferAlreadyPosted" + case TransferPendingTransferAlreadyVoided: + return "TransferPendingTransferAlreadyVoided" + case TransferPendingTransferExpired: + return "TransferPendingTransferExpired" + case TransferImportedEventTimestampMustNotRegress: + return "TransferImportedEventTimestampMustNotRegress" + case TransferImportedEventTimestampMustPostdateDebitAccount: + return "TransferImportedEventTimestampMustPostdateDebitAccount" + case TransferImportedEventTimestampMustPostdateCreditAccount: + return "TransferImportedEventTimestampMustPostdateCreditAccount" + case TransferImportedEventTimeoutMustBeZero: + return "TransferImportedEventTimeoutMustBeZero" + case TransferDebitAccountAlreadyClosed: + return "TransferDebitAccountAlreadyClosed" + case TransferCreditAccountAlreadyClosed: + return "TransferCreditAccountAlreadyClosed" + case TransferOverflowsDebitsPending: + return "TransferOverflowsDebitsPending" + case TransferOverflowsCreditsPending: + return "TransferOverflowsCreditsPending" + case TransferOverflowsDebitsPosted: + return "TransferOverflowsDebitsPosted" + case TransferOverflowsCreditsPosted: + return "TransferOverflowsCreditsPosted" + case TransferOverflowsDebits: + return "TransferOverflowsDebits" + case TransferOverflowsCredits: + return "TransferOverflowsCredits" + case TransferOverflowsTimeout: + return "TransferOverflowsTimeout" + case TransferExceedsCredits: + return "TransferExceedsCredits" + case TransferExceedsDebits: + return "TransferExceedsDebits" + } + return "CreateTransferStatus(" + strconv.FormatInt(int64(i+1), 10) + ")" +} + +type CreateAccountResult struct { + Timestamp uint64 + Status CreateAccountStatus + Reserved uint32 +} + +type CreateTransferResult struct { + Timestamp uint64 + Status CreateTransferStatus + Reserved uint32 +} + +type AccountFilter struct { + AccountID Uint128 + UserData128 Uint128 + UserData64 uint64 + UserData32 uint32 + Code uint16 + Reserved [58]uint8 + TimestampMin uint64 + TimestampMax uint64 + Limit uint32 + Flags uint32 +} + +func (o AccountFilter) AccountFilterFlags() AccountFilterFlags { + var f AccountFilterFlags + f.Debits = ((o.Flags >> 0) & 0x1) == 1 + f.Credits = ((o.Flags >> 1) & 0x1) == 1 + f.Reversed = ((o.Flags >> 2) & 0x1) == 1 + return f +} + +type AccountBalance struct { + DebitsPending Uint128 + DebitsPosted Uint128 + CreditsPending Uint128 + CreditsPosted Uint128 + Timestamp uint64 + Reserved [56]uint8 +} + +type QueryFilter struct { + UserData128 Uint128 + UserData64 uint64 + UserData32 uint32 + Ledger uint32 + Code uint16 + Reserved [6]uint8 + TimestampMin uint64 + TimestampMax uint64 + Limit uint32 + Flags uint32 +} + +func (o QueryFilter) QueryFilterFlags() QueryFilterFlags { + var f QueryFilterFlags + f.Reversed = ((o.Flags >> 0) & 0x1) == 1 + return f +} + +type ChangeEvent struct { + TransferID Uint128 + TransferAmount Uint128 + TransferPendingID Uint128 + TransferUserData128 Uint128 + TransferUserData64 uint64 + TransferUserData32 uint32 + TransferTimeout uint32 + TransferCode uint16 + TransferFlags uint16 + Ledger uint32 + Type ChangeEventType + Reserved [39]uint8 + DebitAccountID Uint128 + DebitAccountDebitsPending Uint128 + DebitAccountDebitsPosted Uint128 + DebitAccountCreditsPending Uint128 + DebitAccountCreditsPosted Uint128 + DebitAccountUserData128 Uint128 + DebitAccountUserData64 uint64 + DebitAccountUserData32 uint32 + DebitAccountCode uint16 + DebitAccountFlags uint16 + CreditAccountID Uint128 + CreditAccountDebitsPending Uint128 + CreditAccountDebitsPosted Uint128 + CreditAccountCreditsPending Uint128 + CreditAccountCreditsPosted Uint128 + CreditAccountUserData128 Uint128 + CreditAccountUserData64 uint64 + CreditAccountUserData32 uint32 + CreditAccountCode uint16 + CreditAccountFlags uint16 + Timestamp uint64 + TransferTimestamp uint64 + DebitAccountTimestamp uint64 + CreditAccountTimestamp uint64 +} + +type ChangeEventType uint8 + +const ( + ChangeEventSinglePhase ChangeEventType = 0 + ChangeEventTwoPhasePending ChangeEventType = 1 + ChangeEventTwoPhasePosted ChangeEventType = 2 + ChangeEventTwoPhaseVoided ChangeEventType = 3 + ChangeEventTwoPhaseExpired ChangeEventType = 4 +) + +func (i ChangeEventType) String() string { + switch i { + case ChangeEventSinglePhase: + return "ChangeEventSinglePhase" + case ChangeEventTwoPhasePending: + return "ChangeEventTwoPhasePending" + case ChangeEventTwoPhasePosted: + return "ChangeEventTwoPhasePosted" + case ChangeEventTwoPhaseVoided: + return "ChangeEventTwoPhaseVoided" + case ChangeEventTwoPhaseExpired: + return "ChangeEventTwoPhaseExpired" + } + return "ChangeEventType(" + strconv.FormatInt(int64(i+1), 10) + ")" +} + +type ChangeEventsFilter struct { + TimestampMin uint64 + TimestampMax uint64 + Limit uint32 + Reserved [44]uint8 +} diff --git a/ocam/src/clients/go/ci.zig b/ocam/src/clients/go/ci.zig new file mode 100644 index 00000000..c2e86f4a --- /dev/null +++ b/ocam/src/clients/go/ci.zig @@ -0,0 +1,109 @@ +const builtin = @import("builtin"); +const std = @import("std"); +const log = std.log; +const assert = std.debug.assert; + +const Shell = @import("stdx").Shell; +const TmpTigerBeetle = @import("../../testing/tmp_tigerbeetle.zig"); + +pub fn tests(shell: *Shell, gpa: std.mem.Allocator) !void { + assert(shell.file_exists("go.mod")); + + const bad_formatting = try shell.exec_stdout("gofmt -l .", .{}); + if (!std.mem.eql(u8, bad_formatting, "")) { + log.err("these go files need formatting:\n'{s}'", .{bad_formatting}); + return error.GoFmt; + } + + try shell.exec("go vet", .{}); + + // `go build` won't compile the native library automatically, we need to do that ourselves. + try shell.exec_zig("build clients:go -Drelease", .{}); + try shell.exec_zig("build -Drelease", .{}); + + // Although we have compiled the TigerBeetle client library, we still need `cgo` to link it with + // our resulting Go binary. Strictly speaking, `CC` is controlled by the users of TigerBeetle, + // so ideally we should test common flavors of gcc. For simplicity, we: + // - use `zig cc` on Windows, as that doesn't have `gcc` out of the box + // - use `zig cc` on Linux. It might or might not have `gcc`, but `zig cc` makes our CI more + // reproducible + // - (implicitly) use `gcc` on Mac, as `zig cc` doesn't work there: + // + switch (builtin.os.tag) { + .linux, .windows => { + const zig_cc = try shell.fmt("{s} cc", .{shell.zig_exe.?}); + try shell.env.put("CC", zig_cc); + }, + .macos => {}, + else => unreachable, + } + + try shell.exec("go test", .{}); + + inline for (.{ "basic", "two-phase", "two-phase-many", "walkthrough" }) |sample| { + log.info("testing sample '{s}'", .{sample}); + + try shell.pushd("./samples/" ++ sample); + defer shell.popd(); + + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + try shell.exec("go build main.go", .{}); + try shell.exec("./main" ++ builtin.target.exeFileExt(), .{}); + } +} + +pub fn validate_release_package(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, +}) !void { + _ = shell; + _ = gpa; + _ = options; +} + +pub fn validate_release_sample(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, + tigerbeetle: []const u8, +}) !void { + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + .prebuilt = options.tigerbeetle, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + + try shell.exec("go mod init tbtest", .{}); + try shell.exec("go get github.com/tigerbeetle/tigerbeetle-go@v{release}", .{ + .release = options.release, + }); + + try Shell.copy_path( + shell.cwd, + "src/clients/go/samples/basic/main.go", + shell.cwd, + "main.go", + ); + const zig_cc = try shell.fmt("{s} cc", .{shell.zig_exe.?}); + + try shell.env.put("CC", zig_cc); + try shell.exec("go run main.go", .{}); +} + +pub fn release_published_latest(shell: *Shell) ![]const u8 { + // Example output: + // github.com/tigerbeetle/tigerbeetle-go v0.9.149 v0.13.56 v0.13.57 + const output = try shell.exec_stdout( + "go list -m -versions github.com/tigerbeetle/tigerbeetle-go", + .{}, + ); + const last_version = std.mem.lastIndexOf(u8, output, " v").?; + + return output[last_version + 2 ..]; +} diff --git a/ocam/src/clients/go/docs.zig b/ocam/src/clients/go/docs.zig new file mode 100644 index 00000000..e8a82668 --- /dev/null +++ b/ocam/src/clients/go/docs.zig @@ -0,0 +1,81 @@ +const Docs = @import("../docs_types.zig").Docs; + +pub const GoDocs = Docs{ + .directory = "go", + + .markdown_name = "go", + .extension = "go", + .proper_name = "Go", + + .test_source_path = "", + + .name = "tigerbeetle-go", + .description = + \\The TigerBeetle client for Go. + \\ + \\[![Go Reference](https://pkg.go.dev/badge/github.com/tigerbeetle/tigerbeetle-go.svg)](https://pkg.go.dev/github.com/tigerbeetle/tigerbeetle-go) + \\ + \\Make sure to import `github.com/tigerbeetle/tigerbeetle-go`, not + \\this repo and subdirectory. + , + + .prerequisites = + \\* Go >= 1.21 + \\ + \\**Additionally on Windows**: you must install [Zig + \\0.14.1](https://ziglang.org/download/#release-0.14.1) and set the + \\`CC` environment variable to `zig.exe cc`. Use the full path for + \\`zig.exe`. + , + + .project_file = "", + .project_file_name = "", + + .test_file_name = "main", + + .install_commands = + \\go mod init tbtest + \\go get github.com/tigerbeetle/tigerbeetle-go + , + .run_commands = "go run main.go", + + .examples = "", + + .client_object_documentation = "", + + .create_accounts_documentation = + \\The `Uint128` fields like `ID`, `UserData128`, `Amount` and + \\account balances have a few helper functions to make it easier + \\to convert 128-bit little-endian unsigned integers between + \\`string`, `math/big.Int`, and `[]byte`. + \\ + \\See the type [Uint128](https://pkg.go.dev/github.com/tigerbeetle/tigerbeetle-go/pkg/types#Uint128) for more details. + , + + .account_flags_documentation = + \\To toggle behavior for an account, use the `types.AccountFlags` struct + \\to combine enum values and generate a `uint16`. Here are a + \\few examples: + \\ + \\* `AccountFlags{Linked: true}.ToUint16()` + \\* `AccountFlags{DebitsMustNotExceedCredits: true}.ToUint16()` + \\* `AccountFlags{CreditsMustNotExceedDebits: true}.ToUint16()` + \\* `AccountFlags{History: true}.ToUint16()` + , + + .create_accounts_errors_documentation = "", + + .create_transfers_documentation = "", + .create_transfers_errors_documentation = "", + + .transfer_flags_documentation = + \\To toggle behavior for an account, use the `types.TransferFlags` struct + \\to combine enum values and generate a `uint16`. Here are a + \\few examples: + \\ + \\* `TransferFlags{Linked: true}.ToUint16()` + \\* `TransferFlags{Pending: true}.ToUint16()` + \\* `TransferFlags{PostPendingTransfer: true}.ToUint16()` + \\* `TransferFlags{VoidPendingTransfer: true}.ToUint16()` + , +}; diff --git a/ocam/src/clients/go/errors.go b/ocam/src/clients/go/errors.go new file mode 100644 index 00000000..cdad2880 --- /dev/null +++ b/ocam/src/clients/go/errors.go @@ -0,0 +1,18 @@ +package tigerbeetle_go + +import "errors" + +var ( + ErrUnexpected = errors.New("unexpected internal error") + ErrOutOfMemory = errors.New("internal client ran out of memory") + ErrSystemResources = errors.New("internal client ran out of system resources") + ErrNetworkSubsystem = errors.New("internal client had unexpected networking issues") + ErrAddressLimitExceeded = errors.New("too many addresses provided") + ErrInvalidAddress = errors.New("invalid client cluster address") + ErrClientEvicted = errors.New("client was evicted") + ErrClientReleaseTooLow = errors.New("client was evicted: release too old") + ErrClientReleaseTooHigh = errors.New("client was evicted: release too new") + ErrClientClosed = errors.New("client was closed") + ErrInvalidOperation = errors.New("internal operation provided was invalid") + ErrTooMuchData = errors.New("too much data was sent or requested in this batch") +) diff --git a/ocam/src/clients/go/go.mod b/ocam/src/clients/go/go.mod new file mode 100644 index 00000000..6e9ddea2 --- /dev/null +++ b/ocam/src/clients/go/go.mod @@ -0,0 +1,3 @@ +module github.com/tigerbeetle/tigerbeetle-go + +go 1.17 diff --git a/ocam/src/clients/go/go.sum b/ocam/src/clients/go/go.sum new file mode 100644 index 00000000..e69de29b diff --git a/ocam/src/clients/go/go_bindings.zig b/ocam/src/clients/go/go_bindings.zig new file mode 100644 index 00000000..d11da462 --- /dev/null +++ b/ocam/src/clients/go/go_bindings.zig @@ -0,0 +1,374 @@ +const std = @import("std"); +const vsr = @import("vsr"); +const assert = std.debug.assert; + +const stdx = vsr.stdx; +const tb = vsr.tigerbeetle; + +const type_mappings = .{ + .{ tb.AccountFlags, "AccountFlags" }, + .{ tb.TransferFlags, "TransferFlags" }, + .{ tb.AccountFilterFlags, "AccountFilterFlags" }, + .{ tb.QueryFilterFlags, "QueryFilterFlags" }, + .{ tb.Account, "Account" }, + .{ tb.Transfer, "Transfer" }, + .{ tb.CreateAccountStatus, "CreateAccountStatus", "Account" }, + .{ tb.CreateTransferStatus, "CreateTransferStatus", "Transfer" }, + .{ tb.CreateAccountResult, "CreateAccountResult" }, + .{ tb.CreateTransferResult, "CreateTransferResult" }, + .{ tb.AccountFilter, "AccountFilter" }, + .{ tb.AccountBalance, "AccountBalance" }, + .{ tb.QueryFilter, "QueryFilter" }, + .{ tb.ChangeEvent, "ChangeEvent" }, + .{ tb.ChangeEventType, "ChangeEventType", "ChangeEvent" }, + .{ tb.ChangeEventsFilter, "ChangeEventsFilter" }, +}; + +fn go_type(comptime Type: type) []const u8 { + switch (@typeInfo(Type)) { + .bool => return "bool", + .@"enum" => return comptime get_mapped_type_name(Type) orelse + @compileError("Type " ++ @typeName(Type) ++ " not mapped."), + .@"struct" => |info| switch (info.layout) { + .@"packed" => return comptime go_type(std.meta.Int(.unsigned, @bitSizeOf(Type))), + else => return comptime get_mapped_type_name(Type) orelse + @compileError("Type " ++ @typeName(Type) ++ " not mapped."), + }, + .int => |info| { + assert(info.signedness == .unsigned); + return switch (info.bits) { + 1 => "bool", + 8 => "uint8", + 16 => "uint16", + 32 => "uint32", + 64 => "uint64", + 128 => "Uint128", + else => @compileError("invalid int type"), + }; + }, + else => @compileError("Unhandled type: " ++ @typeName(Type)), + } +} + +fn get_mapped_type_name(comptime Type: type) ?[]const u8 { + inline for (type_mappings) |type_mapping| { + if (Type == type_mapping[0]) { + return type_mapping[1]; + } + } else return null; +} + +fn to_pascal_case(comptime input: []const u8, comptime min_len: ?usize) []const u8 { + return comptime blk: { + var len: usize = 0; + var output = [_]u8{' '} ** (min_len orelse input.len); + var iterator = std.mem.tokenizeScalar(u8, input, '_'); + while (iterator.next()) |word| { + assert(word.len > 0); + if (is_upper_case(word)) { + _ = std.ascii.upperString(output[len..], word); + } else { + output[len] = std.ascii.toUpper(word[0]); + for (word[1..], 1..) |c, i| output[len + i] = c; + } + len += word.len; + } + + break :blk stdx.comptime_slice(&output, min_len orelse len); + }; +} + +fn calculate_min_len(comptime type_info: anytype) comptime_int { + comptime { + var min_len: comptime_int = 0; + for (type_info.fields) |field| { + const field_len = to_pascal_case(field.name, null).len; + if (field_len > min_len) { + min_len = field_len; + } + } + return min_len; + } +} + +fn is_upper_case(comptime word: []const u8) bool { + // https://github.com/golang/go/wiki/CodeReviewComments#initialisms + const initialisms = .{ "id", "ok" }; + inline for (initialisms) |initialism| { + if (std.ascii.eqlIgnoreCase(initialism, word)) { + return true; + } + } else return false; +} + +fn emit_enum( + buffer: *std.ArrayList(u8), + comptime Type: type, + comptime name: []const u8, + comptime prefix: []const u8, + comptime tag_type: []const u8, +) !void { + try buffer.writer().print("type {s} {s}\n\n" ++ + "const (\n", .{ + name, + tag_type, + }); + + const type_info = @typeInfo(Type).@"enum"; + const min_len = calculate_min_len(type_info); + inline for (type_info.fields) |field| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + const enum_name = prefix ++ comptime to_pascal_case(field.name, min_len); + if (type_info.tag_type == u1) { + try buffer.writer().print("\t{s} {s} = {s}\n", .{ + enum_name, + name, + if (@intFromEnum(@field(Type, field.name)) == 1) "true" else "false", + }); + } else { + const int_value = @intFromEnum(@field(Type, field.name)); + try buffer.writer().print("\t{s} {s} = {s}\n", .{ + enum_name, + name, + if (int_value == std.math.maxInt(@TypeOf(int_value))) + std.fmt.comptimePrint("0x{X}", .{int_value}) + else + std.fmt.comptimePrint("{}", .{int_value}), + }); + } + } + + try buffer.writer().print(")\n\n" ++ + "func (i {s}) String() string {{\n", .{ + name, + }); + + if (type_info.tag_type == u1) { + const enum_zero_name = prefix ++ comptime to_pascal_case( + @tagName(@as(Type, @enumFromInt(0))), + null, + ); + const enum_one_name = prefix ++ comptime to_pascal_case( + @tagName(@as(Type, @enumFromInt(1))), + null, + ); + + try buffer.writer().print("\tif (i == {s}) {{\n" ++ + "\t\treturn \"{s}\"\n" ++ + "\t}} else {{\n" ++ + "\t\treturn \"{s}\"\n" ++ + "\t}}\n", .{ + enum_one_name, + enum_one_name, + enum_zero_name, + }); + } else { + try buffer.writer().print("\tswitch i {{\n", .{}); + + inline for (type_info.fields) |field| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + const enum_name = prefix ++ comptime to_pascal_case(field.name, null); + try buffer.writer().print("\tcase {s}:\n" ++ + "\t\treturn \"{s}\"\n", .{ + enum_name, + enum_name, + }); + } + + try buffer.writer().print( + "\t}}\n" ++ + "\treturn \"{s}(\" + strconv.FormatInt(int64(i+1), 10) + \")\"\n", + .{name}, + ); + } + + try buffer.writer().print("}}\n\n", .{}); +} + +fn emit_packed_struct( + buffer: *std.ArrayList(u8), + comptime type_info: anytype, + comptime name: []const u8, + comptime int_type: []const u8, +) !void { + try buffer.writer().print("type {s} struct {{\n", .{ + name, + }); + + const min_len = calculate_min_len(type_info); + inline for (type_info.fields) |field| { + if (comptime std.mem.eql(u8, "padding", field.name)) continue; + try buffer.writer().print("\t{s} {s}\n", .{ + to_pascal_case(field.name, min_len), + go_type(field.type), + }); + } + + // Conversion from struct to packed (e.g. AccountFlags.ToUint16()) + try buffer.writer().print("}}\n\n" ++ + "func (f {s}) To{s}() {s} {{\n" ++ + "\tvar ret {s} = 0\n\n", .{ + name, + to_pascal_case(int_type, null), + int_type, + int_type, + }); + + inline for (type_info.fields, 0..) |field, i| { + if (comptime std.mem.eql(u8, "padding", field.name)) continue; + + try buffer.writer().print("\tif f.{s} {{\n" ++ + "\t\tret |= (1 << {d})\n" ++ + "\t}}\n\n", .{ + to_pascal_case(field.name, null), + i, + }); + } + + try buffer.writer().print("\treturn ret\n" ++ + "}}\n\n", .{}); +} + +fn emit_struct( + buffer: *std.ArrayList(u8), + comptime type_info: anytype, + comptime name: []const u8, +) !void { + try buffer.writer().print("type {s} struct {{\n", .{ + name, + }); + + const min_len = calculate_min_len(type_info); + comptime var flagsField = false; + inline for (type_info.fields) |field| { + switch (@typeInfo(field.type)) { + .array => |array| { + try buffer.writer().print("\t{s} [{d}]{s}\n", .{ + to_pascal_case(field.name, min_len), + array.len, + go_type(array.child), + }); + }, + else => { + if (comptime std.mem.eql(u8, field.name, "flags")) { + flagsField = true; + } + + try buffer.writer().print( + "\t{s} {s}\n", + .{ + to_pascal_case(field.name, min_len), + go_type(field.type), + }, + ); + }, + } + } + + try buffer.writer().print("}}\n\n", .{}); + + if (flagsField) { + const flagType = if (comptime std.mem.eql(u8, name, "Account")) + tb.AccountFlags + else if (comptime std.mem.eql(u8, name, "Transfer")) + tb.TransferFlags + else if (comptime std.mem.eql(u8, name, "AccountFilter")) + tb.AccountFilterFlags + else if (comptime std.mem.eql(u8, name, "QueryFilter")) + tb.QueryFilterFlags + else + unreachable; + // Conversion from packed to struct (e.g. Account.AccountFlags()) + try buffer.writer().print( + "func (o {s}) {s}Flags() {s}Flags {{\n" ++ + "\tvar f {s}Flags\n", + .{ + name, + name, + name, + name, + }, + ); + + switch (@typeInfo(flagType)) { + .@"struct" => |info| switch (info.layout) { + .@"packed" => inline for (info.fields, 0..) |field, i| { + if (comptime std.mem.eql(u8, "padding", field.name)) continue; + + try buffer.writer().print("\tf.{s} = ((o.Flags >> {}) & 0x1) == 1\n", .{ + to_pascal_case(field.name, null), + i, + }); + }, + else => unreachable, + }, + else => unreachable, + } + + try buffer.writer().print("\treturn f\n" ++ + "}}\n\n", .{}); + } +} + +pub fn generate_bindings(buffer: *std.ArrayList(u8)) !void { + @setEvalBranchQuota(100_000); + + try buffer.writer().print( + \\/////////////////////////////////////////////////////// + \\// This file was auto-generated by go_bindings.zig // + \\// Do not manually modify. // + \\/////////////////////////////////////////////////////// + \\ + \\package tigerbeetle_go + \\ + \\/* + \\#include "./native/tb_client.h" + \\*/ + \\import "C" + \\import "strconv" + \\ + \\ + , .{}); + + // Emit Go declarations. + inline for (type_mappings) |type_mapping| { + const ZigType = type_mapping[0]; + const name = type_mapping[1]; + + switch (@typeInfo(ZigType)) { + .@"struct" => |info| switch (info.layout) { + .auto => @compileError( + "Only packed or extern structs are supported: " ++ @typeName(ZigType), + ), + .@"packed" => try emit_packed_struct( + buffer, + info, + name, + comptime go_type(std.meta.Int(.unsigned, @bitSizeOf(ZigType))), + ), + .@"extern" => try emit_struct(buffer, info, name), + }, + .@"enum" => try emit_enum( + buffer, + ZigType, + name, + type_mapping[2], + comptime go_type(std.meta.Int(.unsigned, @bitSizeOf(ZigType))), + ), + else => @compileError("Type cannot be represented: " ++ @typeName(ZigType)), + } + } + assert(buffer.pop() == '\n'); + assert(std.mem.endsWith(u8, buffer.items, "\n")); + assert(!std.mem.endsWith(u8, buffer.items, "\n\n")); +} + +pub fn main() !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var buffer = std.ArrayList(u8).init(allocator); + try generate_bindings(&buffer); + try std.io.getStdOut().writeAll(buffer.items); +} diff --git a/ocam/src/clients/go/native/native.go b/ocam/src/clients/go/native/native.go new file mode 100644 index 00000000..ddc27da0 --- /dev/null +++ b/ocam/src/clients/go/native/native.go @@ -0,0 +1,4 @@ +// Adds reference to sub-folders containing the external (non-Go) files +// required to build the TigerBeetle client. Otherwise the `tb_client.h` +// header and library object files would be pruned during `go mod vendor`. +package native diff --git a/ocam/src/clients/go/native/tb_client.h b/ocam/src/clients/go/native/tb_client.h new file mode 100644 index 00000000..bb4554e6 --- /dev/null +++ b/ocam/src/clients/go/native/tb_client.h @@ -0,0 +1,388 @@ + ////////////////////////////////////////////////////////// + // This file was auto-generated by tb_client_header.zig // + // Do not manually modify. // + ////////////////////////////////////////////////////////// + +#ifndef TB_CLIENT_H +#define TB_CLIENT_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +typedef __uint128_t tb_uint128_t; + +typedef enum TB_ACCOUNT_FLAGS { + TB_ACCOUNT_LINKED = 1 << 0, + TB_ACCOUNT_DEBITS_MUST_NOT_EXCEED_CREDITS = 1 << 1, + TB_ACCOUNT_CREDITS_MUST_NOT_EXCEED_DEBITS = 1 << 2, + TB_ACCOUNT_HISTORY = 1 << 3, + TB_ACCOUNT_IMPORTED = 1 << 4, + TB_ACCOUNT_CLOSED = 1 << 5, +} TB_ACCOUNT_FLAGS; + +typedef struct tb_account_t { + tb_uint128_t id; + tb_uint128_t debits_pending; + tb_uint128_t debits_posted; + tb_uint128_t credits_pending; + tb_uint128_t credits_posted; + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint32_t reserved; + uint32_t ledger; + uint16_t code; + uint16_t flags; + uint64_t timestamp; +} tb_account_t; + +typedef enum TB_TRANSFER_FLAGS { + TB_TRANSFER_LINKED = 1 << 0, + TB_TRANSFER_PENDING = 1 << 1, + TB_TRANSFER_POST_PENDING_TRANSFER = 1 << 2, + TB_TRANSFER_VOID_PENDING_TRANSFER = 1 << 3, + TB_TRANSFER_BALANCING_DEBIT = 1 << 4, + TB_TRANSFER_BALANCING_CREDIT = 1 << 5, + TB_TRANSFER_CLOSING_DEBIT = 1 << 6, + TB_TRANSFER_CLOSING_CREDIT = 1 << 7, + TB_TRANSFER_IMPORTED = 1 << 8, +} TB_TRANSFER_FLAGS; + +typedef struct tb_transfer_t { + tb_uint128_t id; + tb_uint128_t debit_account_id; + tb_uint128_t credit_account_id; + tb_uint128_t amount; + tb_uint128_t pending_id; + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint32_t timeout; + uint32_t ledger; + uint16_t code; + uint16_t flags; + uint64_t timestamp; +} tb_transfer_t; + +typedef enum TB_CREATE_ACCOUNT_STATUS { + TB_CREATE_ACCOUNT_CREATED = 0xFFFFFFFF, + TB_CREATE_ACCOUNT_LINKED_EVENT_FAILED = 1, + TB_CREATE_ACCOUNT_LINKED_EVENT_CHAIN_OPEN = 2, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_EXPECTED = 22, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_NOT_EXPECTED = 23, + TB_CREATE_ACCOUNT_TIMESTAMP_MUST_BE_ZERO = 3, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE = 24, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE = 25, + TB_CREATE_ACCOUNT_RESERVED_FIELD = 4, + TB_CREATE_ACCOUNT_RESERVED_FLAG = 5, + TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_ZERO = 6, + TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 7, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_FLAGS = 15, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_128 = 16, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_64 = 17, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_32 = 18, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_LEDGER = 19, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_CODE = 20, + TB_CREATE_ACCOUNT_EXISTS = 21, + TB_CREATE_ACCOUNT_FLAGS_ARE_MUTUALLY_EXCLUSIVE = 8, + TB_CREATE_ACCOUNT_DEBITS_PENDING_MUST_BE_ZERO = 9, + TB_CREATE_ACCOUNT_DEBITS_POSTED_MUST_BE_ZERO = 10, + TB_CREATE_ACCOUNT_CREDITS_PENDING_MUST_BE_ZERO = 11, + TB_CREATE_ACCOUNT_CREDITS_POSTED_MUST_BE_ZERO = 12, + TB_CREATE_ACCOUNT_LEDGER_MUST_NOT_BE_ZERO = 13, + TB_CREATE_ACCOUNT_CODE_MUST_NOT_BE_ZERO = 14, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS = 26, +} TB_CREATE_ACCOUNT_STATUS; + +typedef enum TB_CREATE_TRANSFER_STATUS { + TB_CREATE_TRANSFER_CREATED = 0xFFFFFFFF, + TB_CREATE_TRANSFER_LINKED_EVENT_FAILED = 1, + TB_CREATE_TRANSFER_LINKED_EVENT_CHAIN_OPEN = 2, + TB_CREATE_TRANSFER_IMPORTED_EVENT_EXPECTED = 56, + TB_CREATE_TRANSFER_IMPORTED_EVENT_NOT_EXPECTED = 57, + TB_CREATE_TRANSFER_TIMESTAMP_MUST_BE_ZERO = 3, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE = 58, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE = 59, + TB_CREATE_TRANSFER_RESERVED_FLAG = 4, + TB_CREATE_TRANSFER_ID_MUST_NOT_BE_ZERO = 5, + TB_CREATE_TRANSFER_ID_MUST_NOT_BE_INT_MAX = 6, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_FLAGS = 36, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_PENDING_ID = 40, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_TIMEOUT = 44, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_DEBIT_ACCOUNT_ID = 37, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CREDIT_ACCOUNT_ID = 38, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_AMOUNT = 39, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_128 = 41, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_64 = 42, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_32 = 43, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_LEDGER = 67, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CODE = 45, + TB_CREATE_TRANSFER_EXISTS = 46, + TB_CREATE_TRANSFER_ID_ALREADY_FAILED = 68, + TB_CREATE_TRANSFER_FLAGS_ARE_MUTUALLY_EXCLUSIVE = 7, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_ZERO = 8, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 9, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_ZERO = 10, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 11, + TB_CREATE_TRANSFER_ACCOUNTS_MUST_BE_DIFFERENT = 12, + TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_ZERO = 13, + TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_ZERO = 14, + TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_INT_MAX = 15, + TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_DIFFERENT = 16, + TB_CREATE_TRANSFER_TIMEOUT_RESERVED_FOR_PENDING_TRANSFER = 17, + TB_CREATE_TRANSFER_CLOSING_TRANSFER_MUST_BE_PENDING = 64, + TB_CREATE_TRANSFER_LEDGER_MUST_NOT_BE_ZERO = 19, + TB_CREATE_TRANSFER_CODE_MUST_NOT_BE_ZERO = 20, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_NOT_FOUND = 21, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_NOT_FOUND = 22, + TB_CREATE_TRANSFER_ACCOUNTS_MUST_HAVE_THE_SAME_LEDGER = 23, + TB_CREATE_TRANSFER_TRANSFER_MUST_HAVE_THE_SAME_LEDGER_AS_ACCOUNTS = 24, + TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_FOUND = 25, + TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_PENDING = 26, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_DEBIT_ACCOUNT_ID = 27, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CREDIT_ACCOUNT_ID = 28, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_LEDGER = 29, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CODE = 30, + TB_CREATE_TRANSFER_EXCEEDS_PENDING_TRANSFER_AMOUNT = 31, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_AMOUNT = 32, + TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_POSTED = 33, + TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_VOIDED = 34, + TB_CREATE_TRANSFER_PENDING_TRANSFER_EXPIRED = 35, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS = 60, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_DEBIT_ACCOUNT = 61, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_CREDIT_ACCOUNT = 62, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMEOUT_MUST_BE_ZERO = 63, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ALREADY_CLOSED = 65, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ALREADY_CLOSED = 66, + TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_PENDING = 47, + TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_PENDING = 48, + TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_POSTED = 49, + TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_POSTED = 50, + TB_CREATE_TRANSFER_OVERFLOWS_DEBITS = 51, + TB_CREATE_TRANSFER_OVERFLOWS_CREDITS = 52, + TB_CREATE_TRANSFER_OVERFLOWS_TIMEOUT = 53, + TB_CREATE_TRANSFER_EXCEEDS_CREDITS = 54, + TB_CREATE_TRANSFER_EXCEEDS_DEBITS = 55, +} TB_CREATE_TRANSFER_STATUS; + +typedef struct tb_create_account_result_t { + uint64_t timestamp; + uint32_t status; + uint32_t reserved; +} tb_create_account_result_t; + +typedef struct tb_create_transfer_result_t { + uint64_t timestamp; + uint32_t status; + uint32_t reserved; +} tb_create_transfer_result_t; + +typedef struct tb_account_filter_t { + tb_uint128_t account_id; + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint16_t code; + uint8_t reserved[58]; + uint64_t timestamp_min; + uint64_t timestamp_max; + uint32_t limit; + uint32_t flags; +} tb_account_filter_t; + +typedef enum TB_ACCOUNT_FILTER_FLAGS { + TB_ACCOUNT_FILTER_DEBITS = 1 << 0, + TB_ACCOUNT_FILTER_CREDITS = 1 << 1, + TB_ACCOUNT_FILTER_REVERSED = 1 << 2, +} TB_ACCOUNT_FILTER_FLAGS; + +typedef struct tb_account_balance_t { + tb_uint128_t debits_pending; + tb_uint128_t debits_posted; + tb_uint128_t credits_pending; + tb_uint128_t credits_posted; + uint64_t timestamp; + uint8_t reserved[56]; +} tb_account_balance_t; + +typedef struct tb_query_filter_t { + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint32_t ledger; + uint16_t code; + uint8_t reserved[6]; + uint64_t timestamp_min; + uint64_t timestamp_max; + uint32_t limit; + uint32_t flags; +} tb_query_filter_t; + +typedef enum TB_QUERY_FILTER_FLAGS { + TB_QUERY_FILTER_REVERSED = 1 << 0, +} TB_QUERY_FILTER_FLAGS; + +// Opaque struct serving as a handle for the client instance. +// This struct must be "pinned" (not copyable or movable), as its address must remain stable +// throughout the lifetime of the client instance. +typedef struct tb_client_t { + uint64_t opaque[4]; +} tb_client_t; + +// Struct containing the state of a request submitted through the client. +// This struct must be "pinned" (not copyable or movable), as its address must remain stable +// throughout the lifetime of the request. +typedef struct tb_packet_t { + void* user_data; + void* data; + uint32_t data_size; + uint16_t user_tag; + uint8_t operation; + uint8_t status; + uint8_t opaque[64]; +} tb_packet_t; + +typedef enum TB_OPERATION { + TB_OPERATION_PULSE = 128, + TB_OPERATION_GET_CHANGE_EVENTS = 137, + TB_OPERATION_LOOKUP_ACCOUNTS = 140, + TB_OPERATION_LOOKUP_TRANSFERS = 141, + TB_OPERATION_GET_ACCOUNT_TRANSFERS = 142, + TB_OPERATION_GET_ACCOUNT_BALANCES = 143, + TB_OPERATION_QUERY_ACCOUNTS = 144, + TB_OPERATION_QUERY_TRANSFERS = 145, + TB_OPERATION_CREATE_ACCOUNTS = 146, + TB_OPERATION_CREATE_TRANSFERS = 147, +} TB_OPERATION; + +typedef enum TB_PACKET_STATUS { + TB_PACKET_OK = 0, + TB_PACKET_TOO_MUCH_DATA = 1, + TB_PACKET_CLIENT_EVICTED = 2, + TB_PACKET_CLIENT_RELEASE_TOO_LOW = 3, + TB_PACKET_CLIENT_RELEASE_TOO_HIGH = 4, + TB_PACKET_CLIENT_SHUTDOWN = 5, + TB_PACKET_INVALID_OPERATION = 6, + TB_PACKET_INVALID_DATA_SIZE = 7, +} TB_PACKET_STATUS; + +typedef enum TB_INIT_STATUS { + TB_INIT_SUCCESS = 0, + TB_INIT_UNEXPECTED = 1, + TB_INIT_OUT_OF_MEMORY = 2, + TB_INIT_ADDRESS_INVALID = 3, + TB_INIT_ADDRESS_LIMIT_EXCEEDED = 4, + TB_INIT_SYSTEM_RESOURCES = 5, + TB_INIT_NETWORK_SUBSYSTEM = 6, +} TB_INIT_STATUS; + +typedef enum TB_CLIENT_STATUS { + TB_CLIENT_OK = 0, + TB_CLIENT_INVALID = 1, +} TB_CLIENT_STATUS; + +typedef enum TB_REGISTER_LOG_CALLBACK_STATUS { + TB_REGISTER_LOG_CALLBACK_SUCCESS = 0, + TB_REGISTER_LOG_CALLBACK_ALREADY_REGISTERED = 1, + TB_REGISTER_LOG_CALLBACK_NOT_REGISTERED = 2, +} TB_REGISTER_LOG_CALLBACK_STATUS; + +typedef enum TB_LOG_LEVEL { + TB_LOG_ERR = 0, + TB_LOG_WARN = 1, + TB_LOG_INFO = 2, + TB_LOG_DEBUG = 3, +} TB_LOG_LEVEL; + +typedef struct tb_init_parameters_t { + tb_uint128_t cluster_id; + tb_uint128_t client_id; + uint8_t* addresses_ptr; + uint64_t addresses_len; +} tb_init_parameters_t; + +// Per-client callback invoked every time a `tb_client_submit` completes or is canceled. +// Use `packet->userdata` to identify the specific submission. +// `result` is null iff `packet->status != TB_PACKET_OK` +// `result` is only valid for the duration of the callback itself. +typedef void (*tb_completion_t)( + uintptr_t userdata, + tb_packet_t* packet, + uint64_t timestamp, + const uint8_t *result, // nullable + uint32_t result_size +); + +// Initialize a new TigerBeetle client which connects to the addresses provided and +// completes submitted packets by invoking the callback with the given context. +TB_INIT_STATUS tb_client_init( + tb_client_t *client_out, + // 128-bit unsigned integer represented as a 16-byte little-endian array. + const uint8_t cluster_id[16], + const char *address_ptr, + uint32_t address_len, + uintptr_t completion_ctx, + tb_completion_t completion_callback +); + +// Initialize a new TigerBeetle client that echoes back any submitted data. +TB_INIT_STATUS tb_client_init_echo( + tb_client_t *client_out, + // 128-bit unsigned integer represented as a 16-byte little-endian array. + const uint8_t cluster_id[16], + const char *address_ptr, + uint32_t address_len, + uintptr_t completion_ctx, + tb_completion_t completion_callback +); + +// Retrieve the parameters initially passed to `tb_client_init` or `tb_client_init_echo`. +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_init_parameters( + tb_client_t* client, + tb_init_parameters_t* init_parameters_out +); + +// Retrieve the callback context initially passed to `tb_client_init` or `tb_client_init_echo`. +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_completion_context( + tb_client_t* client, + uintptr_t* completion_ctx_out +); + +// Submit a packet with its `operation`, `data`, and `data_size` fields set. +// Once completed, `completion_callback` will be invoked with `completion_ctx` +// and the given packet on the `tb_client` thread (separate from the caller's thread). +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_submit( + tb_client_t *client, + tb_packet_t *packet +); + +// Closes the client, causing any previously submitted packets to be completed with +// `TB_PACKET_CLIENT_SHUTDOWN` before freeing any allocated client resources from init. +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_deinit( + tb_client_t *client +); + +// Registers or unregisters the application log callback. +TB_REGISTER_LOG_CALLBACK_STATUS tb_client_register_log_callback( + void (*callback)(TB_LOG_LEVEL, const uint8_t*, uint32_t), + bool debug +); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // TB_CLIENT_H diff --git a/ocam/src/clients/go/samples/basic/README.md b/ocam/src/clients/go/samples/basic/README.md new file mode 100644 index 00000000..bdd588ef --- /dev/null +++ b/ocam/src/clients/go/samples/basic/README.md @@ -0,0 +1,67 @@ + +# Basic Go Sample + +Code for this sample is in [./main.go](./main.go). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Go >= 1.21 + +**Additionally on Windows**: you must install [Zig +0.14.1](https://ziglang.org/download/#release-0.14.1) and set the +`CC` environment variable to `zig.exe cc`. Use the full path for +`zig.exe`. + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/go/samples/basic`. + +Then, install the TigerBeetle client: + +```console +go mod init tbtest +go get github.com/tigerbeetle/tigerbeetle-go +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +go run main.go +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create a transfer + +Then it transfers `10` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate account balances + +Then it fetches both accounts, checks they both exist, and +checks that **account `1`** has: + * `debits_posted = 10` + * and `credits_posted = 0` + +And that **account `2`** has: + * `debits_posted= 0` + * and `credits_posted = 10` diff --git a/ocam/src/clients/go/samples/basic/go.mod b/ocam/src/clients/go/samples/basic/go.mod new file mode 100644 index 00000000..63175024 --- /dev/null +++ b/ocam/src/clients/go/samples/basic/go.mod @@ -0,0 +1,7 @@ +module basic + +go 1.17 + +require github.com/tigerbeetle/tigerbeetle-go v0.0.0 + +replace github.com/tigerbeetle/tigerbeetle-go => ../../ diff --git a/ocam/src/clients/go/samples/basic/main.go b/ocam/src/clients/go/samples/basic/main.go new file mode 100644 index 00000000..261fe774 --- /dev/null +++ b/ocam/src/clients/go/samples/basic/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "log" + "os" + "reflect" + + . "github.com/tigerbeetle/tigerbeetle-go" +) + +// Since we only require Go 1.17 we can't do this as a generic function +// even though that would be fine. So do the dynamic approach for now. +func assert(a, b interface{}, field string) { + if !reflect.DeepEqual(a, b) { + log.Fatalf("Expected %s to be [%+v (%T)], got: [%+v (%T)]", field, b, b, a, a) + } +} + +func main() { + port := os.Getenv("TB_ADDRESS") + if port == "" { + port = "3000" + } + + client, err := NewClient(ToUint128(0), []string{port}) + if err != nil { + log.Fatalf("Error creating client: %s", err) + } + defer client.Close() + + // Create two accounts + accountResults, err := client.CreateAccounts([]Account{ + { + ID: ToUint128(1), + Ledger: 1, + Code: 1, + }, + { + ID: ToUint128(2), + Ledger: 1, + Code: 1, + }, + }) + if err != nil { + log.Fatalf("Error creating accounts: %s", err) + } + + assert(len(accountResults), 2, "accountResults") + for i, result := range accountResults { + switch result.Status { + case AccountCreated: + default: + log.Fatalf("Error creating account %d: %s", i, result.Status) + } + } + + transferResults, err := client.CreateTransfers([]Transfer{ + { + ID: ToUint128(1), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + }, + }) + if err != nil { + log.Fatalf("Error creating transfer: %s", err) + } + + assert(len(transferResults), 1, "transferResults") + for i, result := range transferResults { + switch result.Status { + case TransferCreated: + default: + log.Fatalf("Error creating transfer %d: %s", i, result.Status) + } + } + + // Check the sums for both accounts + accounts, err := client.LookupAccounts([]Uint128{ToUint128(1), ToUint128(2)}) + if err != nil { + log.Fatalf("Could not fetch accounts: %s", err) + } + assert(len(accounts), 2, "accounts") + + for _, account := range accounts { + if account.ID == ToUint128(1) { + assert(account.DebitsPosted, ToUint128(10), "account 1 debits") + assert(account.CreditsPosted, ToUint128(0), "account 1 credits") + } else if account.ID == ToUint128(2) { + assert(account.DebitsPosted, ToUint128(0), "account 2 debits") + assert(account.CreditsPosted, ToUint128(10), "account 2 credits") + } else { + log.Fatalf("Unexpected account") + } + } +} diff --git a/ocam/src/clients/go/samples/two-phase-many/README.md b/ocam/src/clients/go/samples/two-phase-many/README.md new file mode 100644 index 00000000..fe1b1cfe --- /dev/null +++ b/ocam/src/clients/go/samples/two-phase-many/README.md @@ -0,0 +1,97 @@ + +# Many Two-Phase Transfers Go Sample + +Code for this sample is in [./main.go](./main.go). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Go >= 1.21 + +**Additionally on Windows**: you must install [Zig +0.14.1](https://ziglang.org/download/#release-0.14.1) and set the +`CC` environment variable to `zig.exe cc`. Use the full path for +`zig.exe`. + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/go/samples/two-phase-many`. + +Then, install the TigerBeetle client: + +```console +go mod init tbtest +go get github.com/tigerbeetle/tigerbeetle-go +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +go run main.go +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfers + +Then it begins 5 pending transfers of amounts `100` to +`500`, incrementing by `100` for each transfer. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 1500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 1500` + +(This is because a pending transfer only affects **pending** +credits and debits on accounts, not **posted** credits and +debits.) + +## 4. Post and void alternating transfers + +Then it alternatively posts and voids each transfer, +checking account balances after each transfer. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +solely *posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 900` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 900` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/go/samples/two-phase-many/go.mod b/ocam/src/clients/go/samples/two-phase-many/go.mod new file mode 100644 index 00000000..b65a3a7e --- /dev/null +++ b/ocam/src/clients/go/samples/two-phase-many/go.mod @@ -0,0 +1,7 @@ +module two-phase-many + +go 1.17 + +require github.com/tigerbeetle/tigerbeetle-go v0.0.0 + +replace github.com/tigerbeetle/tigerbeetle-go => ../../ diff --git a/ocam/src/clients/go/samples/two-phase-many/main.go b/ocam/src/clients/go/samples/two-phase-many/main.go new file mode 100644 index 00000000..4675f277 --- /dev/null +++ b/ocam/src/clients/go/samples/two-phase-many/main.go @@ -0,0 +1,390 @@ +package main + +import ( + "fmt" + "log" + "os" + "reflect" + + . "github.com/tigerbeetle/tigerbeetle-go" +) + +// Since we only require Go 1.17 we can't do this as a generic function +// even though that would be fine. So do the dynamic approach for now. +func assert(expected, got interface{}, field string) { + if !reflect.DeepEqual(expected, got) { + log.Fatalf("Expected %s to be [%+v (%T)], got: [%+v (%T)]", field, expected, expected, got, got) + } +} + +func assertAccountBalances(client Client, accounts []Account, debugMsg string) { + ids := []Uint128{} + for _, account := range accounts { + ids = append(ids, account.ID) + } + found, err := client.LookupAccounts(ids) + if err != nil { + log.Fatalf("Could not fetch accounts: %s", err) + } + assert(len(accounts), len(found), "accounts") + + for _, account_found := range found { + requested := false + for _, account := range accounts { + if account.ID == account_found.ID { + requested = true + assert(account.DebitsPosted, account_found.DebitsPosted, + fmt.Sprintf("account %s debits, %s", account.ID, debugMsg)) + assert(account.CreditsPosted, account_found.CreditsPosted, + fmt.Sprintf("account %s credits, %s", account.ID, debugMsg)) + assert(account.DebitsPending, account_found.DebitsPending, + fmt.Sprintf("account %s debits pending, %s", account.ID, debugMsg)) + assert(account.CreditsPending, account_found.CreditsPending, + fmt.Sprintf("account %s credits pending, %s", account.ID, debugMsg)) + } + } + + if !requested { + log.Fatalf("Unexpected account: %s", account_found.ID) + } + } +} + +func main() { + port := os.Getenv("TB_ADDRESS") + if port == "" { + port = "3000" + } + + client, err := NewClient(ToUint128(0), []string{port}) + if err != nil { + log.Fatalf("Error creating client: %s", err) + } + defer client.Close() + + // Create two accounts. + accountResults, err := client.CreateAccounts([]Account{ + { + ID: ToUint128(1), + Ledger: 1, + Code: 1, + }, + { + ID: ToUint128(2), + Ledger: 1, + Code: 1, + }, + }) + if err != nil { + log.Fatalf("Error creating accounts: %s", err) + } + + assert(len(accountResults), 2, "accountResults") + for i, result := range accountResults { + switch result.Status { + case AccountCreated: + default: + log.Fatalf("Error creating account %d: %s", i, result.Status) + } + } + + // Start five pending transfers. + transfers := []Transfer{ + { + ID: ToUint128(1), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(100), + Ledger: 1, + Code: 1, + Flags: TransferFlags{Pending: true}.ToUint16(), + }, + { + ID: ToUint128(2), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(200), + Ledger: 1, + Code: 1, + Flags: TransferFlags{Pending: true}.ToUint16(), + }, + { + ID: ToUint128(3), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(300), + Ledger: 1, + Code: 1, + Flags: TransferFlags{Pending: true}.ToUint16(), + }, + { + ID: ToUint128(4), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(400), + Ledger: 1, + Code: 1, + Flags: TransferFlags{Pending: true}.ToUint16(), + }, + { + ID: ToUint128(5), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(500), + Ledger: 1, + Code: 1, + Flags: TransferFlags{Pending: true}.ToUint16(), + }, + } + transferResults, err := client.CreateTransfers(transfers) + if err != nil { + log.Fatalf("Error creating transfer: %s", err) + } + + assert(len(transferResults), len(transfers), "transferResults") + for i, result := range transferResults { + switch result.Status { + case TransferCreated: + default: + log.Fatalf("Error creating transfer %d: %s", i, result.Status) + } + } + + // Validate accounts pending and posted debits/credits before finishing the two-phase transfer. + assertAccountBalances(client, []Account{ + { + ID: ToUint128(1), + DebitsPosted: ToUint128(0), + CreditsPosted: ToUint128(0), + DebitsPending: ToUint128(1500), + CreditsPending: ToUint128(0), + }, + { + ID: ToUint128(2), + DebitsPosted: ToUint128(0), + CreditsPosted: ToUint128(0), + DebitsPending: ToUint128(0), + CreditsPending: ToUint128(1500), + }, + }, "after starting 5 pending transfers") + + // Create a 6th transfer posting the 1st transfer. + transferResults, err = client.CreateTransfers([]Transfer{ + { + ID: ToUint128(6), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(100), + PendingID: ToUint128(1), + Ledger: 1, + Code: 1, + Flags: TransferFlags{PostPendingTransfer: true}.ToUint16(), + }, + }) + if err != nil { + log.Fatalf("Error creating transfers: %s", err) + } + + assert(len(transferResults), 1, "transferResults") + for i, result := range transferResults { + switch result.Status { + case TransferCreated: + default: + log.Fatalf("Error creating transfer %d: %s", i, result.Status) + } + } + + // Validate account balances after posting 1st pending transfer. + assertAccountBalances(client, []Account{ + { + ID: ToUint128(1), + DebitsPosted: ToUint128(100), + CreditsPosted: ToUint128(0), + DebitsPending: ToUint128(1400), + CreditsPending: ToUint128(0), + }, + { + ID: ToUint128(2), + DebitsPosted: ToUint128(0), + CreditsPosted: ToUint128(100), + DebitsPending: ToUint128(0), + CreditsPending: ToUint128(1400), + }, + }, "after completing 1 pending transfer") + + // Create a 7th transfer voiding the 2nd transfer. + transferResults, err = client.CreateTransfers([]Transfer{ + { + ID: ToUint128(7), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(200), + PendingID: ToUint128(2), + Ledger: 1, + Code: 1, + Flags: TransferFlags{VoidPendingTransfer: true}.ToUint16(), + }, + }) + if err != nil { + log.Fatalf("Error creating transfers: %s", err) + } + + assert(len(transferResults), 1, "transferResults") + for i, result := range transferResults { + switch result.Status { + case TransferCreated: + default: + log.Fatalf("Error creating transfer %d: %s", i, result.Status) + } + } + + // Validate account balances after voiding 2nd pending transfer. + assertAccountBalances(client, []Account{ + { + ID: ToUint128(1), + DebitsPosted: ToUint128(100), + CreditsPosted: ToUint128(0), + DebitsPending: ToUint128(1200), + CreditsPending: ToUint128(0), + }, + { + ID: ToUint128(2), + DebitsPosted: ToUint128(0), + CreditsPosted: ToUint128(100), + DebitsPending: ToUint128(0), + CreditsPending: ToUint128(1200), + }, + }, "after completing 2 pending transfers") + + // Create a 8th transfer posting the 3rd transfer. + transferResults, err = client.CreateTransfers([]Transfer{ + { + ID: ToUint128(8), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(300), + PendingID: ToUint128(3), + Ledger: 1, + Code: 1, + Flags: TransferFlags{PostPendingTransfer: true}.ToUint16(), + }, + }) + if err != nil { + log.Fatalf("Error creating transfers: %s", err) + } + + assert(len(transferResults), 1, "transferResults") + for i, result := range transferResults { + switch result.Status { + case TransferCreated: + default: + log.Fatalf("Error creating transfer %d: %s", i, result.Status) + } + } + + // Validate account balances after posting 3rd pending transfer. + assertAccountBalances(client, []Account{ + { + ID: ToUint128(1), + DebitsPosted: ToUint128(400), + CreditsPosted: ToUint128(0), + DebitsPending: ToUint128(900), + CreditsPending: ToUint128(0), + }, + { + ID: ToUint128(2), + DebitsPosted: ToUint128(0), + CreditsPosted: ToUint128(400), + DebitsPending: ToUint128(0), + CreditsPending: ToUint128(900), + }, + }, "after completing 3 pending transfers") + + // Create a 9th transfer voiding the 4th transfer. + transferResults, err = client.CreateTransfers([]Transfer{ + { + ID: ToUint128(9), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(400), + PendingID: ToUint128(4), + Ledger: 1, + Code: 1, + Flags: TransferFlags{VoidPendingTransfer: true}.ToUint16(), + }, + }) + if err != nil { + log.Fatalf("Error creating transfers: %s", err) + } + + assert(len(transferResults), 1, "transferResults") + for i, result := range transferResults { + switch result.Status { + case TransferCreated: + default: + log.Fatalf("Error creating transfer %d: %s", i, result.Status) + } + } + + // Validate account balances after voiding 4th pending transfer. + assertAccountBalances(client, []Account{ + { + ID: ToUint128(1), + DebitsPosted: ToUint128(400), + CreditsPosted: ToUint128(0), + DebitsPending: ToUint128(500), + CreditsPending: ToUint128(0), + }, + { + ID: ToUint128(2), + DebitsPosted: ToUint128(0), + CreditsPosted: ToUint128(400), + DebitsPending: ToUint128(0), + CreditsPending: ToUint128(500), + }, + }, "after completing 4 pending transfers") + + // Create a 10th transfer posting the 5th transfer. + transferResults, err = client.CreateTransfers([]Transfer{ + { + ID: ToUint128(10), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(500), + PendingID: ToUint128(5), + Ledger: 1, + Code: 1, + Flags: TransferFlags{PostPendingTransfer: true}.ToUint16(), + }, + }) + if err != nil { + log.Fatalf("Error creating transfers: %s", err) + } + + assert(len(transferResults), 1, "transferResults") + for i, result := range transferResults { + switch result.Status { + case TransferCreated: + default: + log.Fatalf("Error creating transfer %d: %s", i, result.Status) + } + } + + // Validate account balances after posting 5th pending transfer. + assertAccountBalances(client, []Account{ + { + ID: ToUint128(1), + DebitsPosted: ToUint128(900), + CreditsPosted: ToUint128(0), + DebitsPending: ToUint128(0), + CreditsPending: ToUint128(0), + }, + { + ID: ToUint128(2), + DebitsPosted: ToUint128(0), + CreditsPosted: ToUint128(900), + DebitsPending: ToUint128(0), + CreditsPending: ToUint128(0), + }, + }, "after completing 5 pending transfers") +} diff --git a/ocam/src/clients/go/samples/two-phase/.gitignore b/ocam/src/clients/go/samples/two-phase/.gitignore new file mode 100644 index 00000000..a25dda9d --- /dev/null +++ b/ocam/src/clients/go/samples/two-phase/.gitignore @@ -0,0 +1 @@ +two-phase \ No newline at end of file diff --git a/ocam/src/clients/go/samples/two-phase/README.md b/ocam/src/clients/go/samples/two-phase/README.md new file mode 100644 index 00000000..bf88ad4e --- /dev/null +++ b/ocam/src/clients/go/samples/two-phase/README.md @@ -0,0 +1,106 @@ + +# Two-Phase Transfer Go Sample + +Code for this sample is in [./main.go](./main.go). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Go >= 1.21 + +**Additionally on Windows**: you must install [Zig +0.14.1](https://ziglang.org/download/#release-0.14.1) and set the +`CC` environment variable to `zig.exe cc`. Use the full path for +`zig.exe`. + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/go/samples/two-phase`. + +Then, install the TigerBeetle client: + +```console +go mod init tbtest +go get github.com/tigerbeetle/tigerbeetle-go +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +go run main.go +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfer + +Then it begins a +pending transfer of `500` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 500` + +(This is because a pending +transfer only affects **pending** credits and debits on accounts, +not **posted** credits and debits.) + +## 4. Post pending transfer + +Then it creates a second transfer that marks the first +transfer as posted. + +## 5. Fetch and validate transfers + +Then it fetches both transfers, validates +that the two transfers exist, validates that the first +transfer had (and still has) a `pending` flag, and validates +that the second transfer had (and still has) a +`post_pending_transfer` flag. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +*posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 500` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 500` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/go/samples/two-phase/go.mod b/ocam/src/clients/go/samples/two-phase/go.mod new file mode 100644 index 00000000..3c07adf4 --- /dev/null +++ b/ocam/src/clients/go/samples/two-phase/go.mod @@ -0,0 +1,7 @@ +module two-phase + +go 1.17 + +require github.com/tigerbeetle/tigerbeetle-go v0.0.0 + +replace github.com/tigerbeetle/tigerbeetle-go => ../../ diff --git a/ocam/src/clients/go/samples/two-phase/main.go b/ocam/src/clients/go/samples/two-phase/main.go new file mode 100644 index 00000000..e141e598 --- /dev/null +++ b/ocam/src/clients/go/samples/two-phase/main.go @@ -0,0 +1,175 @@ +package main + +import ( + "fmt" + "log" + "os" + "reflect" + + . "github.com/tigerbeetle/tigerbeetle-go" +) + +// Since we only require Go 1.17 we can't do this as a generic function +// even though that would be fine. So do the dynamic approach for now. +func assert(a, b interface{}, field string) { + if !reflect.DeepEqual(a, b) { + log.Fatalf("Expected %s to be [%+v (%T)], got: [%+v (%T)]", field, b, b, a, a) + } +} + +func main() { + port := os.Getenv("TB_ADDRESS") + if port == "" { + port = "3000" + } + + client, err := NewClient(ToUint128(0), []string{port}) + if err != nil { + log.Fatalf("Error creating client: %s", err) + } + defer client.Close() + + // Create two accounts + accountResults, err := client.CreateAccounts([]Account{ + { + ID: ToUint128(1), + Ledger: 1, + Code: 1, + }, + { + ID: ToUint128(2), + Ledger: 1, + Code: 1, + }, + }) + if err != nil { + log.Fatalf("Error creating accounts: %s", err) + } + + assert(len(accountResults), 2, "accountResults") + for i, result := range accountResults { + switch result.Status { + case AccountCreated: + default: + log.Fatalf("Error creating account %d: %s", i, result.Status) + } + } + + // Start a pending transfer + transferResults, err := client.CreateTransfers([]Transfer{ + { + ID: ToUint128(1), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(500), + Ledger: 1, + Code: 1, + Flags: TransferFlags{Pending: true}.ToUint16(), + }, + }) + if err != nil { + log.Fatalf("Error creating transfer: %s", err) + } + + assert(len(transferResults), 1, "transferResults") + for i, result := range transferResults { + switch result.Status { + case TransferCreated: + default: + log.Fatalf("Error creating transfer %d: %s", i, result.Status) + } + } + + // Validate accounts pending and posted debits/credits before finishing the two-phase transfer + accounts, err := client.LookupAccounts([]Uint128{ToUint128(1), ToUint128(2)}) + if err != nil { + log.Fatalf("Could not fetch accounts: %s", err) + } + assert(len(accounts), 2, "accounts") + + for _, account := range accounts { + if account.ID == ToUint128(1) { + assert(account.DebitsPosted, ToUint128(0), "account 1 debits, before posted") + assert(account.CreditsPosted, ToUint128(0), "account 1 credits, before posted") + assert(account.DebitsPending, ToUint128(500), "account 1 debits pending, before posted") + assert(account.CreditsPending, ToUint128(0), "account 1 credits pending, before posted") + } else if account.ID == ToUint128(2) { + assert(account.DebitsPosted, ToUint128(0), "account 2 debits, before posted") + assert(account.CreditsPosted, ToUint128(0), "account 2 credits, before posted") + assert(account.DebitsPending, ToUint128(0), "account 2 debits pending, before posted") + assert(account.CreditsPending, ToUint128(500), "account 2 credits pending, before posted") + } else { + log.Fatalf("Unexpected account: %s", account.ID) + } + } + + // Create a second transfer simply posting the first transfer + transferResults, err = client.CreateTransfers([]Transfer{ + { + ID: ToUint128(2), + DebitAccountID: ToUint128(1), + CreditAccountID: ToUint128(2), + Amount: ToUint128(500), + PendingID: ToUint128(1), + Ledger: 1, + Code: 1, + Flags: TransferFlags{PostPendingTransfer: true}.ToUint16(), + }, + }) + if err != nil { + log.Fatalf("Error creating transfers: %s", err) + } + + assert(len(transferResults), 1, "transferResults") + for i, result := range transferResults { + switch result.Status { + case TransferCreated: + default: + log.Fatalf("Error creating transfer %d: %s", i, result.Status) + } + } + + // Validate the contents of all transfers + transfers, err := client.LookupTransfers([]Uint128{ToUint128(1), ToUint128(2)}) + if err != nil { + log.Fatalf("Error looking up transfers: %s", err) + } + assert(len(transfers), 2, "transfers") + + for _, transfer := range transfers { + if transfer.ID == ToUint128(1) { + assert(transfer.TransferFlags().Pending, true, "transfer 1 pending") + assert(transfer.TransferFlags().PostPendingTransfer, false, "transfer 1 post_pending_transfer") + } else if transfer.ID == ToUint128(2) { + assert(transfer.TransferFlags().Pending, false, "transfer 2 pending") + assert(transfer.TransferFlags().PostPendingTransfer, true, "transfer 2 post_pending_transfer") + } else { + log.Fatalf("Unknown transfer: %s", transfer.ID) + } + } + + // Validate accounts pending and posted debits/credits after finishing the two-phase transfer + accounts, err = client.LookupAccounts([]Uint128{ToUint128(1), ToUint128(2)}) + if err != nil { + log.Fatalf("Could not fetch accounts: %s", err) + } + assert(len(accounts), 2, "accounts") + + for _, account := range accounts { + if account.ID == ToUint128(1) { + assert(account.DebitsPosted, ToUint128(500), "account 1 debits") + assert(account.CreditsPosted, ToUint128(0), "account 1 credits") + assert(account.DebitsPending, ToUint128(0), "account 1 debits pending") + assert(account.CreditsPending, ToUint128(0), "account 1 credits pending") + } else if account.ID == ToUint128(2) { + assert(account.DebitsPosted, ToUint128(0), "account 2 debits") + assert(account.CreditsPosted, ToUint128(500), "account 2 credits") + assert(account.DebitsPending, ToUint128(0), "account 2 debits pending") + assert(account.CreditsPending, ToUint128(0), "account 2 credits pending") + } else { + log.Fatalf("Unexpected account: %s", account.ID) + } + } + + fmt.Println("ok") +} diff --git a/ocam/src/clients/go/samples/walkthrough/README.md b/ocam/src/clients/go/samples/walkthrough/README.md new file mode 100644 index 00000000..b657597b --- /dev/null +++ b/ocam/src/clients/go/samples/walkthrough/README.md @@ -0,0 +1 @@ +Code from the [top-level README.md](../../README.md) collected into a single runnable project. diff --git a/ocam/src/clients/go/samples/walkthrough/go.mod b/ocam/src/clients/go/samples/walkthrough/go.mod new file mode 100644 index 00000000..85d58cd3 --- /dev/null +++ b/ocam/src/clients/go/samples/walkthrough/go.mod @@ -0,0 +1,7 @@ +module walkthrough + +go 1.17 + +require github.com/tigerbeetle/tigerbeetle-go v0.0.0 + +replace github.com/tigerbeetle/tigerbeetle-go => ../../ diff --git a/ocam/src/clients/go/samples/walkthrough/main.go b/ocam/src/clients/go/samples/walkthrough/main.go new file mode 100644 index 00000000..12d99418 --- /dev/null +++ b/ocam/src/clients/go/samples/walkthrough/main.go @@ -0,0 +1,472 @@ +// section:imports +package main + +import ( + "fmt" + "log" + "os" + + . "github.com/tigerbeetle/tigerbeetle-go" +) + +func main() { + fmt.Println("Import ok!") + // endsection:imports + + // section:client + tbAddress := os.Getenv("TB_ADDRESS") + if len(tbAddress) == 0 { + tbAddress = "3000" + } + client, err := NewClient(ToUint128(0), []string{tbAddress}) + if err != nil { + log.Printf("Error creating client: %s", err) + return + } + defer client.Close() + // endsection:client + + { + // section:create-accounts + accountResults, err := client.CreateAccounts([]Account{ + { + ID: ID(), // TigerBeetle time-based ID. + UserData128: ToUint128(0), + UserData64: 0, + UserData32: 0, + Ledger: 1, + Code: 718, + Flags: 0, + Timestamp: 0, + }, + }) + // Results handling omitted. + // endsection:create-accounts + _, _ = accountResults, err + } + + { + // section:account-flags + account0 := Account{ + ID: ToUint128(100), + Ledger: 1, + Code: 718, + Flags: AccountFlags{ + DebitsMustNotExceedCredits: true, + Linked: true, + }.ToUint16(), + } + account1 := Account{ + ID: ToUint128(101), + Ledger: 1, + Code: 718, + Flags: AccountFlags{ + History: true, + }.ToUint16(), + } + + accountResults, err := client.CreateAccounts([]Account{account0, account1}) + // Results handling omitted. + // endsection:account-flags + _, _ = accountResults, err + } + + { + // section:create-accounts-errors + account0 := Account{ + ID: ToUint128(102), + Ledger: 1, + Code: 718, + Flags: 0, + } + account1 := Account{ + ID: ToUint128(103), + Ledger: 1, + Code: 718, + Flags: 0, + } + account2 := Account{ + ID: ToUint128(104), + Ledger: 1, + Code: 718, + Flags: 0, + } + + accountResults, err := client.CreateAccounts([]Account{account0, account1, account2}) + if err != nil { + log.Printf("Error creating accounts: %s", err) + return + } + + for i, result := range accountResults { + switch result.Status { + case AccountCreated: + log.Printf("Batch account at %d successfully created with timestamp %d.", i, result.Timestamp) + case AccountExists: + log.Printf("Batch account at %d already exists with timestamp %d.", i, result.Timestamp) + default: + log.Printf("Batch account at %d failed to create: %s", i, result.Status) + } + } + // endsection:create-accounts-errors + } + + { + // section:lookup-accounts + accounts, err := client.LookupAccounts([]Uint128{ToUint128(100), ToUint128(101)}) + // endsection:lookup-accounts + _, _ = accounts, err + } + + { + // section:create-transfers + transfers := []Transfer{{ + ID: ID(), // TigerBeetle time-based ID. + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: 0, + Timestamp: 0, + }} + + transferResults, err := client.CreateTransfers(transfers) + // Results handling omitted. + // endsection:create-transfers + _, _ = transferResults, err + } + + { + // section:create-transfers-errors + transfers := []Transfer{{ + ID: ToUint128(1), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: 0, + }, { + ID: ToUint128(2), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: 0, + }, { + ID: ToUint128(3), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: 0, + }} + + transferResults, err := client.CreateTransfers(transfers) + if err != nil { + log.Printf("Error creating transfers: %s", err) + return + } + + for i, result := range transferResults { + switch result.Status { + case TransferCreated: + log.Printf("Batch transfer at %d successfully created with timestamp %d.", i, result.Timestamp) + case TransferExists: + log.Printf("Batch transfer at %d already exists with timestamp %d.", i, result.Timestamp) + default: + log.Printf("Batch transfer at %d failed to create: %s", i, result.Status) + } + } + // endsection:create-transfers-errors + } + + { + // section:batch + batch := []Transfer{} + BATCH_SIZE := 8189 + for i := 0; i < len(batch); i += BATCH_SIZE { + size := BATCH_SIZE + if i+BATCH_SIZE > len(batch) { + size = len(batch) - i + } + transferResults, err := client.CreateTransfers(batch[i : i+size]) + // Results handling omitted. + _, _ = transferResults, err + } + // endsection:batch + } + + { + // section:transfer-flags-link + transfer0 := Transfer{ + ID: ToUint128(4), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: TransferFlags{Linked: true}.ToUint16(), + } + transfer1 := Transfer{ + ID: ToUint128(5), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: 0, + } + + transferResults, err := client.CreateTransfers([]Transfer{transfer0, transfer1}) + // Results handling omitted. + // endsection:transfer-flags-link + _, _ = transferResults, err + } + + { + // section:transfer-flags-post + transfer0 := Transfer{ + ID: ToUint128(6), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Ledger: 1, + Code: 1, + Flags: TransferFlags{Pending: true}.ToUint16(), + } + + transferResults, err := client.CreateTransfers([]Transfer{transfer0}) + // Results handling omitted. + + transfer1 := Transfer{ + ID: ToUint128(7), + // Post the entire pending amount. + Amount: AmountMax, + PendingID: ToUint128(6), + Flags: TransferFlags{PostPendingTransfer: true}.ToUint16(), + } + + transferResults, err = client.CreateTransfers([]Transfer{transfer1}) + // Results handling omitted. + // endsection:transfer-flags-post + _, _ = transferResults, err + } + + { + // section:transfer-flags-void + transfer0 := Transfer{ + ID: ToUint128(8), + DebitAccountID: ToUint128(101), + CreditAccountID: ToUint128(102), + Amount: ToUint128(10), + Timeout: 0, + Ledger: 1, + Code: 1, + Flags: TransferFlags{Pending: true}.ToUint16(), + } + + transferResults, err := client.CreateTransfers([]Transfer{transfer0}) + // Results handling omitted. + + transfer1 := Transfer{ + ID: ToUint128(9), + Amount: ToUint128(0), + PendingID: ToUint128(8), + Flags: TransferFlags{VoidPendingTransfer: true}.ToUint16(), + } + + transferResults, err = client.CreateTransfers([]Transfer{transfer1}) + // Results handling omitted. + // endsection:transfer-flags-void + _, _ = transferResults, err + } + + { + // section:lookup-transfers + transfers, err := client.LookupTransfers([]Uint128{ToUint128(1), ToUint128(2)}) + // endsection:lookup-transfers + _, _ = transfers, err + } + + { + // section:get-account-transfers + filter := AccountFilter{ + AccountID: ToUint128(2), + UserData128: ToUint128(0), // No filter by UserData. + UserData64: 0, + UserData32: 0, + Code: 0, // No filter by Code. + TimestampMin: 0, // No filter by Timestamp. + TimestampMax: 0, // No filter by Timestamp. + Limit: 10, // Limit to ten transfers at most. + Flags: AccountFilterFlags{ + Debits: true, // Include transfer from the debit side. + Credits: true, // Include transfer from the credit side. + Reversed: true, // Sort by timestamp in reverse-chronological order. + }.ToUint32(), + } + + transfers, err := client.GetAccountTransfers(filter) + // endsection:get-account-transfers + _, _ = transfers, err + } + + { + // section:get-account-balances + filter := AccountFilter{ + AccountID: ToUint128(2), + UserData128: ToUint128(0), // No filter by UserData. + UserData64: 0, + UserData32: 0, + Code: 0, // No filter by Code. + TimestampMin: 0, // No filter by Timestamp. + TimestampMax: 0, // No filter by Timestamp. + Limit: 10, // Limit to ten balances at most. + Flags: AccountFilterFlags{ + Debits: true, // Include transfer from the debit side. + Credits: true, // Include transfer from the credit side. + Reversed: true, // Sort by timestamp in reverse-chronological order. + }.ToUint32(), + } + + account_balances, err := client.GetAccountBalances(filter) + // endsection:get-account-balances + _, _ = account_balances, err + } + + { + // section:query-accounts + filter := QueryFilter{ + UserData128: ToUint128(1000), // Filter by UserData + UserData64: 100, + UserData32: 10, + Code: 1, // Filter by Code + Ledger: 0, // No filter by Ledger + TimestampMin: 0, // No filter by Timestamp. + TimestampMax: 0, // No filter by Timestamp. + Limit: 10, // Limit to ten accounts at most. + Flags: QueryFilterFlags{ + Reversed: true, // Sort by timestamp in reverse-chronological order. + }.ToUint32(), + } + + accounts, err := client.QueryAccounts(filter) + // endsection:query-accounts + _, _ = accounts, err + } + + { + // section:query-transfers + filter := QueryFilter{ + UserData128: ToUint128(1000), // Filter by UserData. + UserData64: 100, + UserData32: 10, + Code: 1, // Filter by Code. + Ledger: 0, // No filter by Ledger. + TimestampMin: 0, // No filter by Timestamp. + TimestampMax: 0, // No filter by Timestamp. + Limit: 10, // Limit to ten transfers at most. + Flags: QueryFilterFlags{ + Reversed: true, // Sort by timestamp in reverse-chronological order. + }.ToUint32(), + } + + transfers, err := client.QueryTransfers(filter) + // endsection:query-transfers + _, _ = transfers, err + } + + { + // section:linked-events + batch := []Transfer{} + linkedFlag := TransferFlags{Linked: true}.ToUint16() + + // An individual transfer (successful): + batch = append(batch, Transfer{ID: ToUint128(1) /* ... rest of transfer ... */}) + + // A chain of 4 transfers (the last transfer in the chain closes the chain with linked=false): + batch = append(batch, Transfer{ID: ToUint128(2) /* ... , */, Flags: linkedFlag}) // Commit/rollback. + batch = append(batch, Transfer{ID: ToUint128(3) /* ... , */, Flags: linkedFlag}) // Commit/rollback. + batch = append(batch, Transfer{ID: ToUint128(2) /* ... , */, Flags: linkedFlag}) // Fail with exists + batch = append(batch, Transfer{ID: ToUint128(4) /* ... , */}) // Fail without committing + + // An individual transfer (successful): + // This should not see any effect from the failed chain above. + batch = append(batch, Transfer{ID: ToUint128(2) /* ... rest of transfer ... */}) + + // A chain of 2 transfers (the first transfer fails the chain): + batch = append(batch, Transfer{ID: ToUint128(2) /* ... rest of transfer ... */, Flags: linkedFlag}) + batch = append(batch, Transfer{ID: ToUint128(3) /* ... rest of transfer ... */}) + + // A chain of 2 transfers (successful): + batch = append(batch, Transfer{ID: ToUint128(3) /* ... rest of transfer ... */, Flags: linkedFlag}) + batch = append(batch, Transfer{ID: ToUint128(4) /* ... rest of transfer ... */}) + + transferResults, err := client.CreateTransfers(batch) + // Results handling omitted. + // endsection:linked-events + _, _ = transferResults, err + } + + { + // section:imported-events + // External source of time. + var historicalTimestamp uint64 = 0 + historicalAccounts := []Account{ /* Loaded from an external source. */ } + historicalTransfers := []Transfer{ /* Loaded from an external source. */ } + + // First, load and import all accounts with their timestamps from the historical source. + accountsBatch := []Account{} + for index, account := range historicalAccounts { + // Set a unique and strictly increasing timestamp. + historicalTimestamp += 1 + account.Timestamp = historicalTimestamp + + account.Flags = AccountFlags{ + // Set the account as `imported`. + Imported: true, + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + Linked: index < len(historicalAccounts)-1, + }.ToUint16() + + accountsBatch = append(accountsBatch, account) + } + + accountResults, err := client.CreateAccounts(accountsBatch) + // Results handling omitted. + + // Then, load and import all transfers with their timestamps from the historical source. + transfersBatch := []Transfer{} + for index, transfer := range historicalTransfers { + // Set a unique and strictly increasing timestamp. + historicalTimestamp += 1 + transfer.Timestamp = historicalTimestamp + + transfer.Flags = TransferFlags{ + // Set the transfer as `imported`. + Imported: true, + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + Linked: index < len(historicalAccounts)-1, + }.ToUint16() + + transfersBatch = append(transfersBatch, transfer) + } + + transferResults, err := client.CreateTransfers(transfersBatch) + // Results handling omitted.. + // Since it is a linked chain, in case of any error the entire batch is rolled back and can be retried + // with the same historical timestamps without regressing the cluster timestamp. + // endsection:imported-events + _, _, _ = accountResults, transferResults, err + } + + // section:imports +} + +// endsection:imports diff --git a/ocam/src/clients/go/tb_client.go b/ocam/src/clients/go/tb_client.go new file mode 100644 index 00000000..c3180432 --- /dev/null +++ b/ocam/src/clients/go/tb_client.go @@ -0,0 +1,507 @@ +package tigerbeetle_go + +/* +#cgo CFLAGS: -g -Wall +#cgo darwin,arm64 LDFLAGS: ${SRCDIR}/native/libtb_client_aarch64-macos.a -ldl -lm +#cgo darwin,amd64 LDFLAGS: ${SRCDIR}/native/libtb_client_x86_64-macos.a -ldl -lm +#cgo linux,arm64 LDFLAGS: ${SRCDIR}/native/libtb_client_aarch64-linux.a -ldl -lm +#cgo linux,amd64 LDFLAGS: ${SRCDIR}/native/libtb_client_x86_64-linux.a -ldl -lm +#cgo windows,amd64 LDFLAGS: -L${SRCDIR}/native -ltb_client_x86_64-windows -lws2_32 -lntdll + +#include +#include +#include "./native/tb_client.h" + +#ifndef __declspec + #define __declspec(x) +#endif + +typedef const uint8_t* tb_result_bytes_t; + +extern __declspec(dllexport) void onGoPacketCompletion( + uintptr_t ctx, + tb_packet_t* packet, + uint64_t timestamp, + tb_result_bytes_t result, + uint32_t result_size +); +*/ +import "C" +import ( + e "errors" + "runtime" + "strings" + "unsafe" + + _ "github.com/tigerbeetle/tigerbeetle-go/native" +) + +/////////////////////////////////////////////////////////////// + +var AmountMax = BytesToUint128([16]byte{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, +}) + +type Client interface { + CreateAccounts(accounts []Account) ([]CreateAccountResult, error) + CreateTransfers(transfers []Transfer) ([]CreateTransferResult, error) + LookupAccounts(accountIDs []Uint128) ([]Account, error) + LookupTransfers(transferIDs []Uint128) ([]Transfer, error) + GetAccountTransfers(filter AccountFilter) ([]Transfer, error) + GetAccountBalances(filter AccountFilter) ([]AccountBalance, error) + QueryAccounts(filter QueryFilter) ([]Account, error) + QueryTransfers(filter QueryFilter) ([]Transfer, error) + + // Experimental: GetChangeEvents API is undocumented. + GetChangeEvents(filter ChangeEventsFilter) ([]ChangeEvent, error) + + Nop() error + Close() +} + +type request struct { + ready chan []uint8 +} + +type c_client struct { + tb_client *C.tb_client_t +} + +func NewClient( + clusterID Uint128, + addresses []string, +) (Client, error) { + // Allocate a cstring of the addresses joined with ",". + addresses_raw := strings.Join(addresses[:], ",") + c_addresses := C.CString(addresses_raw) + defer C.free(unsafe.Pointer(c_addresses)) + + tb_client := new(C.tb_client_t) + var cluster_id = C.tb_uint128_t(clusterID) + + // Create the tb_client. + init_status := C.tb_client_init( + tb_client, + (*C.uint8_t)(unsafe.Pointer(&cluster_id)), + c_addresses, + C.uint32_t(len(addresses_raw)), + C.uintptr_t(0), // on_completion_ctx + (*[0]byte)(C.onGoPacketCompletion), + ) + + if init_status != C.TB_INIT_SUCCESS { + switch init_status { + case C.TB_INIT_UNEXPECTED: + return nil, ErrUnexpected + case C.TB_INIT_OUT_OF_MEMORY: + return nil, ErrOutOfMemory + case C.TB_INIT_ADDRESS_INVALID: + return nil, ErrInvalidAddress + case C.TB_INIT_ADDRESS_LIMIT_EXCEEDED: + return nil, ErrAddressLimitExceeded + case C.TB_INIT_SYSTEM_RESOURCES: + return nil, ErrSystemResources + case C.TB_INIT_NETWORK_SUBSYSTEM: + return nil, ErrNetworkSubsystem + default: + panic("tb_client_init(): invalid error code") + } + } + + c := &c_client{ + tb_client: tb_client, + } + + return c, nil +} + +func (c *c_client) Close() { + _ = C.tb_client_deinit(c.tb_client) +} + +func getEventSize(op C.TB_OPERATION) uintptr { + switch op { + case C.TB_OPERATION_CREATE_ACCOUNTS: + return unsafe.Sizeof(Account{}) + case C.TB_OPERATION_CREATE_TRANSFERS: + return unsafe.Sizeof(Transfer{}) + case C.TB_OPERATION_LOOKUP_ACCOUNTS: + fallthrough + case C.TB_OPERATION_LOOKUP_TRANSFERS: + return unsafe.Sizeof(Uint128{}) + case C.TB_OPERATION_GET_ACCOUNT_TRANSFERS: + return unsafe.Sizeof(AccountFilter{}) + case C.TB_OPERATION_GET_ACCOUNT_BALANCES: + return unsafe.Sizeof(AccountFilter{}) + case C.TB_OPERATION_QUERY_ACCOUNTS: + return unsafe.Sizeof(QueryFilter{}) + case C.TB_OPERATION_QUERY_TRANSFERS: + return unsafe.Sizeof(QueryFilter{}) + case C.TB_OPERATION_GET_CHANGE_EVENTS: + return unsafe.Sizeof(ChangeEventsFilter{}) + default: + return 0 + } +} + +func getResultSize(op C.TB_OPERATION) uintptr { + switch op { + case C.TB_OPERATION_CREATE_ACCOUNTS: + return unsafe.Sizeof(CreateAccountResult{}) + case C.TB_OPERATION_CREATE_TRANSFERS: + return unsafe.Sizeof(CreateTransferResult{}) + case C.TB_OPERATION_LOOKUP_ACCOUNTS: + return unsafe.Sizeof(Account{}) + case C.TB_OPERATION_LOOKUP_TRANSFERS: + return unsafe.Sizeof(Transfer{}) + case C.TB_OPERATION_GET_ACCOUNT_TRANSFERS: + return unsafe.Sizeof(Transfer{}) + case C.TB_OPERATION_GET_ACCOUNT_BALANCES: + return unsafe.Sizeof(AccountBalance{}) + case C.TB_OPERATION_QUERY_ACCOUNTS: + return unsafe.Sizeof(Account{}) + case C.TB_OPERATION_QUERY_TRANSFERS: + return unsafe.Sizeof(Transfer{}) + case C.TB_OPERATION_GET_CHANGE_EVENTS: + return unsafe.Sizeof(ChangeEvent{}) + default: + return 0 + } +} + +func (c *c_client) doRequest( + op C.TB_OPERATION, + count int, + data unsafe.Pointer, +) ([]uint8, error) { + var req request + req.ready = make(chan []uint8, 1) // buffered chan prevents completion handler blocking for Go. + + // NOTE: packet must be its own allocation and cannot live in request as then CGO is unable to + // correctly track it (panic: runtime error: cgo argument has Go pointer to unpinned Go pointer) + packet := new(C.tb_packet_t) + packet.user_data = unsafe.Pointer(&req) + packet.user_tag = 0 + packet.operation = C.uint8_t(op) + packet.data_size = C.uint32_t(count * int(getEventSize(op))) + packet.data = data + + // NOTE: Pin all go-allocated refs that will be accessed by onGoPacketCompletion after submit(). + var pinner runtime.Pinner + defer pinner.Unpin() + pinner.Pin(&req) + pinner.Pin(packet) + if data != nil { + pinner.Pin(data) + } + + client_status := C.tb_client_submit(c.tb_client, packet) + if client_status == C.TB_CLIENT_INVALID { + return nil, ErrClientClosed + } + + // Wait for the request to complete. + reply := <-req.ready + packet_status := C.TB_PACKET_STATUS(packet.status) + + // Handle packet error + if packet_status != C.TB_PACKET_OK { + switch packet_status { + case C.TB_PACKET_TOO_MUCH_DATA: + return nil, ErrTooMuchData + case C.TB_PACKET_CLIENT_EVICTED: + return nil, ErrClientEvicted + case C.TB_PACKET_CLIENT_RELEASE_TOO_LOW: + return nil, ErrClientReleaseTooLow + case C.TB_PACKET_CLIENT_RELEASE_TOO_HIGH: + return nil, ErrClientReleaseTooHigh + case C.TB_PACKET_CLIENT_SHUTDOWN: + return nil, ErrClientClosed + case C.TB_PACKET_INVALID_OPERATION: + // We control what C.TB_OPERATION is given + // but allow an invalid opcode to be passed to emulate a client nop. + return nil, ErrInvalidOperation + case C.TB_PACKET_INVALID_DATA_SIZE: + // We control what type of data is given. + panic("unreachable") + default: + panic("tb_client_submit(): returned packet with invalid status") + } + } + + return reply, nil +} + +//export onGoPacketCompletion +func onGoPacketCompletion( + _context C.uintptr_t, + packet *C.tb_packet_t, + timestamp C.uint64_t, + result C.tb_result_bytes_t, + result_size C.uint32_t, +) { + _ = _context + _ = timestamp + + // Get the request from the packet user data. + req := (*request)(unsafe.Pointer(packet.user_data)) + var reply []uint8 = nil + if result_size > 0 && result != nil { + op := C.TB_OPERATION(packet.operation) + + // Make sure the completion handler is giving us valid data. + resultSize := C.uint32_t(getResultSize(op)) + if result_size%resultSize != 0 { + panic("invalid result_size: misaligned for the event") + } + + //TODO(batiati): Refine the way we handle events with asymmetric results. + if op != C.TB_OPERATION_GET_ACCOUNT_TRANSFERS && + op != C.TB_OPERATION_GET_ACCOUNT_BALANCES && + op != C.TB_OPERATION_QUERY_ACCOUNTS && + op != C.TB_OPERATION_QUERY_TRANSFERS && + op != C.TB_OPERATION_GET_CHANGE_EVENTS { + // Make sure the amount of results at least matches the amount of requests. + count := packet.data_size / C.uint32_t(getEventSize(op)) + if count*resultSize < result_size { + panic("invalid result_size: implied multiple results per event") + } + } + + // Copy the result data into a new buffer. + reply = make([]uint8, result_size) + C.memcpy(unsafe.Pointer(&reply[0]), unsafe.Pointer(result), C.size_t(result_size)) + } + + // Signal to the goroutine which owns this request that it's ready. + req.ready <- reply +} + +func (c *c_client) CreateAccounts(accounts []Account) ([]CreateAccountResult, error) { + count := len(accounts) + var dataPtr unsafe.Pointer + if count > 0 { + dataPtr = unsafe.Pointer(&accounts[0]) + } else { + dataPtr = nil + } + + reply, err := c.doRequest( + C.TB_OPERATION_CREATE_ACCOUNTS, + count, + dataPtr, + ) + + if err != nil { + return nil, err + } + + if reply == nil { + return make([]CreateAccountResult, 0), nil + } + + resultsCount := len(reply) / int(unsafe.Sizeof(CreateAccountResult{})) + results := unsafe.Slice((*CreateAccountResult)(unsafe.Pointer(&reply[0])), resultsCount) + return results, nil +} + +func (c *c_client) CreateTransfers(transfers []Transfer) ([]CreateTransferResult, error) { + count := len(transfers) + var dataPtr unsafe.Pointer + if count > 0 { + dataPtr = unsafe.Pointer(&transfers[0]) + } else { + dataPtr = nil + } + + reply, err := c.doRequest( + C.TB_OPERATION_CREATE_TRANSFERS, + count, + dataPtr, + ) + + if err != nil { + return nil, err + } + + if reply == nil { + return make([]CreateTransferResult, 0), nil + } + + resultsCount := len(reply) / int(unsafe.Sizeof(CreateTransferResult{})) + results := unsafe.Slice((*CreateTransferResult)(unsafe.Pointer(&reply[0])), resultsCount) + return results, nil +} + +func (c *c_client) LookupAccounts(accountIDs []Uint128) ([]Account, error) { + count := len(accountIDs) + var dataPtr unsafe.Pointer + if count > 0 { + dataPtr = unsafe.Pointer(&accountIDs[0]) + } else { + dataPtr = nil + } + + reply, err := c.doRequest( + C.TB_OPERATION_LOOKUP_ACCOUNTS, + count, + dataPtr, + ) + + if err != nil { + return nil, err + } + + if reply == nil { + return make([]Account, 0), nil + } + + resultsCount := len(reply) / int(unsafe.Sizeof(Account{})) + results := unsafe.Slice((*Account)(unsafe.Pointer(&reply[0])), resultsCount) + return results, nil +} + +func (c *c_client) LookupTransfers(transferIDs []Uint128) ([]Transfer, error) { + count := len(transferIDs) + var dataPtr unsafe.Pointer + if count > 0 { + dataPtr = unsafe.Pointer(&transferIDs[0]) + } else { + dataPtr = nil + } + + reply, err := c.doRequest( + C.TB_OPERATION_LOOKUP_TRANSFERS, + count, + dataPtr, + ) + + if err != nil { + return nil, err + } + + if reply == nil { + return make([]Transfer, 0), nil + } + + resultsCount := len(reply) / int(unsafe.Sizeof(Transfer{})) + results := unsafe.Slice((*Transfer)(unsafe.Pointer(&reply[0])), resultsCount) + return results, nil +} + +func (c *c_client) GetAccountTransfers(filter AccountFilter) ([]Transfer, error) { + reply, err := c.doRequest( + C.TB_OPERATION_GET_ACCOUNT_TRANSFERS, + 1, + unsafe.Pointer(&filter), + ) + + if err != nil { + return nil, err + } + + if reply == nil { + return make([]Transfer, 0), nil + } + + resultsCount := len(reply) / int(unsafe.Sizeof(Transfer{})) + results := unsafe.Slice((*Transfer)(unsafe.Pointer(&reply[0])), resultsCount) + return results, nil +} + +func (c *c_client) GetAccountBalances(filter AccountFilter) ([]AccountBalance, error) { + reply, err := c.doRequest( + C.TB_OPERATION_GET_ACCOUNT_BALANCES, + 1, + unsafe.Pointer(&filter), + ) + + if err != nil { + return nil, err + } + + if reply == nil { + return make([]AccountBalance, 0), nil + } + + resultsCount := len(reply) / int(unsafe.Sizeof(AccountBalance{})) + results := unsafe.Slice((*AccountBalance)(unsafe.Pointer(&reply[0])), resultsCount) + return results, nil +} + +func (c *c_client) QueryAccounts(filter QueryFilter) ([]Account, error) { + reply, err := c.doRequest( + C.TB_OPERATION_QUERY_ACCOUNTS, + 1, + unsafe.Pointer(&filter), + ) + + if err != nil { + return nil, err + } + + if reply == nil { + return make([]Account, 0), nil + } + + resultsCount := len(reply) / int(unsafe.Sizeof(Account{})) + results := unsafe.Slice((*Account)(unsafe.Pointer(&reply[0])), resultsCount) + return results, nil +} + +func (c *c_client) QueryTransfers(filter QueryFilter) ([]Transfer, error) { + reply, err := c.doRequest( + C.TB_OPERATION_QUERY_TRANSFERS, + 1, + unsafe.Pointer(&filter), + ) + + if err != nil { + return nil, err + } + + if reply == nil { + return make([]Transfer, 0), nil + } + + resultsCount := len(reply) / int(unsafe.Sizeof(Transfer{})) + results := unsafe.Slice((*Transfer)(unsafe.Pointer(&reply[0])), resultsCount) + return results, nil +} + +func (c *c_client) GetChangeEvents(filter ChangeEventsFilter) ([]ChangeEvent, error) { + reply, err := c.doRequest( + C.TB_OPERATION_GET_CHANGE_EVENTS, + 1, + unsafe.Pointer(&filter), + ) + + if err != nil { + return nil, err + } + + if reply == nil { + return make([]ChangeEvent, 0), nil + } + + resultsCount := len(reply) / int(unsafe.Sizeof(ChangeEvent{})) + results := unsafe.Slice((*ChangeEvent)(unsafe.Pointer(&reply[0])), resultsCount) + return results, nil +} + +func (c *c_client) Nop() error { + const dataSize = 256 + var dummyData [dataSize]C.uint8_t + ptr := unsafe.Pointer(&dummyData) + + reservedOp := C.TB_OPERATION(0) + reply, err := c.doRequest(reservedOp, 1, ptr) + + if !e.Is(err, ErrInvalidOperation) { + return err + } + + _ = reply + return nil +} diff --git a/ocam/src/clients/go/tb_client_test.go b/ocam/src/clients/go/tb_client_test.go new file mode 100644 index 00000000..ec072cdc --- /dev/null +++ b/ocam/src/clients/go/tb_client_test.go @@ -0,0 +1,1697 @@ +package tigerbeetle_go + +import ( + "bytes" + "errors" + "fmt" + "math/big" + "math/rand" + "os" + "os/exec" + "runtime" + "sync" + "testing" + "time" + "unsafe" + + "github.com/tigerbeetle/tigerbeetle-go/assert" +) + +const ( + TIGERBEETLE_PORT = "3000" + TIGERBEETLE_CLUSTER_ID uint64 = 0 + TIGERBEETLE_REPLICA_ID uint32 = 0 + TIGERBEETLE_REPLICA_COUNT uint32 = 1 +) + +func WithClient(t testing.TB, withClient func(Client)) { + var tigerbeetlePath string + if runtime.GOOS == "windows" { + tigerbeetlePath = "../../../tigerbeetle.exe" + } else { + tigerbeetlePath = "../../../tigerbeetle" + } + + addressArg := "--addresses=" + TIGERBEETLE_PORT + cacheSizeArg := "--cache-grid=256MiB" + replicaArg := fmt.Sprintf("--replica=%d", TIGERBEETLE_REPLICA_ID) + replicaCountArg := fmt.Sprintf("--replica-count=%d", TIGERBEETLE_REPLICA_COUNT) + clusterArg := fmt.Sprintf("--cluster=%d", TIGERBEETLE_CLUSTER_ID) + + fileName := fmt.Sprintf("./%d_%d_%d.tigerbeetle", TIGERBEETLE_CLUSTER_ID, TIGERBEETLE_REPLICA_ID, rand.Int()) + t.Cleanup(func() { + _ = os.Remove(fileName) + }) + + tbInit := exec.Command(tigerbeetlePath, "format", clusterArg, replicaArg, replicaCountArg, fileName) + var tbErr bytes.Buffer + tbInit.Stdout = &tbErr + tbInit.Stderr = &tbErr + if err := tbInit.Run(); err != nil { + fmt.Println(fmt.Sprint(err) + ": " + tbErr.String()) + t.Fatal(err) + } + + tbStart := exec.Command(tigerbeetlePath, "start", addressArg, cacheSizeArg, fileName) + if testing.Verbose() { + tbStart.Stderr = os.Stderr + } + if err := tbStart.Start(); err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + if err := tbStart.Process.Kill(); err != nil { + t.Fatal(err) + } + }) + + addresses := []string{"127.0.0.1:" + TIGERBEETLE_PORT} + client, err := NewClient(ToUint128(TIGERBEETLE_CLUSTER_ID), addresses) + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + client.Close() + }) + + withClient(client) +} + +func TestClient(t *testing.T) { + WithClient(t, func(client Client) { + doTestClient(t, client) + }) +} + +func TestImportedFlag(t *testing.T) { + // This test cannot run in parallel with the others because it needs an + // stable "timestamp max" reference. + WithClient(t, func(client Client) { + doTestImportedFlag(t, client) + }) +} + +func doTestClient(t *testing.T, client Client) { + createTwoAccounts := func(t *testing.T) (Account, Account) { + accountA := Account{ + ID: ID(), + Ledger: 1, + Code: 1, + } + accountB := Account{ + ID: ID(), + Ledger: 1, + Code: 2, + } + + results, err := client.CreateAccounts([]Account{ + accountA, + accountB, + }) + if err != nil { + t.Fatal(err) + } + assertCreateAccountsOK(t, results, 2) + + return accountA, accountB + } + + /// Consistency of U128 across Zig and the language clients. + /// It must be kept in sync with all platforms. + t.Run("u128 consistency", func(t *testing.T) { + t.Parallel() + + // Binary little endian representation: + // Using signed bytes for convenience to match Java's representation: + binary := [16]byte{ + 0xe6, 0xe5, 0xe4, 0xe3, 0xe2, 0xe1, + 0xd2, 0xd1, + 0xc2, 0xc1, + 0xb2, 0xb1, + 0xa4, 0xa3, 0xa2, 0xa1, + } + decimal, ok := new(big.Int).SetString("214850178493633095719753766415838275046", 10) + if !ok { + t.Fatal() + } + + u128 := BytesToUint128(binary) + + assert.Equal(t, u128.Bytes(), binary) + assert.Equal(t, u128.BigInt(), decimal) + assert.Equal(t, BigIntToUint128(decimal).Bytes(), u128.Bytes()) + + lo, hi := u128.Uint64() + assert.True(t, lo == 15119395263638463974) + assert.True(t, hi == 11647051514084770242) + }) + + t.Run("can create accounts", func(t *testing.T) { + t.Parallel() + createTwoAccounts(t) + }) + + t.Run("can lookup accounts", func(t *testing.T) { + t.Parallel() + accountA, accountB := createTwoAccounts(t) + + results, err := client.LookupAccounts([]Uint128{ + accountA.ID, + accountB.ID, + }) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, results, 2) + accA := results[0] + assert.Equal(t, uint32(1), accA.Ledger) + assert.Equal(t, uint16(1), accA.Code) + assert.Equal(t, uint16(0), accA.Flags) + assert.Equal(t, ToUint128(0), accA.DebitsPending) + assert.Equal(t, ToUint128(0), accA.DebitsPosted) + assert.Equal(t, ToUint128(0), accA.CreditsPending) + assert.Equal(t, ToUint128(0), accA.CreditsPosted) + assert.NotEqual(t, uint64(0), accA.Timestamp) + assert.Equal(t, unsafe.Sizeof(accA), 128) + + accB := results[1] + assert.Equal(t, uint32(1), accB.Ledger) + assert.Equal(t, uint16(2), accB.Code) + assert.Equal(t, uint16(0), accB.Flags) + assert.Equal(t, ToUint128(0), accB.DebitsPending) + assert.Equal(t, ToUint128(0), accB.DebitsPosted) + assert.Equal(t, ToUint128(0), accB.CreditsPending) + assert.Equal(t, ToUint128(0), accB.CreditsPosted) + assert.NotEqual(t, uint64(0), accB.Timestamp) + }) + + t.Run("can create a transfer", func(t *testing.T) { + t.Parallel() + accountA, accountB := createTwoAccounts(t) + + results, err := client.CreateTransfers([]Transfer{ + { + ID: ID(), + CreditAccountID: accountA.ID, + DebitAccountID: accountB.ID, + Amount: ToUint128(100), + Ledger: 1, + Code: 1, + }, + }) + if err != nil { + t.Fatal(err) + } + assertCreateTransfersOK(t, results, 1) + + accounts, err := client.LookupAccounts([]Uint128{accountA.ID, accountB.ID}) + if err != nil { + t.Fatal(err) + } + assert.Len(t, accounts, 2) + + accountA = accounts[0] + assert.Equal(t, ToUint128(0), accountA.DebitsPending) + assert.Equal(t, ToUint128(0), accountA.DebitsPosted) + assert.Equal(t, ToUint128(0), accountA.CreditsPending) + assert.Equal(t, ToUint128(100), accountA.CreditsPosted) + + accountB = accounts[1] + assert.Equal(t, ToUint128(0), accountB.DebitsPending) + assert.Equal(t, ToUint128(100), accountB.DebitsPosted) + assert.Equal(t, ToUint128(0), accountB.CreditsPending) + assert.Equal(t, ToUint128(0), accountB.CreditsPosted) + }) + + t.Run("can create linked transfers", func(t *testing.T) { + t.Parallel() + accountA, accountB := createTwoAccounts(t) + + id := ID() + transfer1 := Transfer{ + ID: id, + CreditAccountID: accountA.ID, + DebitAccountID: accountB.ID, + Amount: ToUint128(50), + Flags: TransferFlags{Linked: true}.ToUint16(), // points to transfer 2 + Code: 1, + Ledger: 1, + } + transfer2 := Transfer{ + ID: id, + CreditAccountID: accountA.ID, + DebitAccountID: accountB.ID, + Amount: ToUint128(50), + // Does not have linked flag as it is the end of the chain. + // This will also cause it to fail as this is now a duplicate with different flags + Flags: 0, + Code: 1, + Ledger: 1, + } + results, err := client.CreateTransfers([]Transfer{transfer1, transfer2}) + if err != nil { + t.Fatal(err) + } + assert.Len(t, results, 2) + assert.True(t, results[0].Timestamp > 0) + assert.Equal(t, results[0].Status, TransferLinkedEventFailed) + assert.True(t, results[1].Timestamp > 0) + assert.Equal(t, results[1].Status, TransferExistsWithDifferentFlags) + + accounts, err := client.LookupAccounts([]Uint128{accountA.ID, accountB.ID}) + if err != nil { + t.Fatal(err) + } + assert.Len(t, accounts, 2) + + accountA = accounts[0] + assert.Equal(t, ToUint128(0), accountA.CreditsPosted) + assert.Equal(t, ToUint128(0), accountA.CreditsPending) + assert.Equal(t, ToUint128(0), accountA.DebitsPosted) + assert.Equal(t, ToUint128(0), accountA.DebitsPending) + + accountB = accounts[1] + assert.Equal(t, ToUint128(0), accountB.CreditsPosted) + assert.Equal(t, ToUint128(0), accountB.CreditsPending) + assert.Equal(t, ToUint128(0), accountB.DebitsPosted) + assert.Equal(t, ToUint128(0), accountB.DebitsPending) + }) + + t.Run("can close accounts", func(t *testing.T) { + t.Parallel() + accountA, accountB := createTwoAccounts(t) + + closingTransfer := Transfer{ + ID: ID(), + CreditAccountID: accountA.ID, + DebitAccountID: accountB.ID, + Amount: ToUint128(0), + Flags: TransferFlags{ + ClosingDebit: true, + ClosingCredit: true, + Pending: true, + }.ToUint16(), + Code: 1, + Ledger: 1, + } + results, err := client.CreateTransfers([]Transfer{closingTransfer}) + if err != nil { + t.Fatal(err) + } + assertCreateTransfersOK(t, results, 1) + + accounts, err := client.LookupAccounts([]Uint128{accountA.ID, accountB.ID}) + if err != nil { + t.Fatal(err) + } + assert.Len(t, accounts, 2) + + assert.NotEqual(t, accountA.Flags, accounts[0].Flags) + assert.True(t, accounts[0].AccountFlags().Closed) + + assert.NotEqual(t, accountB.Flags, accounts[1].Flags) + assert.True(t, accounts[1].AccountFlags().Closed) + + voidingTransfer := Transfer{ + ID: ID(), + CreditAccountID: accountA.ID, + DebitAccountID: accountB.ID, + Amount: ToUint128(0), + Flags: TransferFlags{ + VoidPendingTransfer: true, + }.ToUint16(), + PendingID: closingTransfer.ID, + Code: 1, + Ledger: 1, + } + results, err = client.CreateTransfers([]Transfer{voidingTransfer}) + if err != nil { + t.Fatal(err) + } + assertCreateTransfersOK(t, results, 1) + + accounts, err = client.LookupAccounts([]Uint128{accountA.ID, accountB.ID}) + if err != nil { + t.Fatal(err) + } + assert.Len(t, accounts, 2) + + assert.Equal(t, accountA.Flags, accounts[0].Flags) + assert.True(t, !accounts[0].AccountFlags().Closed) + + assert.Equal(t, accountB.Flags, accounts[1].Flags) + assert.True(t, !accounts[1].AccountFlags().Closed) + }) + + t.Run("accept zero-length create_accounts", func(t *testing.T) { + t.Parallel() + results, err := client.CreateAccounts([]Account{}) + if err != nil { + t.Fatal(err) + } + assert.Empty(t, results) + }) + + t.Run("accept zero-length create_transfers", func(t *testing.T) { + t.Parallel() + results, err := client.CreateTransfers([]Transfer{}) + if err != nil { + t.Fatal(err) + } + assert.Empty(t, results) + }) + + t.Run("accept zero-length lookup_accounts", func(t *testing.T) { + t.Parallel() + results, err := client.LookupAccounts([]Uint128{}) + if err != nil { + t.Fatal(err) + } + + assert.Empty(t, results) + }) + + t.Run("accept zero-length lookup_transfers", func(t *testing.T) { + t.Parallel() + results, err := client.LookupTransfers([]Uint128{}) + if err != nil { + t.Fatal(err) + } + + assert.Empty(t, results) + }) + + t.Run("can submit concurrent requests", func(t *testing.T) { + accountA, accountB := createTwoAccounts(t) + accounts, err := client.LookupAccounts([]Uint128{accountA.ID, accountB.ID}) + if err != nil { + t.Fatal(err) + } + assert.Len(t, accounts, 2) + accountACredits := accounts[0].CreditsPosted.BigInt() + accountBDebits := accounts[1].DebitsPosted.BigInt() + + const TASKS_MAX = 1_000_000 + var waitGroup sync.WaitGroup + for i := 0; i < TASKS_MAX; i++ { + waitGroup.Add(1) + + go func(i int) { + defer waitGroup.Done() + if i%2 == 0 { + results, err := client.CreateTransfers([]Transfer{ + { + ID: ID(), + CreditAccountID: accountA.ID, + DebitAccountID: accountB.ID, + Amount: ToUint128(1), + Ledger: 1, + Code: 1, + }, + }) + if err != nil { + t.Error(err) + return + } + assertCreateTransfersOK(t, results, 1) + } else { + results, err := client.LookupAccounts([]Uint128{accountA.ID}) + if err != nil { + t.Error(err) + return + } + assert.Len(t, results, 1) + assert.Equal(t, results[0].ID, accountA.ID) + } + }(i) + } + waitGroup.Wait() + + accounts, err = client.LookupAccounts([]Uint128{accountA.ID, accountB.ID}) + if err != nil { + t.Fatal(err) + } + assert.Len(t, accounts, 2) + accountACreditsAfter := accounts[0].CreditsPosted.BigInt() + accountBDebitsAfter := accounts[1].DebitsPosted.BigInt() + + // Each transfer moves ONE unit, + // so the credit/debit must differ from TRANSFERS_MAX units: + assert.Equal(t, TASKS_MAX/2, big.NewInt(0).Sub(accountACreditsAfter, accountACredits).Int64()) + assert.Equal(t, TASKS_MAX/2, big.NewInt(0).Sub(accountBDebitsAfter, accountBDebits).Int64()) + }) + + t.Run("can create concurrent linked chains", func(t *testing.T) { + accountA, accountB := createTwoAccounts(t) + + // NB: this test is _not_ parallel, so can use up all the concurrency. + const TRANSFERS_MAX = 10_000 + + accounts, err := client.LookupAccounts([]Uint128{accountA.ID, accountB.ID}) + if err != nil { + t.Fatal(err) + } + assert.Len(t, accounts, 2) + + var waitGroup sync.WaitGroup + for i := 0; i < TRANSFERS_MAX; i++ { + waitGroup.Add(1) + go func(i int) { + defer waitGroup.Done() + + // The Linked flag will cause the + // batch to fail due to LinkedEventChainOpen. + flags := TransferFlags{Linked: i%10 == 0}.ToUint16() + results, err := client.CreateTransfers([]Transfer{ + { + ID: ID(), + CreditAccountID: accountA.ID, + DebitAccountID: accountB.ID, + Amount: ToUint128(1), + Ledger: 1, + Code: 1, + Flags: flags, + }, + }) + if err != nil { + t.Error(err) + return + } + + assert.Len(t, results, 1) + assert.True(t, results[0].Timestamp > 0) + if i%10 == 0 { + assert.Equal(t, results[0].Status, TransferLinkedEventChainOpen) + } else { + assert.Equal(t, results[0].Status, TransferCreated) + } + }(i) + } + waitGroup.Wait() + }) + + t.Run("can query transfers for an account", func(t *testing.T) { + t.Parallel() + accountA, accountB := createTwoAccounts(t) + + BATCH_MAX := uint32(8189) + + // Create a new account: + accountC := Account{ + ID: ID(), + Ledger: 1, + Code: 1, + Flags: AccountFlags{ + History: true, + }.ToUint16(), + } + account_results, err := client.CreateAccounts([]Account{accountC}) + if err != nil { + t.Fatal(err) + } + assertCreateAccountsOK(t, account_results, 1) + + // Create transfers where the new account is either the debit or credit account: + transfers_created := make([]Transfer, 10) + for i := 0; i < 10; i++ { + transfer_id := ID() + + // Swap debit and credit accounts: + if i%2 == 0 { + transfers_created[i] = Transfer{ + ID: transfer_id, + CreditAccountID: accountA.ID, + DebitAccountID: accountC.ID, + Amount: ToUint128(50), + Flags: 0, + Code: 1, + Ledger: 1, + } + } else { + transfers_created[i] = Transfer{ + ID: transfer_id, + CreditAccountID: accountC.ID, + DebitAccountID: accountB.ID, + Amount: ToUint128(50), + Flags: 0, + Code: 1, + Ledger: 1, + } + } + } + transfer_results, err := client.CreateTransfers(transfers_created) + if err != nil { + t.Fatal(err) + } + assertCreateTransfersOK(t, transfer_results, len(transfers_created)) + + // Query all transfers for accountC: + filter := AccountFilter{ + AccountID: accountC.ID, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: false, + }.ToUint32(), + } + transfers_retrieved, err := client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err := client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, len(transfers_created)) + assert.Len(t, account_balances, len(transfers_retrieved)) + + timestamp := uint64(0) + for i, transfer := range transfers_retrieved { + assert.True(t, timestamp < transfer.Timestamp) + timestamp = transfer.Timestamp + + assert.True(t, transfer.Timestamp == account_balances[i].Timestamp) + } + + // Query only the debit transfers for accountC, descending: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: AccountFilterFlags{ + Debits: true, + Credits: false, + Reversed: true, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, len(transfers_created)/2) + assert.Len(t, account_balances, len(transfers_retrieved)) + + timestamp = ^uint64(0) + for i, transfer := range transfers_retrieved { + assert.True(t, transfer.Timestamp < timestamp) + timestamp = transfer.Timestamp + + assert.True(t, transfer.Timestamp == account_balances[i].Timestamp) + } + + // Query only the credit transfers for accountC, descending: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: AccountFilterFlags{ + Debits: false, + Credits: true, + Reversed: true, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, len(transfers_created)/2) + assert.Len(t, account_balances, len(transfers_retrieved)) + + timestamp = ^uint64(0) + for i, transfer := range transfers_retrieved { + assert.True(t, transfer.Timestamp < timestamp) + timestamp = transfer.Timestamp + + assert.True(t, transfer.Timestamp == account_balances[i].Timestamp) + } + + // Query the first 5 transfers for accountC: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: 0, + TimestampMax: 0, + Limit: uint32(len(transfers_created) / 2), + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: false, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, len(transfers_created)/2) + assert.Len(t, account_balances, len(transfers_retrieved)) + + timestamp = 0 + for i, transfer := range transfers_retrieved { + assert.True(t, timestamp < transfer.Timestamp) + timestamp = transfer.Timestamp + + assert.True(t, transfer.Timestamp == account_balances[i].Timestamp) + } + + // Query the next 5 transfers for accountC, with pagination: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: timestamp + 1, + TimestampMax: 0, + Limit: uint32(len(transfers_created) / 2), + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: false, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, len(transfers_created)/2) + assert.Len(t, account_balances, len(transfers_retrieved)) + + for i, transfer := range transfers_retrieved { + assert.True(t, timestamp < transfer.Timestamp) + timestamp = transfer.Timestamp + + assert.True(t, transfer.Timestamp == account_balances[i].Timestamp) + } + + // Query again, no more transfers should be found: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: timestamp + 1, + TimestampMax: 0, + Limit: uint32(len(transfers_created) / 2), + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: false, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, 0) + assert.Len(t, account_balances, len(transfers_retrieved)) + + // Query the first 5 transfers for accountC order by DESC: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: 0, + TimestampMax: 0, + Limit: uint32(len(transfers_created) / 2), + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: true, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, len(transfers_created)/2) + assert.Len(t, account_balances, len(transfers_retrieved)) + + timestamp = ^uint64(0) + for i, transfer := range transfers_retrieved { + assert.True(t, timestamp > transfer.Timestamp) + timestamp = transfer.Timestamp + + assert.True(t, transfer.Timestamp == account_balances[i].Timestamp) + } + + // Query the next 5 transfers for accountC, with pagination: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: 0, + TimestampMax: timestamp - 1, + Limit: uint32(len(transfers_created) / 2), + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: true, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, len(transfers_created)/2) + assert.Len(t, account_balances, len(transfers_retrieved)) + + for i, transfer := range transfers_retrieved { + assert.True(t, timestamp > transfer.Timestamp) + timestamp = transfer.Timestamp + + assert.True(t, transfer.Timestamp == account_balances[i].Timestamp) + } + + // Query again, no more transfers should be found: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: 0, + TimestampMax: timestamp - 1, + Limit: uint32(len(transfers_created) / 2), + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: true, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, 0) + assert.Len(t, account_balances, len(transfers_retrieved)) + + // Invalid account: + filter = AccountFilter{ + AccountID: ToUint128(0), + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: false, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, 0) + assert.Len(t, account_balances, len(transfers_retrieved)) + + // Invalid timestamp min: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: ^uint64(0), // ulong max value + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: false, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, 0) + assert.Len(t, account_balances, len(transfers_retrieved)) + + // Invalid timestamp max: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: 0, + TimestampMax: ^uint64(0), // ulong max value + Limit: BATCH_MAX, + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: false, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, 0) + assert.Len(t, account_balances, len(transfers_retrieved)) + + // Invalid timestamps: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: (^uint64(0)) - 1, // ulong max - 1 + TimestampMax: 1, + Limit: BATCH_MAX, + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: false, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, 0) + assert.Len(t, account_balances, len(transfers_retrieved)) + + // Zero limit: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: 0, + TimestampMax: 0, + Limit: 0, + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: false, + }.ToUint32(), + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, 0) + assert.Len(t, account_balances, len(transfers_retrieved)) + + // TooMuchData: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: 0, + TimestampMax: 0, + Limit: 10_000, + Flags: AccountFilterFlags{ + Debits: true, + Credits: true, + Reversed: false, + }.ToUint32(), + } + + _, err = client.GetAccountTransfers(filter) + assert.True(t, errors.Is(err, ErrTooMuchData)) + + _, err = client.GetAccountBalances(filter) + assert.True(t, errors.Is(err, ErrTooMuchData)) + + // Empty flags: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: 0, + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, 0) + assert.Len(t, account_balances, len(transfers_retrieved)) + + // Invalid flags: + filter = AccountFilter{ + AccountID: accountC.ID, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: 0xFFFF, + } + transfers_retrieved, err = client.GetAccountTransfers(filter) + if err != nil { + t.Fatal(err) + } + account_balances, err = client.GetAccountBalances(filter) + if err != nil { + t.Fatal(err) + } + + assert.Len(t, transfers_retrieved, 0) + assert.Len(t, account_balances, len(transfers_retrieved)) + }) + + t.Run("can query accounts", func(t *testing.T) { + t.Parallel() + + BATCH_MAX := uint32(8189) + + // Creating accounts: + accounts_created := make([]Account, 10) + for i := 0; i < 10; i++ { + account_id := ID() + + if i%2 == 0 { + accounts_created[i] = Account{ + ID: account_id, + UserData128: ToUint128(1000), + UserData64: 100, + UserData32: 10, + Code: 999, + Ledger: 1, + Flags: 0, + } + } else { + accounts_created[i] = Account{ + ID: account_id, + UserData128: ToUint128(2000), + UserData64: 200, + UserData32: 20, + Code: 999, + Ledger: 1, + Flags: 0, + } + } + } + account_results, err := client.CreateAccounts(accounts_created) + if err != nil { + t.Fatal(err) + } + assertCreateAccountsOK(t, account_results, len(accounts_created)) + + // Querying accounts where: + // `user_data_128=1000 AND user_data_64=100 AND user_data_32=10 + // AND code=999 AND ledger=1 ORDER BY timestamp ASC`. + filter := QueryFilter{ + UserData128: ToUint128(1000), + UserData64: 100, + UserData32: 10, + Code: 999, + Ledger: 1, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: QueryFilterFlags{ + Reversed: false, + }.ToUint32(), + } + query, err := client.QueryAccounts(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 5) + + timestamp := uint64(0) + for _, account := range query { + assert.True(t, timestamp < account.Timestamp) + timestamp = account.Timestamp + + assert.True(t, account.UserData128 == filter.UserData128) + assert.True(t, account.UserData64 == filter.UserData64) + assert.True(t, account.UserData32 == filter.UserData32) + assert.True(t, account.Code == filter.Code) + assert.True(t, account.Ledger == filter.Ledger) + } + + // Querying accounts where: + // `user_data_128=2000 AND user_data_64=200 AND user_data_32=20 + // AND code=999 AND ledger=1 ORDER BY timestamp DESC`. + filter = QueryFilter{ + UserData128: ToUint128(2000), + UserData64: 200, + UserData32: 20, + Code: 999, + Ledger: 1, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: QueryFilterFlags{ + Reversed: true, + }.ToUint32(), + } + query, err = client.QueryAccounts(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 5) + + timestamp = ^uint64(0) // ulong max value + for _, account := range query { + assert.True(t, timestamp > account.Timestamp) + timestamp = account.Timestamp + + assert.True(t, account.UserData128 == filter.UserData128) + assert.True(t, account.UserData64 == filter.UserData64) + assert.True(t, account.UserData32 == filter.UserData32) + assert.True(t, account.Code == filter.Code) + assert.True(t, account.Ledger == filter.Ledger) + } + + // Querying accounts where: + // `code=999 ORDER BY timestamp ASC`. + filter = QueryFilter{ + UserData128: ToUint128(0), + UserData64: 0, + UserData32: 0, + Code: 999, + Ledger: 0, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: QueryFilterFlags{ + Reversed: false, + }.ToUint32(), + } + query, err = client.QueryAccounts(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 10) + + timestamp = uint64(0) + for _, account := range query { + assert.True(t, timestamp < account.Timestamp) + timestamp = account.Timestamp + + assert.True(t, account.Code == filter.Code) + } + + // Querying accounts where: + // `code=999 ORDER BY timestamp DESC LIMIT 5`. + filter = QueryFilter{ + UserData128: ToUint128(0), + UserData64: 0, + UserData32: 0, + Code: 999, + Ledger: 0, + TimestampMin: 0, + TimestampMax: 0, + Limit: 5, + Flags: QueryFilterFlags{ + Reversed: true, + }.ToUint32(), + } + + // First 5 items: + query, err = client.QueryAccounts(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 5) + + timestamp = ^uint64(0) // ulong max value + for _, account := range query { + assert.True(t, timestamp > account.Timestamp) + timestamp = account.Timestamp + + assert.True(t, account.Code == filter.Code) + } + + // Next 5 items from this timestamp: + filter.TimestampMax = timestamp - 1 + query, err = client.QueryAccounts(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 5) + + for _, account := range query { + assert.True(t, timestamp > account.Timestamp) + timestamp = account.Timestamp + + assert.True(t, account.Code == filter.Code) + } + + // No more results: + filter.TimestampMax = timestamp - 1 + query, err = client.QueryAccounts(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 0) + + // Not found: + filter = QueryFilter{ + UserData128: ToUint128(0), + UserData64: 200, + UserData32: 10, + Code: 0, + Ledger: 0, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: QueryFilterFlags{ + Reversed: false, + }.ToUint32(), + } + + query, err = client.QueryAccounts(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 0) + }) + + t.Run("can query transfers", func(t *testing.T) { + t.Parallel() + accountA, accountB := createTwoAccounts(t) + + BATCH_MAX := uint32(8189) + + // Create a new account: + account := Account{ + ID: ID(), + Ledger: 1, + Code: 1, + Flags: 0, + } + account_results, err := client.CreateAccounts([]Account{account}) + if err != nil { + t.Fatal(err) + } + assertCreateAccountsOK(t, account_results, 1) + + // Creating transfers: + transfers_created := make([]Transfer, 10) + for i := 0; i < 10; i++ { + transfer_id := ID() + + if i%2 == 0 { + transfers_created[i] = Transfer{ + ID: transfer_id, + CreditAccountID: accountA.ID, + DebitAccountID: account.ID, + Amount: ToUint128(100), + Flags: 0, + UserData128: ToUint128(1000), + UserData64: 100, + UserData32: 10, + Code: 999, + Ledger: 1, + } + } else { + transfers_created[i] = Transfer{ + ID: transfer_id, + CreditAccountID: account.ID, + DebitAccountID: accountB.ID, + Amount: ToUint128(100), + Flags: 0, + UserData128: ToUint128(2000), + UserData64: 200, + UserData32: 20, + Code: 999, + Ledger: 1, + } + } + } + transfer_results, err := client.CreateTransfers(transfers_created) + if err != nil { + t.Fatal(err) + } + assertCreateTransfersOK(t, transfer_results, len(transfers_created)) + + // Querying transfers where: + // `user_data_128=1000 AND user_data_64=100 AND user_data_32=10 + // AND code=999 AND ledger=1 ORDER BY timestamp ASC`. + filter := QueryFilter{ + UserData128: ToUint128(1000), + UserData64: 100, + UserData32: 10, + Code: 999, + Ledger: 1, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: QueryFilterFlags{ + Reversed: false, + }.ToUint32(), + } + query, err := client.QueryTransfers(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 5) + + timestamp := uint64(0) + for _, transfer := range query { + assert.True(t, timestamp < transfer.Timestamp) + timestamp = transfer.Timestamp + + assert.True(t, transfer.UserData128 == filter.UserData128) + assert.True(t, transfer.UserData64 == filter.UserData64) + assert.True(t, transfer.UserData32 == filter.UserData32) + assert.True(t, transfer.Code == filter.Code) + assert.True(t, transfer.Ledger == filter.Ledger) + } + + // Querying transfers where: + // `user_data_128=2000 AND user_data_64=200 AND user_data_32=20 + // AND code=999 AND ledger=1 ORDER BY timestamp DESC`. + filter = QueryFilter{ + UserData128: ToUint128(2000), + UserData64: 200, + UserData32: 20, + Code: 999, + Ledger: 1, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: QueryFilterFlags{ + Reversed: true, + }.ToUint32(), + } + query, err = client.QueryTransfers(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 5) + + timestamp = ^uint64(0) // ulong max value + for _, transfer := range query { + assert.True(t, timestamp > transfer.Timestamp) + timestamp = transfer.Timestamp + + assert.True(t, transfer.UserData128 == filter.UserData128) + assert.True(t, transfer.UserData64 == filter.UserData64) + assert.True(t, transfer.UserData32 == filter.UserData32) + assert.True(t, transfer.Code == filter.Code) + assert.True(t, transfer.Ledger == filter.Ledger) + } + + // Querying transfers where: + // `code=999 ORDER BY timestamp ASC`. + filter = QueryFilter{ + UserData128: ToUint128(0), + UserData64: 0, + UserData32: 0, + Code: 999, + Ledger: 0, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: QueryFilterFlags{ + Reversed: false, + }.ToUint32(), + } + query, err = client.QueryTransfers(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 10) + + timestamp = uint64(0) + for _, transfer := range query { + assert.True(t, timestamp < transfer.Timestamp) + timestamp = transfer.Timestamp + + assert.True(t, transfer.Code == filter.Code) + } + + // Querying transfers where: + // `code=999 ORDER BY timestamp DESC LIMIT 5`. + filter = QueryFilter{ + UserData128: ToUint128(0), + UserData64: 0, + UserData32: 0, + Code: 999, + Ledger: 0, + TimestampMin: 0, + TimestampMax: 0, + Limit: 5, + Flags: QueryFilterFlags{ + Reversed: true, + }.ToUint32(), + } + + // First 5 items: + query, err = client.QueryTransfers(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 5) + + timestamp = ^uint64(0) // ulong max value + for _, transfer := range query { + assert.True(t, timestamp > transfer.Timestamp) + timestamp = transfer.Timestamp + + assert.True(t, transfer.Code == filter.Code) + } + + // Next 5 items from this timestamp: + filter.TimestampMax = timestamp - 1 + query, err = client.QueryTransfers(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 5) + + for _, transfer := range query { + assert.True(t, timestamp > transfer.Timestamp) + timestamp = transfer.Timestamp + + assert.True(t, transfer.Code == filter.Code) + } + + // No more results: + filter.TimestampMax = timestamp - 1 + query, err = client.QueryTransfers(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 0) + + // Not found: + filter = QueryFilter{ + UserData128: ToUint128(0), + UserData64: 200, + UserData32: 10, + Code: 0, + Ledger: 0, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: QueryFilterFlags{ + Reversed: false, + }.ToUint32(), + } + + query, err = client.QueryTransfers(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 0) + }) + + t.Run("invalid query filters", func(t *testing.T) { + t.Parallel() + + BATCH_MAX := uint32(8189) + + // Invalid timestamp min: + filter := QueryFilter{ + UserData128: ToUint128(0), + UserData64: 0, + UserData32: 0, + Ledger: 0, + Code: 0, + TimestampMin: ^uint64(0), // ulong max value + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: QueryFilterFlags{ + Reversed: false, + }.ToUint32(), + } + query, err := client.QueryTransfers(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 0) + + // Invalid timestamp max: + filter = QueryFilter{ + UserData128: ToUint128(0), + UserData64: 0, + UserData32: 0, + Ledger: 0, + Code: 0, + TimestampMin: 0, + TimestampMax: ^uint64(0), // ulong max value, + Limit: BATCH_MAX, + Flags: QueryFilterFlags{ + Reversed: false, + }.ToUint32(), + } + query, err = client.QueryTransfers(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 0) + + // Invalid timestamps: + filter = QueryFilter{ + UserData128: ToUint128(0), + UserData64: 0, + UserData32: 0, + Ledger: 0, + Code: 0, + TimestampMin: (^uint64(0)) - 1, // ulong max - 1, + TimestampMax: 1, + Limit: BATCH_MAX, + Flags: QueryFilterFlags{ + Reversed: false, + }.ToUint32(), + } + query, err = client.QueryTransfers(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 0) + + // Zero limit: + filter = QueryFilter{ + UserData128: ToUint128(0), + UserData64: 0, + UserData32: 0, + Ledger: 0, + Code: 0, + TimestampMin: 0, + TimestampMax: 0, + Limit: 0, + Flags: QueryFilterFlags{ + Reversed: false, + }.ToUint32(), + } + query, err = client.QueryTransfers(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 0) + + // TooMuchData: + filter = QueryFilter{ + UserData128: ToUint128(0), + UserData64: 0, + UserData32: 0, + Ledger: 0, + Code: 0, + TimestampMin: 0, + TimestampMax: 0, + Limit: 10_000, + Flags: QueryFilterFlags{ + Reversed: false, + }.ToUint32(), + } + _, err = client.QueryTransfers(filter) + assert.True(t, errors.Is(err, ErrTooMuchData)) + + _, err = client.QueryAccounts(filter) + assert.True(t, errors.Is(err, ErrTooMuchData)) + + // Invalid flags: + filter = QueryFilter{ + UserData128: ToUint128(0), + UserData64: 0, + UserData32: 0, + Ledger: 0, + Code: 0, + TimestampMin: 0, + TimestampMax: 0, + Limit: BATCH_MAX, + Flags: 0xFFFF, + } + query, err = client.QueryTransfers(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, query, 0) + }) + + t.Run("get change events", func(t *testing.T) { + t.Parallel() + filter := ChangeEventsFilter{ + TimestampMin: 0, + TimestampMax: 0, + Limit: 10, + } + events, err := client.GetChangeEvents(filter) + if err != nil { + t.Fatal(err) + } + assert.Len(t, events, int(filter.Limit)) + }) +} + +func doTestImportedFlag(t *testing.T, client Client) { + t.Run("can import accounts and transfers", func(t *testing.T) { + tmpAccount := ID() + tmpResults, err := client.CreateAccounts([]Account{ + { + ID: tmpAccount, + Ledger: 1, + Code: 2, + }, + }) + if err != nil { + t.Fatal(err) + } + assertCreateAccountsOK(t, tmpResults, 1) + + tmpAccounts, err := client.LookupAccounts([]Uint128{tmpAccount}) + if err != nil { + t.Fatal(err) + } + assert.Len(t, tmpAccounts, 1) + + // Wait 10 ms so we can use the account's timestamp as the reference for past time + // after the last object inserted. + time.Sleep(10 * time.Millisecond) + timestampMax := tmpAccounts[0].Timestamp + + accountA := ID() + accountB := ID() + transferA := ID() + + accountResults, err := client.CreateAccounts([]Account{ + { + ID: accountA, + Ledger: 1, + Code: 1, + Flags: AccountFlags{ + Imported: true, + }.ToUint16(), + Timestamp: timestampMax + 1, + }, + { + ID: accountB, + Ledger: 1, + Code: 2, + Flags: AccountFlags{ + Imported: true, + }.ToUint16(), + Timestamp: timestampMax + 2, + }}) + if err != nil { + t.Fatal(err) + } + assertCreateAccountsOK(t, accountResults, 2) + + transferResults, err := client.CreateTransfers([]Transfer{ + { + ID: transferA, + CreditAccountID: accountA, + DebitAccountID: accountB, + Amount: ToUint128(100), + Ledger: 1, + Code: 1, + Flags: TransferFlags{ + Imported: true, + }.ToUint16(), + Timestamp: timestampMax + 3, + }, + }) + if err != nil { + t.Fatal(err) + } + assertCreateTransfersOK(t, transferResults, 1) + + accounts, err := client.LookupAccounts([]Uint128{accountA, accountB}) + if err != nil { + t.Fatal(err) + } + assert.Len(t, accounts, 2) + assert.Equal(t, timestampMax+1, accounts[0].Timestamp) + assert.Equal(t, timestampMax+2, accounts[1].Timestamp) + + transfers, err := client.LookupTransfers([]Uint128{transferA}) + if err != nil { + t.Fatal(err) + } + assert.Len(t, transfers, 1) + assert.Equal(t, timestampMax+3, transfers[0].Timestamp) + }) +} + +func assertCreateAccountsOK(t *testing.T, results []CreateAccountResult, expected int) { + assert.Len(t, results, expected) + for _, result := range results { + assert.True(t, result.Timestamp > 0) + assert.Equal(t, result.Status, AccountCreated) + } +} + +func assertCreateTransfersOK(t *testing.T, results []CreateTransferResult, expected int) { + assert.Len(t, results, expected) + for _, result := range results { + assert.True(t, result.Timestamp > 0) + assert.Equal(t, result.Status, TransferCreated) + } +} + +func BenchmarkNop(b *testing.B) { + WithClient(b, func(client Client) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := client.Nop(); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/ocam/src/clients/go/uint128.go b/ocam/src/clients/go/uint128.go new file mode 100644 index 00000000..50599d90 --- /dev/null +++ b/ocam/src/clients/go/uint128.go @@ -0,0 +1,170 @@ +package tigerbeetle_go + +/* +#include "./native/tb_client.h" +*/ +import "C" +import ( + "crypto/rand" + "encoding/binary" + "encoding/hex" + "fmt" + "math/big" + "sync" + "time" + "unsafe" +) + +type Uint128 C.tb_uint128_t + +func (value Uint128) Bytes() [16]byte { + return *(*[16]byte)(unsafe.Pointer(&value)) +} + +func swapEndian(bytes []byte) { + for i, j := 0, len(bytes)-1; i < j; i, j = i+1, j-1 { + bytes[i], bytes[j] = bytes[j], bytes[i] + } +} + +func (value Uint128) String() string { + bytes := value.Bytes() + + // Convert little-endian Uint128 number to big-endian string. + swapEndian(bytes[:]) + s := hex.EncodeToString(bytes[:16]) + + // Prettier to drop preceding zeros so you get "0" instead of "0000000000000000". + lastNonZero := 0 + for s[lastNonZero] == '0' && lastNonZero < len(s)-1 { + lastNonZero++ + } + return s[lastNonZero:] +} + +func (value Uint128) BigInt() *big.Int { + // big.Int uses bytes in big-endian but Uint128 stores bytes in little endian, so reverse it. + bytes := value.Bytes() + swapEndian(bytes[:]) + + ret := big.Int{} + ret.SetBytes(bytes[:]) + return &ret +} + +// Returns two 64-bit integers representing the least significant (first 8 bytes) value +// and the most significant (last 8 bytes) value of the 128-bit integer. +func (value Uint128) Uint64() (uint64, uint64) { + parts := (*[2]uint64)(unsafe.Pointer(&value)) + return parts[0], parts[1] +} + +// BytesToUint128 converts a raw [16]byte value to Uint128. +func BytesToUint128(value [16]byte) Uint128 { + return *(*Uint128)(unsafe.Pointer(&value[0])) +} + +// HexStringToUint128 converts a hex-encoded integer to a Uint128. +func HexStringToUint128(value string) (Uint128, error) { + if len(value) > 32 { + return Uint128{}, fmt.Errorf("Uint128 hex string must not be more than 32 bytes.") + } + if len(value)%2 == 1 { + value = "0" + value + } + + bytes := [16]byte{} + nonZeroLen, err := hex.Decode(bytes[:], []byte(value)) + if err != nil { + return Uint128{}, err + } + + // Convert big-endian string to little endian number + for i := 0; i < nonZeroLen/2; i += 1 { + j := nonZeroLen - 1 - i + bytes[i], bytes[j] = bytes[j], bytes[i] + } + + return BytesToUint128(bytes), nil +} + +// BigIntToUint128 converts a [math/big.Int] to a Uint128. +func BigIntToUint128(value *big.Int) Uint128 { + if value.Sign() < 0 { + panic("cannot convert negative big.Int to Uint128") + } + + // FillBytes can panic if the value does not fit in 16 bytes. + bytes := value.FillBytes(make([]byte, 16)) + + // big.Int bytes are big-endian so convert them to little-endian for Uint128 bytes. + swapEndian(bytes[:]) + + return BytesToUint128(*(*[16]byte)(bytes)) +} + +// ToUint128 converts a integer to a Uint128. +func ToUint128(value uint64) Uint128 { + values := [2]uint64{value, 0} + return *(*Uint128)(unsafe.Pointer(&values[0])) +} + +var idLastTimestamp int64 +var idLastRandom [10]byte +var idMutex sync.Mutex + +// Generates a Universally Unique and Sortable Identifier based on https://github.com/ulid/spec. +// Uint128 returned are guaranteed to be monotonically increasing when interpreted as little-endian. +// `ID()` is safe to call from multiple goroutines with monotonicity being sequentially consistent. +// +// Panics if it is unable to generated random bytes. +// Panics if the timestamp is outside of a reasonable bounds. +func ID() Uint128 { + timestamp := time.Now().UnixMilli() + + // Lock the mutex for global id variables. + // Then ensure lastTimestamp is monotonically increasing & lastRandom changes each millisecond + idMutex.Lock() + if timestamp <= idLastTimestamp { + timestamp = idLastTimestamp + } else { + idLastTimestamp = timestamp + _, err := rand.Read(idLastRandom[:]) + if err != nil { + idMutex.Unlock() + panic("crypto.rand failed to provide random bytes") + } + } + + // Read out a uint80 from lastRandom as a uint64 and uint16. + randomLo := binary.LittleEndian.Uint64(idLastRandom[:8]) + randomHi := binary.LittleEndian.Uint16(idLastRandom[8:]) + + // Increment the random bits as a uint80 together. + // If the random bits wrap, increment the timestamp. + randomLo += 1 + if randomLo == 0 { + randomHi += 1 + if randomHi == 0 { + timestamp += 1 + idLastTimestamp = timestamp + + if timestamp == 1<<48 { + panic("timestamp overflow") + } + } + } + + // Write incremented uint80 back to lastRandom and stop mutating global id variables. + binary.LittleEndian.PutUint64(idLastRandom[:8], randomLo) + binary.LittleEndian.PutUint16(idLastRandom[8:], randomHi) + idMutex.Unlock() + + // Create Uint128 from new timestamp and random. + var id [16]byte + binary.LittleEndian.PutUint64(id[:8], randomLo) + binary.LittleEndian.PutUint16(id[8:], randomHi) + binary.LittleEndian.PutUint16(id[10:], (uint16)(timestamp)) // timestamp lo + binary.LittleEndian.PutUint32(id[12:], (uint32)(timestamp>>16)) // timestamp hi + return BytesToUint128(id) +} diff --git a/ocam/src/clients/go/uint128_test.go b/ocam/src/clients/go/uint128_test.go new file mode 100644 index 00000000..e0ff09e5 --- /dev/null +++ b/ocam/src/clients/go/uint128_test.go @@ -0,0 +1,160 @@ +package tigerbeetle_go + +import ( + "math/big" + "sync" + "testing" + "time" + + "github.com/tigerbeetle/tigerbeetle-go/assert" +) + +func Test_HexStringToUint128(t *testing.T) { + tests := []string{ + "0", + "1", + "400", + "203", + "ffffffffffffffffffffffffffffffff", + "123456", + } + + for _, test := range tests { + res, err := HexStringToUint128(test) + if err != nil { + t.Fatalf("Expected %s to be a valid hex string, got: %s", test, err) + } + thereAndBack := res.String() + if thereAndBack != test { + t.Fatalf("Expected %s to be %s, got %s", test, test, thereAndBack) + } + } +} + +func Test_HexStringToUint128_LittleEndian(t *testing.T) { + test := "123456" + + res, err := HexStringToUint128(test) + if err != nil { + t.Fatalf("Expected %s to be a valid hex string, got: %s", test, err) + } + + expected := [16]byte{86, 52, 18, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} + + if res.Bytes() != expected { + t.Fatalf("Expected %s to produce bytes %v, got bytes %v", test, expected, res.Bytes()) + } +} + +func Test_BigIntToUint128(t *testing.T) { + tests := []string{ + "0", + "1", + "400", + "203", + "ffffffffffffffffffffffffffffffff", + "123456", + } + + for _, test := range tests { + uint128, err := HexStringToUint128(test) + + if err != nil { + t.Fatalf("Expected %s to be a valid hex string, got: %s", test, err) + } + + bigint := uint128.BigInt() + uint128_back := BigIntToUint128(bigint) + string_back := uint128_back.String() + + if string_back != test { + t.Fatalf("Expected %s to be %s, got %s", test, test, string_back) + } + } +} + +func Test_ToUint64(t *testing.T) { + zero := ToUint128(0) + lo, hi := zero.Uint64() + assert.True(t, lo == 0) + assert.True(t, hi == 0) + + maxUint64 := ToUint128(^uint64(0)) + lo, hi = maxUint64.Uint64() + assert.True(t, lo == ^uint64(0)) + assert.True(t, hi == 0) +} + +func Test_BigIntToUint128_Negative(t *testing.T) { + negative := new(big.Int).SetInt64(-1) + testFunc := func() { + BigIntToUint128(negative) + } + + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic") + } + }() + + testFunc() + t.Errorf("Expected panic, but execution continued") +} + +func Test_BigIntToUint128_ExceedU128(t *testing.T) { + tooBig := new(big.Int).Lsh(big.NewInt(1), 128) + testFunc := func() { + BigIntToUint128(tooBig) + } + + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic") + } + }() + + testFunc() + t.Errorf("Expected panic, but execution continued") +} + +func Test_ID(t *testing.T) { + verifier := func() { + idA := ID() + for i := 0; i < 1_000_000; i++ { + if i%1_000 == 0 { + time.Sleep(1 * time.Millisecond) + } + + idB := ID() + + // Verify idB and idA are monotonic using BigInts. + a := idA.BigInt() + b := idB.BigInt() + if b.Cmp(a) != 1 { + t.Fatalf("Expected ID %v to be greater than ID %v", b, a) + } + + idA = idB + } + } + + // Verify monotonic IDs locally. + verifier() + + // Verify monotonic IDs across multiple threads. + var barrier, finish sync.WaitGroup + concurrency := 10 + barrier.Add(concurrency) // To sync up all goroutines before verifier() to maximize contention. + finish.Add(concurrency) // To wait for all goroutines to finish running verifier(). + + for i := 0; i < concurrency; i++ { + go func() { + barrier.Done() + barrier.Wait() + verifier() + finish.Done() + }() + } + + finish.Wait() +} diff --git a/ocam/src/clients/java/.gitignore b/ocam/src/clients/java/.gitignore new file mode 100644 index 00000000..5356a95b --- /dev/null +++ b/ocam/src/clients/java/.gitignore @@ -0,0 +1,10 @@ +build +target +src/main/resources/lib/** +examples/build +examples/target +*tigerbeetle.benchmark +*tigerbeetle.examples +*tigerbeetle.tests +*.log +lib/ diff --git a/ocam/src/clients/java/.vscode/settings.json b/ocam/src/clients/java/.vscode/settings.json new file mode 100644 index 00000000..5d1703aa --- /dev/null +++ b/ocam/src/clients/java/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "java.project.sourcePaths": [ + "src/main/java", + "src/test/java", + ], + "java.configuration.updateBuildConfiguration": "automatic", + "java.format.enabled": true, + "java.format.settings.url": "eclipse-formatter.xml", + "cSpell.words": [ + "tigerbeetle" + ], +} \ No newline at end of file diff --git a/ocam/src/clients/java/LICENSE.txt b/ocam/src/clients/java/LICENSE.txt new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/ocam/src/clients/java/LICENSE.txt @@ -0,0 +1,177 @@ + + 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 diff --git a/ocam/src/clients/java/README.md b/ocam/src/clients/java/README.md new file mode 100644 index 00000000..5f2284f6 --- /dev/null +++ b/ocam/src/clients/java/README.md @@ -0,0 +1,868 @@ + +# tigerbeetle-java + +The TigerBeetle client for Java. + +[![javadoc](https://javadoc.io/badge2/com.tigerbeetle/tigerbeetle-java/javadoc.svg)](https://javadoc.io/doc/com.tigerbeetle/tigerbeetle-java) + +[![maven-central](https://img.shields.io/maven-central/v/com.tigerbeetle/tigerbeetle-java)](https://central.sonatype.com/namespace/com.tigerbeetle) + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Java >= 11 +* Maven >= 3.6 (not strictly necessary but it's what our guides assume) + +## Setup + +First, create a directory for your project and `cd` into the directory. + +Then create `pom.xml` and copy this into it: + +```xml + + 4.0.0 + + com.tigerbeetle + samples + 1.0-SNAPSHOT + + + 11 + 11 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all,-options,-path + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + com.tigerbeetle.samples.Main + + + + + + + + com.tigerbeetle + tigerbeetle-java + + 0.0.1-3431 + + + +``` + +Then, install the TigerBeetle client: + +```console +mvn install +``` + +Now, create `src/main/java/Main.java` and copy this into it: + +```java +import com.tigerbeetle.*; + +public final class Main { + public static void main(String[] args) throws Exception { + System.out.println("Import ok!"); + } +} +``` + +Finally, build and run: + +```console +mvn exec:java +``` + +Now that all prerequisites and dependencies are correctly set +up, let's dig into using TigerBeetle. + +## Sample projects + +This document is primarily a reference guide to +the client. Below are various sample projects demonstrating +features of TigerBeetle. + +* [Basic](/src/clients/java/samples/basic/): Create two accounts and transfer an amount between them. +* [Two-Phase Transfer](/src/clients/java/samples/two-phase/): Create two accounts and start a pending transfer between +them, then post the transfer. +* [Many Two-Phase Transfers](/src/clients/java/samples/two-phase-many/): Create two accounts and start a number of pending transfers +between them, posting and voiding alternating transfers. + +## Creating a Client + +A client is created with a cluster ID and replica +addresses for all replicas in the cluster. The cluster +ID and replica addresses are both chosen by the system that +starts the TigerBeetle cluster. + +Clients are thread-safe and a single instance should be shared +between multiple concurrent tasks. This allows events to be +[automatically batched](https://docs.tigerbeetle.com/coding/requests/#batching-events). + +Multiple clients are useful when connecting to more than +one TigerBeetle cluster. + +In this example the cluster ID is `0` and there is one +replica. The address is read from the `TB_ADDRESS` +environment variable and defaults to port `3000`. + +```java +String replicaAddress = System.getenv("TB_ADDRESS"); +byte[] clusterID = UInt128.asBytes(0); +String[] replicaAddresses = new String[] {replicaAddress == null ? "3000" : replicaAddress}; +try (var client = new Client(clusterID, replicaAddresses)) { + // Use client +} +``` + +The following are valid addresses: +* `3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1:3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1` (interpreted as `127.0.0.1:3001`, `3001` is the default port) + +## Creating Accounts + +See details for account fields in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account). + +```java +AccountBatch accounts = new AccountBatch(1); +accounts.add(); +accounts.setId(UInt128.id()); // TigerBeetle time-based ID. +accounts.setUserData128(0, 0); +accounts.setUserData64(0); +accounts.setUserData32(0); +accounts.setLedger(1); +accounts.setCode(718); +accounts.setFlags(AccountFlags.NONE); +accounts.setTimestamp(0); + +CreateAccountResultBatch accountResults = client.createAccounts(accounts); +// Results handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +The 128-bit fields like `id` and `user_data_128` have a few +overrides to make it easier to integrate. You can either +pass in a long, a pair of longs (least and most +significant bits), or a `byte[]`. + +There is also a `com.tigerbeetle.UInt128` helper with static +methods for converting 128-bit little-endian unsigned integers +between instances of `long`, `java.util.UUID`, `java.math.BigInteger` and `byte[]`. + +The fields for transfer amounts and account balances are also 128-bit, +but they are always represented as a `java.math.BigInteger`. + +### Account Flags + +The account flags value is a bitfield. See details for +these flags in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account#flags). + +To toggle behavior for an account, combine enum values stored in the +`AccountFlags` object with bitwise-or: + +* `AccountFlags.LINKED` +* `AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS` +* `AccountFlags.CREDITS_MUST_NOT_EXCEED_CREDITS` +* `AccountFlags.HISTORY` + +For example, to link two accounts where the first account +additionally has the `debits_must_not_exceed_credits` constraint: + +```java +AccountBatch accounts = new AccountBatch(2); + +accounts.add(); +accounts.setId(100); +accounts.setLedger(1); +accounts.setCode(718); +accounts.setFlags(AccountFlags.LINKED | AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS); + +accounts.add(); +accounts.setId(101); +accounts.setLedger(1); +accounts.setCode(718); +accounts.setFlags(AccountFlags.HISTORY); + +CreateAccountResultBatch accountResults = client.createAccounts(accounts); +// Results handling omitted. +``` + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each account in the request batch: +- Successfully created accounts with the status + [`created`](https://docs.tigerbeetle.com/reference/requests/create_accounts#created) + return the timestamp assigned to the `Account` object. +- Already existing accounts with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists) + return the timestamp of the original existing object. +- Failed accounts return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_accounts reference](https://docs.tigerbeetle.com/reference/requests/create_accounts#status). + +```java +AccountBatch accounts = new AccountBatch(3); + +accounts.add(); +accounts.setId(102); +accounts.setLedger(1); +accounts.setCode(718); +accounts.setFlags(AccountFlags.NONE); + +accounts.add(); +accounts.setId(103); +accounts.setLedger(1); +accounts.setCode(718); +accounts.setFlags(AccountFlags.NONE); + +accounts.add(); +accounts.setId(104); +accounts.setLedger(1); +accounts.setCode(718); +accounts.setFlags(AccountFlags.NONE); + +CreateAccountResultBatch accountResults = client.createAccounts(accounts); +while (accountResults.next()) { + switch (accountResults.getStatus()) { + case Created: + System.out.printf("Batch account at %d successfully created with timestamp %d.\n", + accountResults.getPosition(), accountResults.getTimestamp()); + break; + case Exists: + System.err.printf("Batch account at %d already exists with timestamp %d.\n", + accountResults.getPosition(), accountResults.getTimestamp()); + break; + default: + System.err.printf("Batch account at %d failed to create: %s.\n", + accountResults.getPosition(), accountResults.getStatus()); + break; + } +} +``` + +## Account Lookup + +Account lookup is batched, like account creation. Pass +in all IDs to fetch. The account for each matched ID is returned. + +If no account matches an ID, no object is returned for +that account. So the order of accounts in the response is +not necessarily the same as the order of IDs in the +request. You can refer to the ID field in the response to +distinguish accounts. + +```java +IdBatch ids = new IdBatch(2); +ids.add(100); +ids.add(101); + +AccountBatch accounts = client.lookupAccounts(ids); +``` + +## Create Transfers + +This creates a journal entry between two accounts. + +See details for transfer fields in the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer). + +```java +TransferBatch transfers = new TransferBatch(1); + +transfers.add(); +transfers.setId(UInt128.id()); +transfers.setDebitAccountId(102); +transfers.setCreditAccountId(103); +transfers.setAmount(10); +transfers.setUserData128(0, 0); +transfers.setUserData64(0); +transfers.setUserData32(0); +transfers.setTimeout(0); +transfers.setLedger(1); +transfers.setCode(1); +transfers.setFlags(TransferFlags.NONE); +transfers.setTimeout(0); + +CreateTransferResultBatch transferResults = client.createTransfers(transfers); +// Results handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each transfer in the request batch: +- Successfully created transfers with the result + [`created`](https://docs.tigerbeetle.com/reference/requests/create_transfers#created) + return the timestamp assigned to the `Transfer` object. +- Already existing transfers with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists) + return the timestamp of the original existing object. +- Failed transfers return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_transfers reference](https://docs.tigerbeetle.com/reference/requests/create_transfers#status). + +```java +TransferBatch transfers = new TransferBatch(3); + +transfers.add(); +transfers.setId(1); +transfers.setDebitAccountId(102); +transfers.setCreditAccountId(103); +transfers.setAmount(10); +transfers.setLedger(1); +transfers.setCode(1); + +transfers.add(); +transfers.setId(2); +transfers.setDebitAccountId(102); +transfers.setCreditAccountId(103); +transfers.setAmount(10); +transfers.setLedger(1); +transfers.setCode(1); + +transfers.add(); +transfers.setId(3); +transfers.setDebitAccountId(102); +transfers.setCreditAccountId(103); +transfers.setAmount(10); +transfers.setLedger(1); +transfers.setCode(1); + +CreateTransferResultBatch transferResults = client.createTransfers(transfers); +while (transferResults.next()) { + switch (transferResults.getStatus()) { + case Created: + System.out.printf("Batch transfer at %d successfully created with timestamp %d.\n", + transferResults.getPosition(), transferResults.getTimestamp()); + break; + case Exists: + System.err.printf("Batch transfer at %d already exists with timestamp %d.\n", + transferResults.getPosition(), transferResults.getTimestamp()); + break; + default: + System.err.printf("Batch transfer at %d failed to create: %s\n", + transferResults.getPosition(), transferResults.getStatus()); + break; + } +} +``` + +## Batching + +TigerBeetle performance is maximized when you batch +API requests. + +A client instance shared across multiple threads/tasks can automatically +batch concurrent requests, but the application must still send as many events +as possible in a single call. + +For example, if you insert 1 million transfers sequentially, one at a time, +the insert rate will be a *fraction* of the potential, because the client will +wait for a reply between each one. +Instead, **always batch as much as you can**. + +The maximum batch size is set in the TigerBeetle server. The default is 8189. + +```java +ResultSet dataSource = null; /* Loaded from an external source. */; + +var BATCH_SIZE = 8189; +TransferBatch batch = new TransferBatch(BATCH_SIZE); +while(dataSource.next()) { + batch.add(); + batch.setId(dataSource.getBytes("id")); + batch.setDebitAccountId(dataSource.getBytes("debit_account_id")); + batch.setCreditAccountId(dataSource.getBytes("credit_account_id")); + batch.setAmount(dataSource.getBigDecimal("amount").toBigInteger()); + batch.setLedger(dataSource.getInt("ledger")); + batch.setCode(dataSource.getInt("code")); + + if (batch.getLength() == BATCH_SIZE) { + CreateTransferResultBatch transferResults = client.createTransfers(batch); + // Results handling omitted. + + // Reset the batch for the next iteration. + batch.beforeFirst(); + } +} + +if (batch.getLength() > 0) { + // Send the remaining items. + CreateTransferResultBatch transferResults = client.createTransfers(batch); + // Results handling omitted. +} + +``` + +### Queues and Workers + +If you are making requests to TigerBeetle from workers +pulling jobs from a queue, you can batch requests to +TigerBeetle by having the worker act on multiple jobs from +the queue at once rather than one at a time. i.e. pulling +multiple jobs from the queue rather than just one. + +## Transfer Flags + +The transfer `flags` value is a bitfield. See details for these flags in +the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer#flags). + +To toggle behavior for an account, combine enum values stored in the +`TransferFlags` object with bitwise-or: + +* `TransferFlags.NONE` +* `TransferFlags.LINKED` +* `TransferFlags.PENDING` +* `TransferFlags.POST_PENDING_TRANSFER` +* `TransferFlags.VOID_PENDING_TRANSFER` + +For example, to link `transfer0` and `transfer1`: + +```java +TransferBatch transfers = new TransferBatch(2); + +// First transfer +transfers.add(); +transfers.setId(4); +transfers.setDebitAccountId(102); +transfers.setCreditAccountId(103); +transfers.setAmount(10); +transfers.setLedger(1); +transfers.setCode(1); +transfers.setFlags(TransferFlags.LINKED); + +transfers.add(); +transfers.setId(5); +transfers.setDebitAccountId(102); +transfers.setCreditAccountId(103); +transfers.setAmount(10); +transfers.setLedger(1); +transfers.setCode(1); +transfers.setFlags(TransferFlags.NONE); + +CreateTransferResultBatch transferResults = client.createTransfers(transfers); +// Results handling omitted. +``` + +### Two-Phase Transfers + +Two-phase transfers are supported natively by toggling the appropriate +flag. TigerBeetle will then adjust the `credits_pending` and +`debits_pending` fields of the appropriate accounts. A corresponding +post pending transfer then needs to be sent to post or void the +transfer. + +#### Post a Pending Transfer + +With `flags` set to `post_pending_transfer`, +TigerBeetle will post the transfer. TigerBeetle will atomically roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and apply them to the `debits_posted` and +`credits_posted` balances. + +```java +TransferBatch transfers = new TransferBatch(1); + +transfers.add(); +transfers.setId(6); +transfers.setDebitAccountId(102); +transfers.setCreditAccountId(103); +transfers.setAmount(10); +transfers.setLedger(1); +transfers.setCode(1); +transfers.setFlags(TransferFlags.PENDING); + +CreateTransferResultBatch transferResults = client.createTransfers(transfers); +// Results handling omitted. + +transfers = new TransferBatch(1); + +transfers.add(); +transfers.setId(7); +transfers.setAmount(TransferBatch.AMOUNT_MAX); +transfers.setPendingId(6); +transfers.setFlags(TransferFlags.POST_PENDING_TRANSFER); + +transferResults = client.createTransfers(transfers); +// Results handling omitted. +``` + +#### Void a Pending Transfer + +In contrast, with `flags` set to `void_pending_transfer`, +TigerBeetle will void the transfer. TigerBeetle will roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and **not** apply them to the `debits_posted` and +`credits_posted` balances. + +```java +TransferBatch transfers = new TransferBatch(1); + +transfers.add(); +transfers.setId(8); +transfers.setDebitAccountId(102); +transfers.setCreditAccountId(103); +transfers.setAmount(10); +transfers.setLedger(1); +transfers.setCode(1); +transfers.setFlags(TransferFlags.PENDING); + +CreateTransferResultBatch transferResults = client.createTransfers(transfers); +// Results handling omitted. + +transfers = new TransferBatch(1); + +transfers.add(); +transfers.setId(9); +transfers.setAmount(0); +transfers.setPendingId(8); +transfers.setFlags(TransferFlags.VOID_PENDING_TRANSFER); + +transferResults = client.createTransfers(transfers); +// Results handling omitted. +``` + +## Transfer Lookup + +NOTE: While transfer lookup exists, it is not a flexible query API. We +are developing query APIs and there will be new methods for querying +transfers in the future. + +Transfer lookup is batched, like transfer creation. Pass in all `id`s to +fetch, and matched transfers are returned. + +If no transfer matches an `id`, no object is returned for that +transfer. So the order of transfers in the response is not necessarily +the same as the order of `id`s in the request. You can refer to the +`id` field in the response to distinguish transfers. + +```java +IdBatch ids = new IdBatch(2); +ids.add(1); +ids.add(2); + +TransferBatch transfers = client.lookupTransfers(ids); +``` + +## Get Account Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the transfers involving a given account, allowing basic filter and pagination +capabilities. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```java +AccountFilter filter = new AccountFilter(); +filter.setAccountId(2); +filter.setUserData128(0); // No filter by UserData. +filter.setUserData64(0); +filter.setUserData32(0); +filter.setCode(0); // No filter by Code. +filter.setTimestampMin(0); // No filter by Timestamp. +filter.setTimestampMax(0); // No filter by Timestamp. +filter.setLimit(10); // Limit to ten transfers at most. +filter.setDebits(true); // Include transfer from the debit side. +filter.setCredits(true); // Include transfer from the credit side. +filter.setReversed(true); // Sort by timestamp in reverse-chronological order. + +TransferBatch transfers = client.getAccountTransfers(filter); +``` + +## Get Account Balances + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the point-in-time balances of a given account, allowing basic filter and +pagination capabilities. + +Only accounts created with the flag +[`history`](https://docs.tigerbeetle.com/reference/account#flagshistory) set retain +[historical balances](https://docs.tigerbeetle.com/reference/requests/get_account_balances). + +The balances in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```java +AccountFilter filter = new AccountFilter(); +filter.setAccountId(2); +filter.setUserData128(0); // No filter by UserData. +filter.setUserData64(0); +filter.setUserData32(0); +filter.setCode(0); // No filter by Code. +filter.setTimestampMin(0); // No filter by Timestamp. +filter.setTimestampMax(0); // No filter by Timestamp. +filter.setLimit(10); // Limit to ten balances at most. +filter.setDebits(true); // Include transfer from the debit side. +filter.setCredits(true); // Include transfer from the credit side. +filter.setReversed(true); // Sort by timestamp in reverse-chronological order. + +AccountBalanceBatch account_balances = client.getAccountBalances(filter); +``` + +## Query Accounts + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query accounts by the intersection of some fields and by timestamp range. + +The accounts in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```java +QueryFilter filter = new QueryFilter(); +filter.setUserData128(1000); // Filter by UserData. +filter.setUserData64(100); +filter.setUserData32(10); +filter.setCode(1); // Filter by Code. +filter.setLedger(0); // No filter by Ledger. +filter.setTimestampMin(0); // No filter by Timestamp. +filter.setTimestampMax(0); // No filter by Timestamp. +filter.setLimit(10); // Limit to ten accounts at most. +filter.setReversed(true); // Sort by timestamp in reverse-chronological order. + +AccountBatch accounts = client.queryAccounts(filter); +``` + +## Query Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query transfers by the intersection of some fields and by timestamp range. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```java +QueryFilter filter = new QueryFilter(); +filter.setUserData128(1000); // Filter by UserData. +filter.setUserData64(100); +filter.setUserData32(10); +filter.setCode(1); // Filter by Code. +filter.setLedger(0); // No filter by Ledger. +filter.setTimestampMin(0); // No filter by Timestamp. +filter.setTimestampMax(0); // No filter by Timestamp. +filter.setLimit(10); // Limit to ten transfers at most. +filter.setReversed(true); // Sort by timestamp in reverse-chronological order. + +TransferBatch transfers = client.queryTransfers(filter); +``` + +## Linked Events + +When the `linked` flag is specified for an account when creating accounts or +a transfer when creating transfers, it links that event with the next event in the +batch, to create a chain of events, of arbitrary length, which all +succeed or fail together. The tail of a chain is denoted by the first +event without this flag. The last event in a batch may therefore never +have the `linked` flag set as this would leave a chain +open-ended. Multiple chains or individual events may coexist within a +batch to succeed or fail independently. + +Events within a chain are executed within order, or are rolled back on +error, so that the effect of each event in the chain is visible to the +next, and so that the chain is either visible or invisible as a unit +to subsequent events after the chain. The event that was the first to +break the chain will have a unique error result. Other events in the +chain will have their error result set to `linked_event_failed`. + +```java +TransferBatch transfers = new TransferBatch(10); + +// An individual transfer (successful): +transfers.add(); +transfers.setId(1); +// ... rest of transfer ... +transfers.setFlags(TransferFlags.NONE); + +// A chain of 4 transfers (the last transfer in the chain closes the chain with +// linked=false): +transfers.add(); +transfers.setId(2); // Commit/rollback. +// ... rest of transfer ... +transfers.setFlags(TransferFlags.LINKED); +transfers.add(); +transfers.setId(3); // Commit/rollback. +// ... rest of transfer ... +transfers.setFlags(TransferFlags.LINKED); +transfers.add(); +transfers.setId(2); // Fail with exists +// ... rest of transfer ... +transfers.setFlags(TransferFlags.LINKED); +transfers.add(); +transfers.setId(4); // Fail without committing +// ... rest of transfer ... +transfers.setFlags(TransferFlags.NONE); + +// An individual transfer (successful): +// This should not see any effect from the failed chain above. +transfers.add(); +transfers.setId(2); +// ... rest of transfer ... +transfers.setFlags(TransferFlags.NONE); + +// A chain of 2 transfers (the first transfer fails the chain): +transfers.add(); +transfers.setId(2); +// ... rest of transfer ... +transfers.setFlags(TransferFlags.LINKED); +transfers.add(); +transfers.setId(3); +// ... rest of transfer ... +transfers.setFlags(TransferFlags.NONE); +// A chain of 2 transfers (successful): +transfers.add(); +transfers.setId(3); +// ... rest of transfer ... +transfers.setFlags(TransferFlags.LINKED); +transfers.add(); +transfers.setId(4); +// ... rest of transfer ... +transfers.setFlags(TransferFlags.NONE); + +CreateTransferResultBatch transferResults = client.createTransfers(transfers); +// Results handling omitted. +``` + +## Imported Events + +When the `imported` flag is specified for an account when creating accounts or +a transfer when creating transfers, it allows importing historical events with +a user-defined timestamp. + +The entire batch of events must be set with the flag `imported`. + +It's recommended to submit the whole batch as a `linked` chain of events, ensuring that +if any event fails, none of them are committed, preserving the last timestamp unchanged. +This approach gives the application a chance to correct failed imported events, re-submitting +the batch again with the same user-defined timestamps. + +```java +// External source of time +long historicalTimestamp = 0L; +ResultSet historicalAccounts = null; // Loaded from an external source; +ResultSet historicalTransfers = null ; // Loaded from an external source. + +var BATCH_SIZE = 8189; + +// First, load and import all accounts with their timestamps from the historical source. +AccountBatch accounts = new AccountBatch(BATCH_SIZE); +while (historicalAccounts.next()) { + // Set a unique and strictly increasing timestamp. + historicalTimestamp += 1; + + accounts.add(); + accounts.setId(historicalAccounts.getBytes("id")); + accounts.setLedger(historicalAccounts.getInt("ledger")); + accounts.setCode(historicalAccounts.getInt("code")); + accounts.setTimestamp(historicalTimestamp); + + // Set the account as `imported`. + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + if (accounts.getLength() < BATCH_SIZE) { + accounts.setFlags(AccountFlags.IMPORTED | AccountFlags.LINKED); + } else { + accounts.setFlags(AccountFlags.IMPORTED); + + CreateAccountResultBatch accountResults = client.createAccounts(accounts); + // Results handling omitted. + + // Reset the batch for the next iteration. + accounts.beforeFirst(); + } +} + +if (accounts.getLength() > 0) { + // Send the remaining items. + CreateAccountResultBatch accountResults = client.createAccounts(accounts); + // Results handling omitted. +} + +// Then, load and import all transfers with their timestamps from the historical source. +TransferBatch transfers = new TransferBatch(BATCH_SIZE); +while (historicalTransfers.next()) { + // Set a unique and strictly increasing timestamp. + historicalTimestamp += 1; + + transfers.add(); + transfers.setId(historicalTransfers.getBytes("id")); + transfers.setDebitAccountId(historicalTransfers.getBytes("debit_account_id")); + transfers.setCreditAccountId(historicalTransfers.getBytes("credit_account_id")); + transfers.setAmount(historicalTransfers.getBigDecimal("amount").toBigInteger()); + transfers.setLedger(historicalTransfers.getInt("ledger")); + transfers.setCode(historicalTransfers.getInt("code")); + transfers.setTimestamp(historicalTimestamp); + + // Set the transfer as `imported`. + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + if (transfers.getLength() < BATCH_SIZE) { + transfers.setFlags(TransferFlags.IMPORTED | TransferFlags.LINKED); + } else { + transfers.setFlags(TransferFlags.IMPORTED); + + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + // Results handling omitted. + + // Reset the batch for the next iteration. + transfers.beforeFirst(); + } +} + +if (transfers.getLength() > 0) { + // Send the remaining items. + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + // Results handling omitted. +} + +// Since it is a linked chain, in case of any error the entire batch is rolled back and can be retried +// with the same historical timestamps without regressing the cluster timestamp. +``` + +## Timeouts And Cancellation + +The Client retries indefinitely and doesn't impose any per-request timeout. Cancellation is +provided as a mechanism, and the specific cancellation policy is left to the +application. A Client instance can be closed at any time. On close, all in-flight +requests are canceled and return an error to the caller. Even if an error is returned, +a request might still be processed by the TigerBeetle server. +[Reliable transaction submission](https://docs.tigerbeetle.com/coding/reliable-transaction-submission/) +explains how to make transfers retry-proof using IDs for end-to-end idempotency. diff --git a/ocam/src/clients/java/ci.zig b/ocam/src/clients/java/ci.zig new file mode 100644 index 00000000..0d874b3d --- /dev/null +++ b/ocam/src/clients/java/ci.zig @@ -0,0 +1,175 @@ +const std = @import("std"); +const log = std.log; +const assert = std.debug.assert; + +const Shell = @import("stdx").Shell; +const TmpTigerBeetle = @import("../../testing/tmp_tigerbeetle.zig"); + +pub fn tests(shell: *Shell, gpa: std.mem.Allocator) !void { + assert(shell.file_exists("pom.xml")); + + try shell.exec_zig("build clients:java -Drelease", .{}); + try shell.exec_zig("build -Drelease", .{}); + + try shell.exec_zig("build test:jni", .{}); + // Java's maven doesn't support a separate test command, or a way to add dependency on a + // project (as opposed to a compiled jar file). + // + // For this reason, we do all our testing in one go, imperatively building a client jar and + // installing it into the local maven repository. + try shell.exec("mvn --batch-mode --file pom.xml --quiet formatter:validate", .{}); + try shell.exec("mvn --batch-mode --file pom.xml --quiet install", .{}); + + inline for (.{ "basic", "two-phase", "two-phase-many", "walkthrough" }) |sample| { + log.info("testing sample '{s}'", .{sample}); + + try shell.pushd("./samples/" ++ sample); + defer shell.popd(); + + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + try shell.exec( + \\mvn --batch-mode --file pom.xml --quiet + \\ package exec:java + , .{}); + } +} + +pub fn validate_release_package(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, +}) !void { + _ = shell; + _ = gpa; + _ = options; +} + +pub fn validate_release_sample(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, + tigerbeetle: []const u8, +}) !void { + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + .prebuilt = options.tigerbeetle, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + + try shell.cwd.writeFile(.{ .sub_path = "pom.xml", .data = try shell.fmt( + \\ + \\ 4.0.0 + \\ com.tigerbeetle + \\ samples + \\ 1.0-SNAPSHOT + \\ + \\ + \\ UTF-8 + \\ 11 + \\ 11 + \\ + \\ + \\ + \\ + \\ + \\ org.apache.maven.plugins + \\ maven-compiler-plugin + \\ 3.8.1 + \\ + \\ + \\ -Xlint:all,-options,-path + \\ + \\ + \\ + \\ + \\ + \\ org.codehaus.mojo + \\ exec-maven-plugin + \\ 1.6.0 + \\ + \\ com.tigerbeetle.samples.Main + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ com.tigerbeetle + \\ tigerbeetle-java + \\ {s} + \\ + \\ + \\ + , .{options.release}) }); + + try Shell.copy_path( + shell.cwd, + "src/clients/java/samples/basic/src/main/java/Main.java", + shell.cwd, + "src/main/java/Main.java", + ); + + // According to the docs, java package might not be immediately available: + // + // > Upon release, your component will be published to Central: this typically occurs within 30 + // > minutes, though updates to search can take up to four hours. + // + // + // + // Retry the download for 45 minutes, passing `--update-snapshots` to thwart local negative + // caching. + for (0..9) |_| { + if (shell.exec("mvn package --update-snapshots", .{})) { + break; + } else |_| { + log.warn("waiting for 5 minutes for the {s} version to appear in maven cental", .{ + options.release, + }); + std.time.sleep(5 * std.time.ns_per_min); + } + } else { + shell.exec("mvn package --update-snapshots", .{}) catch |err| { + log.err("package is not available in maven central", .{}); + return err; + }; + } + + try shell.exec("mvn exec:java", .{}); +} + +pub fn release_published_latest(shell: *Shell) ![]const u8 { + const url = "https://central.sonatype.com/api/internal/browse/component/versions?" ++ + "sortField=normalizedVersion&sortDirection=desc&page=0&size=1&" ++ + "filter=namespace%3Acom.tigerbeetle%2Cname%3Atigerbeetle-java"; + + const response_body = try shell.http_get(url, .{}); + + const MavenSearch = struct { + const Component = struct { + namespace: []const u8, + name: []const u8, + version: []const u8, + }; + components: []Component, + }; + + const maven_search_results = try std.json.parseFromSliceLeaky( + MavenSearch, + shell.arena.allocator(), + response_body, + .{ .ignore_unknown_fields = true }, + ); + + assert(maven_search_results.components.len == 1); + + assert(std.mem.eql(u8, maven_search_results.components[0].namespace, "com.tigerbeetle")); + assert(std.mem.eql(u8, maven_search_results.components[0].name, "tigerbeetle-java")); + + return maven_search_results.components[0].version; +} diff --git a/ocam/src/clients/java/docs.zig b/ocam/src/clients/java/docs.zig new file mode 100644 index 00000000..be9c5ad3 --- /dev/null +++ b/ocam/src/clients/java/docs.zig @@ -0,0 +1,126 @@ +const Docs = @import("../docs_types.zig").Docs; + +pub const JavaDocs = Docs{ + .directory = "java", + + .markdown_name = "java", + .extension = "java", + .proper_name = "Java", + + .test_source_path = "src/main/java/", + + .name = "tigerbeetle-java", + .description = + \\The TigerBeetle client for Java. + \\ + \\[![javadoc](https://javadoc.io/badge2/com.tigerbeetle/tigerbeetle-java/javadoc.svg)](https://javadoc.io/doc/com.tigerbeetle/tigerbeetle-java) + \\ + \\[![maven-central](https://img.shields.io/maven-central/v/com.tigerbeetle/tigerbeetle-java)](https://central.sonatype.com/namespace/com.tigerbeetle) + , + + .prerequisites = + \\* Java >= 11 + \\* Maven >= 3.6 (not strictly necessary but it's what our guides assume) + , + + .project_file_name = "pom.xml", + .project_file = + \\ + \\ 4.0.0 + \\ + \\ com.tigerbeetle + \\ samples + \\ 1.0-SNAPSHOT + \\ + \\ + \\ 11 + \\ 11 + \\ + \\ + \\ + \\ + \\ + \\ org.apache.maven.plugins + \\ maven-compiler-plugin + \\ 3.8.1 + \\ + \\ + \\ -Xlint:all,-options,-path + \\ + \\ + \\ + \\ + \\ + \\ org.codehaus.mojo + \\ exec-maven-plugin + \\ 1.6.0 + \\ + \\ com.tigerbeetle.samples.Main + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ com.tigerbeetle + \\ tigerbeetle-java + \\ + \\ 0.0.1-3431 + \\ + \\ + \\ + , + + .test_file_name = "Main", + + .install_commands = "mvn install", + .run_commands = "mvn exec:java", + + .examples = "", + + .client_object_documentation = "", + + .create_accounts_documentation = + \\The 128-bit fields like `id` and `user_data_128` have a few + \\overrides to make it easier to integrate. You can either + \\pass in a long, a pair of longs (least and most + \\significant bits), or a `byte[]`. + \\ + \\There is also a `com.tigerbeetle.UInt128` helper with static + \\methods for converting 128-bit little-endian unsigned integers + \\between instances of `long`, `java.util.UUID`, `java.math.BigInteger` and `byte[]`. + \\ + \\The fields for transfer amounts and account balances are also 128-bit, + \\but they are always represented as a `java.math.BigInteger`. + , + + .account_flags_documentation = + \\To toggle behavior for an account, combine enum values stored in the + \\`AccountFlags` object with bitwise-or: + \\ + \\* `AccountFlags.LINKED` + \\* `AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS` + \\* `AccountFlags.CREDITS_MUST_NOT_EXCEED_CREDITS` + \\* `AccountFlags.HISTORY` + , + + .create_accounts_errors_documentation = "", + + .create_transfers_documentation = "", + + .create_transfers_errors_documentation = "", + + .transfer_flags_documentation = + \\To toggle behavior for an account, combine enum values stored in the + \\`TransferFlags` object with bitwise-or: + \\ + \\* `TransferFlags.NONE` + \\* `TransferFlags.LINKED` + \\* `TransferFlags.PENDING` + \\* `TransferFlags.POST_PENDING_TRANSFER` + \\* `TransferFlags.VOID_PENDING_TRANSFER` + , +}; diff --git a/ocam/src/clients/java/eclipse-formatter.xml b/ocam/src/clients/java/eclipse-formatter.xml new file mode 100644 index 00000000..f6b400f1 --- /dev/null +++ b/ocam/src/clients/java/eclipse-formatter.xml @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ocam/src/clients/java/exclude-pmd.properties b/ocam/src/clients/java/exclude-pmd.properties new file mode 100644 index 00000000..a6ca2aeb --- /dev/null +++ b/ocam/src/clients/java/exclude-pmd.properties @@ -0,0 +1,19 @@ +# PMD Source Code Analyzer Project +# https://pmd.github.io/ +# Exclusion rules + +# DoNotExtendJavaLangError: this class has "System Error" semantics, +# the application must not try to recover from them. +# +# UnnecessaryFullyQualifiedName: it is necessary to full qualify java.lang.AssertionError +# since both classes have the same name, but in different packages +com.tigerbeetle.AssertionError=DoNotExtendJavaLangError,UnnecessaryFullyQualifiedName + +# AvoidCatchingThrowable: we do need to catch and store any Throwable +# during the callback, since we can't handle them from the C client's thread. +# +# UnusedPrivateField: some private fields are used from the JNI side. +com.tigerbeetle.Request=AvoidCatchingThrowable,UnusedPrivateField + +# Avoid empty catch blocks: we need to ignore IOException errors. +com.tigerbeetle.JNILoader$Abi=EmptyCatchBlock diff --git a/ocam/src/clients/java/java_bindings.zig b/ocam/src/clients/java/java_bindings.zig new file mode 100644 index 00000000..fc174547 --- /dev/null +++ b/ocam/src/clients/java/java_bindings.zig @@ -0,0 +1,978 @@ +const std = @import("std"); + +const vsr = @import("vsr"); +const stdx = vsr.stdx; +const tb = vsr.tigerbeetle; +const tb_client = vsr.tb_client; +const exports = tb_client.exports; +const assert = std.debug.assert; + +const TypeMapping = struct { + name: []const u8, + private_fields: []const []const u8 = &.{}, + readonly_fields: []const []const u8 = &.{}, + docs_link: ?[]const u8 = null, + visibility: enum { public, internal } = .public, + constants: []const u8 = "", + + pub fn is_private(comptime self: @This(), name: []const u8) bool { + inline for (self.private_fields) |field| { + if (std.mem.eql(u8, field, name)) { + return true; + } + } else return false; + } + + pub fn is_read_only(comptime self: @This(), name: []const u8) bool { + inline for (self.readonly_fields) |field| { + if (std.mem.eql(u8, field, name)) { + return true; + } + } else return false; + } +}; + +/// Some 128-bit fields are better represented as `java.math.BigInteger`, +/// otherwise they are considered IDs and exposed as an array of bytes. +const big_integer = struct { + const fields = .{ + "credits_posted", + "credits_pending", + "debits_posted", + "debits_pending", + "amount", + }; + + fn contains(comptime field: []const u8) bool { + return comptime blk: for (fields) |value| { + if (std.mem.eql(u8, field, value)) break :blk true; + } else false; + } + + fn contains_any(comptime type_info: anytype) bool { + return comptime blk: for (type_info.fields) |field| { + if (contains(field.name)) break :blk true; + } else false; + } +}; + +const type_mappings = .{ + .{ tb.AccountFlags, TypeMapping{ + .name = "AccountFlags", + .private_fields = &.{"padding"}, + .docs_link = "reference/account#flags", + } }, + .{ tb.TransferFlags, TypeMapping{ + .name = "TransferFlags", + .private_fields = &.{"padding"}, + .docs_link = "reference/transfer#flags", + } }, + .{ tb.AccountFilterFlags, TypeMapping{ + .name = "AccountFilterFlags", + .private_fields = &.{"padding"}, + .visibility = .internal, + } }, + .{ tb.QueryFilterFlags, TypeMapping{ + .name = "QueryFilterFlags", + .private_fields = &.{"padding"}, + .visibility = .internal, + } }, + .{ tb.Account, TypeMapping{ + .name = "AccountBatch", + .private_fields = &.{"reserved"}, + .readonly_fields = &.{ + "debits_pending", + "credits_pending", + "debits_posted", + "credits_posted", + }, + .docs_link = "reference/account#", + } }, + .{ tb.AccountBalance, TypeMapping{ + .name = "AccountBalanceBatch", + .private_fields = &.{"reserved"}, + .readonly_fields = &.{ + "debits_pending", + "credits_pending", + "debits_posted", + "credits_posted", + "timestamp", + }, + .docs_link = "reference/account-balances#", + } }, + .{ + tb.Transfer, TypeMapping{ + .name = "TransferBatch", + .private_fields = &.{"reserved"}, + .readonly_fields = &.{}, + .docs_link = "reference/transfer#", + .constants = + \\ public static final BigInteger AMOUNT_MAX = UInt128.INT_MAX; + \\ + , + }, + }, + .{ tb.CreateAccountStatus, TypeMapping{ + .name = "CreateAccountStatus", + .docs_link = "reference/requests/create_accounts#", + } }, + .{ tb.CreateTransferStatus, TypeMapping{ + .name = "CreateTransferStatus", + .docs_link = "reference/requests/create_transfers#", + } }, + .{ tb.CreateAccountResult, TypeMapping{ + .name = "CreateAccountResultBatch", + .private_fields = &.{"reserved"}, + .readonly_fields = &.{ "timestamp", "status" }, + } }, + .{ tb.CreateTransferResult, TypeMapping{ + .name = "CreateTransferResultBatch", + .private_fields = &.{"reserved"}, + .readonly_fields = &.{ "timestamp", "status" }, + } }, + .{ tb.AccountFilter, TypeMapping{ + .name = "AccountFilterBatch", + .visibility = .internal, + .private_fields = &.{"reserved"}, + } }, + .{ tb.QueryFilter, TypeMapping{ + .name = "QueryFilterBatch", + .visibility = .internal, + .private_fields = &.{"reserved"}, + } }, + .{ exports.tb_init_status, TypeMapping{ + .name = "InitializationStatus", + } }, + .{ exports.tb_client_status, TypeMapping{ + .name = "ClientStatus", + .visibility = .internal, + } }, + .{ exports.tb_packet_status, TypeMapping{ + .name = "PacketStatus", + .visibility = .internal, + } }, +}; + +const auto_generated_code_notice = + \\////////////////////////////////////////////////////////// + \\// This file was auto-generated by java_bindings.zig + \\// Do not manually modify. + \\////////////////////////////////////////////////////////// + \\ +; + +fn java_type( + comptime Type: type, +) []const u8 { + switch (@typeInfo(Type)) { + .@"enum" => return comptime get_mapped_type_name(Type) orelse @compileError( + "Type " ++ @typeName(Type) ++ " not mapped.", + ), + .@"struct" => |info| switch (info.layout) { + .@"packed" => return comptime java_type(std.meta.Int(.unsigned, @bitSizeOf(Type))), + else => return comptime get_mapped_type_name(Type) orelse @compileError( + "Type " ++ @typeName(Type) ++ " not mapped.", + ), + }, + .int => |info| { + // For better API ergonomy, + // we expose 16-bit unsigned integers in Java as "int" instead of "short". + // Even though, the backing fields are always stored as "short". + assert(info.signedness == .unsigned); + return switch (info.bits) { + 1 => "byte", + 8 => "byte", + 16, 32 => "int", + 64 => "long", + else => @compileError("invalid int type"), + }; + }, + else => @compileError("Unhandled type: " ++ @typeName(Type)), + } +} + +fn get_mapped_type_name(comptime Type: type) ?[]const u8 { + inline for (type_mappings) |type_mapping| { + if (Type == type_mapping[0]) { + return type_mapping[1].name; + } + } else return null; +} + +fn emit_enum( + buffer: *std.ArrayList(u8), + comptime Type: type, + comptime mapping: TypeMapping, + comptime int_type: []const u8, +) !void { + try buffer.writer().print( + \\{[notice]s} + \\package com.tigerbeetle; + \\ + \\{[visibility]s}enum {[name]s} {{ + \\ + , .{ + .visibility = if (mapping.visibility == .internal) "" else "public ", + .notice = auto_generated_code_notice, + .name = mapping.name, + }); + + const fields = comptime fields: { + const EnumField = std.builtin.Type.EnumField; + const type_info = @typeInfo(Type).@"enum"; + var fields: []const EnumField = &[_]EnumField{}; + for (type_info.fields) |field| { + if (mapping.is_private(field.name)) continue; + if (std.mem.startsWith(u8, field.name, "deprecated_")) continue; + fields = fields ++ [_]EnumField{field}; + } + break :fields fields; + }; + + inline for (fields, 0..) |field, i| { + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ + \\ /** + \\ * @see {[field_name]s} + \\ */ + \\ + , .{ + .docs_link = docs_link, + .field_name = field.name, + }); + } + + const int_value = @intFromEnum(@field(Type, field.name)); + try buffer.writer().print( + \\ {[enum_name]s}(({[int_type]s}) {[value]s}){[separator]c} + \\ + , .{ + .enum_name = stdx.to_case(field.name, .PascalCase), + .int_type = int_type, + .value = if (int_value == std.math.maxInt(@TypeOf(int_value))) + std.fmt.comptimePrint("0x{X}", .{int_value}) + else + std.fmt.comptimePrint("{}", .{int_value}), + .separator = if (i == fields.len - 1) ';' else ',', + }); + } + + try buffer.writer().print( + \\ + \\ public final {[int_type]s} value; + \\ + \\ {[name]s}({[int_type]s} value) {{ + \\ this.value = value; + \\ }} + \\ + \\ public static {[name]s} fromValue({[int_type]s} value) {{ + \\ switch (value) {{ + \\ + , .{ + .int_type = int_type, + .name = mapping.name, + }); + + inline for (fields) |field| { + const int_value = @intFromEnum(@field(Type, field.name)); + try buffer.writer().print( + \\ case {[value]s}: return {[enum_name]s}; + \\ + , .{ + .enum_name = stdx.to_case(field.name, .PascalCase), + .value = if (int_value == std.math.maxInt(@TypeOf(int_value))) + std.fmt.comptimePrint("0x{X}", .{int_value}) + else + std.fmt.comptimePrint("{}", .{int_value}), + }); + } + + try buffer.writer().print( + \\ default: throw new IllegalArgumentException( + \\ String.format("Invalid {[name]s} value=%d", value)); + \\ }} + \\ }} + \\}} + \\ + \\ + , .{ + .name = mapping.name, + }); +} + +fn emit_packed_enum( + buffer: *std.ArrayList(u8), + comptime type_info: anytype, + comptime mapping: TypeMapping, + comptime int_type: []const u8, +) !void { + try buffer.writer().print( + \\{[notice]s} + \\package com.tigerbeetle; + \\ + \\{[visibility]s}interface {[name]s} {{ + \\ {[int_type]s} NONE = ({[int_type]s}) 0; + \\ + , .{ + .visibility = if (mapping.visibility == .internal) "" else "public ", + .notice = auto_generated_code_notice, + .name = mapping.name, + .int_type = int_type, + }); + + inline for (type_info.fields, 0..) |field, i| { + if (comptime mapping.is_private(field.name)) continue; + + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ + \\ /** + \\ * @see {[field_name]s} + \\ */ + \\ + , .{ + .docs_link = docs_link, + .field_name = field.name, + }); + } + + try buffer.writer().print( + \\ {[int_type]s} {[enum_name]s} = ({[int_type]s}) (1 << {[value]d}); + \\ + , .{ + .int_type = int_type, + .enum_name = stdx.to_case(field.name, .UPPER_CASE), + .value = i, + }); + } + + try buffer.writer().print("\n", .{}); + + inline for (type_info.fields) |field| { + if (comptime mapping.is_private(field.name)) continue; + + try buffer.writer().print( + \\ static boolean has{[flag_name]s}(final {[int_type]s} flags) {{ + \\ return (flags & {[enum_name]s}) == {[enum_name]s}; + \\ }} + \\ + \\ + , .{ + .flag_name = stdx.to_case(field.name, .PascalCase), + .int_type = int_type, + .enum_name = stdx.to_case(field.name, .UPPER_CASE), + }); + } + + try buffer.writer().print( + \\}} + \\ + , .{}); +} + +fn batch_type(comptime Type: type) []const u8 { + switch (@typeInfo(Type)) { + .int => |info| { + assert(info.signedness == .unsigned); + switch (info.bits) { + 16 => return "UInt16", + 32 => return "UInt32", + 64 => return "UInt64", + else => {}, + } + }, + .@"struct" => |info| switch (info.layout) { + .@"packed" => return batch_type(std.meta.Int(.unsigned, @bitSizeOf(Type))), + else => {}, + }, + .@"enum" => return batch_type(std.meta.Int(.unsigned, @bitSizeOf(Type))), + else => {}, + } + + @compileError("Unhandled type: " ++ @typeName(Type)); +} + +fn emit_batch( + buffer: *std.ArrayList(u8), + comptime type_info: anytype, + comptime mapping: TypeMapping, + comptime size: usize, +) !void { + try buffer.writer().print( + \\{[notice]s} + \\package com.tigerbeetle; + \\ + \\import java.nio.ByteBuffer; + \\{[big_integer_import]s} + \\ + \\{[visibility]s}final class {[name]s} extends Batch {{ + \\ + \\{[constants]s} + \\ interface Struct {{ + \\ int SIZE = {[size]d}; + \\ + \\ + , .{ + .visibility = if (mapping.visibility == .internal) "" else "public ", + .notice = auto_generated_code_notice, + .name = mapping.name, + .size = size, + .big_integer_import = if (big_integer.contains_any(type_info)) + "import java.math.BigInteger;" + else + "", + .constants = mapping.constants, + }); + + // Fields offset: + var offset: usize = 0; + inline for (type_info.fields) |field| { + try buffer.writer().print( + \\ int {[field_name]s} = {[offset]d}; + \\ + , .{ + .field_name = stdx.to_case(field.name, .PascalCase), + .offset = offset, + }); + + offset += @sizeOf(field.type); + } + + // Constructors: + try buffer.writer().print( + \\ }} + \\ + \\ /** + \\ * Creates an empty batch with the desired maximum capacity. + \\ *

+ \\ * Once created, an instance cannot be resized, however it may contain any number of elements + \\ * between zero and its {{@link #getCapacity capacity}}. + \\ * + \\ * @param capacity the maximum capacity. + \\ * @throws IllegalArgumentException if capacity is negative. + \\ */ + \\ public {[name]s}(final int capacity) {{ + \\ super(capacity, Struct.SIZE); + \\ }} + \\ + \\ {[name]s}(final ByteBuffer buffer) {{ + \\ super(buffer, Struct.SIZE); + \\ }} + \\ + \\ + , .{ + .name = mapping.name, + }); + + // Properties: + inline for (type_info.fields) |field| { + if (field.type == u128) { + try emit_u128_batch_accessors(buffer, mapping, field); + } else { + try emit_batch_accessors(buffer, mapping, field); + } + } + + try buffer.writer().print( + \\}} + \\ + \\ + , .{}); +} + +fn emit_batch_accessors( + buffer: *std.ArrayList(u8), + comptime mapping: TypeMapping, + comptime field: anytype, +) !void { + comptime assert(field.type != u128); + const is_private = comptime mapping.is_private(field.name); + const is_read_only = comptime mapping.is_read_only(field.name); + + // Get: + try buffer.writer().print( + \\ /** + \\ * @throws IllegalStateException if not at a {{@link #isValidPosition valid position}}. + \\ + , .{}); + + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ * @see {[field_name]s} + \\ */ + \\ + , .{ + .docs_link = docs_link, + .field_name = field.name, + }); + } else { + try buffer.writer().print( + \\ */ + \\ + , .{}); + } + + if (@typeInfo(field.type) == .array) { + try buffer.writer().print( + \\ {[visibility]s}byte[] get{[property]s}() {{ + \\ return getArray(at(Struct.{[property]s}), {[array_len]d}); + \\ }} + \\ + \\ + , .{ + .visibility = if (is_private) "" else "public ", + .property = stdx.to_case(field.name, .PascalCase), + .array_len = @typeInfo(field.type).array.len, + }); + } else { + try buffer.writer().print( + \\ {[visibility]s}{[java_type]s} get{[property]s}() {{ + \\ final var value = get{[batch_type]s}(at(Struct.{[property]s})); + \\ return {[return_expression]s}; + \\ }} + \\ + \\ + , .{ + .visibility = if (is_private) "" else "public ", + .java_type = java_type(field.type), + .property = stdx.to_case(field.name, .PascalCase), + .batch_type = batch_type(field.type), + .return_expression = comptime if (@typeInfo(field.type) == .@"enum") + get_mapped_type_name(field.type).? ++ ".fromValue(value)" + else + "value", + }); + } + + // Set: + try buffer.writer().print( + \\ /** + \\ * @param {[param_name]s} + \\ * @throws IllegalStateException if not at a {{@link #isValidPosition valid position}}. + \\ * @throws IllegalStateException if a {{@link #isReadOnly() read-only}} batch. + \\ + , .{ + .param_name = stdx.to_case(field.name, .camelCase), + }); + + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ * @see {[field_name]s} + \\ */ + \\ + , .{ + .docs_link = docs_link, + .field_name = field.name, + }); + } else { + try buffer.writer().print( + \\ */ + \\ + , .{}); + } + + if (@typeInfo(field.type) == .array) { + try buffer.writer().print( + \\ {[visibility]s}void set{[property]s}(byte[] {[param_name]s}) {{ + \\ if ({[param_name]s} == null) + \\ {[param_name]s} = new byte[{[array_len]d}]; + \\ if ({[param_name]s}.length != {[array_len]d}) + \\ throw new IllegalArgumentException("Reserved must be {[array_len]d} bytes long"); + \\ putArray(at(Struct.{[property]s}), {[param_name]s}); + \\ }} + \\ + \\ + , .{ + .property = stdx.to_case(field.name, .PascalCase), + .param_name = stdx.to_case(field.name, .camelCase), + .visibility = if (is_private or is_read_only) "" else "public ", + .array_len = @typeInfo(field.type).array.len, + }); + } else { + try buffer.writer().print( + \\ {[visibility]s}void set{[property]s}(final {[java_type]s} {[param_name]s}) {{ + \\ put{[batch_type]s}(at(Struct.{[property]s}), {[param_name]s}{[value_expression]s}); + \\ }} + \\ + \\ + , .{ + .property = stdx.to_case(field.name, .PascalCase), + .param_name = stdx.to_case(field.name, .camelCase), + .visibility = if (is_private or is_read_only) "" else "public ", + .batch_type = batch_type(field.type), + .java_type = java_type(field.type), + .value_expression = if (comptime @typeInfo(field.type) == .@"enum") + ".value" + else + "", + }); + } +} + +// We offer multiple APIs for dealing with UInt128 in Java: +// - A byte array, heap-allocated, for ids and user_data; +// - A BigInteger, heap-allocated, for balances and amounts; +// - Two 64-bit integers (long), stack-allocated, for both cases; +fn emit_u128_batch_accessors( + buffer: *std.ArrayList(u8), + comptime mapping: TypeMapping, + comptime field: anytype, +) !void { + comptime assert(field.type == u128); + const is_private = comptime mapping.is_private(field.name); + const is_read_only = comptime mapping.is_read_only(field.name); + + if (big_integer.contains(field.name)) { + // Get BigInteger: + try buffer.writer().print( + \\ /** + \\ * @return a {{@link java.math.BigInteger}} representing the 128-bit value. + \\ * @throws IllegalStateException if not at a {{@link #isValidPosition valid position}}. + \\ + , .{}); + + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ * @see {[field_name]s} + \\ */ + \\ + , .{ + .docs_link = docs_link, + .field_name = field.name, + }); + } else { + try buffer.writer().print( + \\ */ + \\ + , .{}); + } + + try buffer.writer().print( + \\ {[visibility]s}BigInteger get{[property]s}() {{ + \\ final var index = at(Struct.{[property]s}); + \\ return UInt128.asBigInteger( + \\ getUInt128(index, UInt128.LeastSignificant), + \\ getUInt128(index, UInt128.MostSignificant)); + \\ }} + \\ + \\ + , .{ + .visibility = if (is_private) "" else "public ", + .property = stdx.to_case(field.name, .PascalCase), + }); + } else { + // Get array: + try buffer.writer().print( + \\ /** + \\ * @return an array of 16 bytes representing the 128-bit value. + \\ * @throws IllegalStateException if not at a {{@link #isValidPosition valid position}}. + \\ + , .{}); + + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ * @see {[field_name]s} + \\ */ + \\ + , .{ + .docs_link = docs_link, + .field_name = field.name, + }); + } else { + try buffer.writer().print( + \\ */ + \\ + , .{}); + } + + try buffer.writer().print( + \\ {[visibility]s}byte[] get{[property]s}() {{ + \\ return getUInt128(at(Struct.{[property]s})); + \\ }} + \\ + \\ + , .{ + .visibility = if (is_private) "" else "public ", + .property = stdx.to_case(field.name, .PascalCase), + }); + } + + // Get long: + try buffer.writer().print( + \\ /** + \\ * @param part a {{@link UInt128}} enum indicating which part of the 128-bit value + \\ is to be retrieved. + \\ * @return a {{@code long}} representing the first 8 bytes of the 128-bit value if + \\ * {{@link UInt128#LeastSignificant}} is informed, or the last 8 bytes if + \\ * {{@link UInt128#MostSignificant}}. + \\ * @throws IllegalStateException if not at a {{@link #isValidPosition valid position}}. + \\ + , .{}); + + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ * @see {[field_name]s} + \\ */ + \\ + , .{ + .docs_link = docs_link, + .field_name = field.name, + }); + } else { + try buffer.writer().print( + \\ */ + \\ + , .{}); + } + + try buffer.writer().print( + \\ {[visibility]s}long get{[property]s}(final UInt128 part) {{ + \\ return getUInt128(at(Struct.{[property]s}), part); + \\ }} + \\ + \\ + , .{ + .visibility = if (is_private) "" else "public ", + .property = stdx.to_case(field.name, .PascalCase), + }); + + if (big_integer.contains(field.name)) { + // Set BigInteger: + try buffer.writer().print( + \\ /** + \\ * @param {[param_name]s} a {{@link java.math.BigInteger}} representing the 128-bit value. + \\ * @throws IllegalStateException if not at a {{@link #isValidPosition valid position}}. + \\ * @throws IllegalStateException if a {{@link #isReadOnly() read-only}} batch. + \\ + , .{ + .param_name = stdx.to_case(field.name, .camelCase), + }); + + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ * @see {[field_name]s} + \\ */ + \\ + , .{ + .docs_link = docs_link, + .field_name = field.name, + }); + } else { + try buffer.writer().print( + \\ */ + \\ + , .{}); + } + + try buffer.writer().print( + \\ {[visibility]s}void set{[property]s}(final BigInteger {[param_name]s}) {{ + \\ putUInt128(at(Struct.{[property]s}), UInt128.asBytes({[param_name]s})); + \\ }} + \\ + \\ + , .{ + .visibility = if (is_private or is_read_only) "" else "public ", + .property = stdx.to_case(field.name, .PascalCase), + .param_name = stdx.to_case(field.name, .camelCase), + }); + } else { + // Set array: + try buffer.writer().print( + \\ /** + \\ * @param {[param_name]s} an array of 16 bytes representing the 128-bit value. + \\ * @throws IllegalArgumentException if {{@code {[param_name]s}}} is not 16 bytes long. + \\ * @throws IllegalStateException if not at a {{@link #isValidPosition valid position}}. + \\ * @throws IllegalStateException if a {{@link #isReadOnly() read-only}} batch. + \\ + , .{ + .param_name = stdx.to_case(field.name, .camelCase), + }); + + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ * @see {[field_name]s} + \\ */ + \\ + , .{ + .docs_link = docs_link, + .field_name = field.name, + }); + } else { + try buffer.writer().print( + \\ */ + \\ + , .{}); + } + + try buffer.writer().print( + \\ {[visibility]s}void set{[property]s}(final byte[] {[param_name]s}) {{ + \\ putUInt128(at(Struct.{[property]s}), {[param_name]s}); + \\ }} + \\ + \\ + , .{ + .visibility = if (is_private or is_read_only) "" else "public ", + .property = stdx.to_case(field.name, .PascalCase), + .param_name = stdx.to_case(field.name, .camelCase), + }); + } + + // Set long: + try buffer.writer().print( + \\ /** + \\ * @param leastSignificant a {{@code long}} representing the first 8 bytes of the 128-bit value. + \\ * @param mostSignificant a {{@code long}} representing the last 8 bytes of the 128-bit value. + \\ * @throws IllegalStateException if not at a {{@link #isValidPosition valid position}}. + \\ * @throws IllegalStateException if a {{@link #isReadOnly() read-only}} batch. + \\ + , .{}); + + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ * @see {[field_name]s} + \\ */ + \\ + , .{ + .docs_link = docs_link, + .field_name = field.name, + }); + } else { + try buffer.writer().print( + \\ */ + \\ + , .{}); + } + + try buffer.writer().print( + \\ {[visibility]s}void set{[property]s}(final long leastSignificant, final long mostSignificant) {{ + \\ putUInt128(at(Struct.{[property]s}), leastSignificant, mostSignificant); + \\ }} + \\ + \\ + , .{ + .visibility = if (is_private or is_read_only) "" else "public ", + .property = stdx.to_case(field.name, .PascalCase), + }); + + // Set long without most significant bits + try buffer.writer().print( + \\ /** + \\ * @param leastSignificant a {{@code long}} representing the first 8 bytes of the 128-bit value. + \\ * @throws IllegalStateException if not at a {{@link #isValidPosition valid position}}. + \\ * @throws IllegalStateException if a {{@link #isReadOnly() read-only}} batch. + \\ + , .{}); + + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ * @see {[field_name]s} + \\ */ + \\ + , .{ + .docs_link = docs_link, + .field_name = field.name, + }); + } else { + try buffer.writer().print( + \\ */ + \\ + , .{}); + } + + try buffer.writer().print( + \\ {[visibility]s}void set{[property]s}(final long leastSignificant) {{ + \\ putUInt128(at(Struct.{[property]s}), leastSignificant, 0); + \\ }} + \\ + \\ + , .{ + .visibility = if (is_private or is_read_only) "" else "public ", + .property = stdx.to_case(field.name, .PascalCase), + }); +} + +pub fn generate_bindings( + comptime ZigType: type, + comptime mapping: TypeMapping, + buffer: *std.ArrayList(u8), +) !void { + @setEvalBranchQuota(100_000); + + switch (@typeInfo(ZigType)) { + .@"struct" => |info| switch (info.layout) { + .auto => @compileError( + "Only packed or extern structs are supported: " ++ @typeName(ZigType), + ), + .@"packed" => try emit_packed_enum( + buffer, + info, + mapping, + comptime java_type(std.meta.Int(.unsigned, @bitSizeOf(ZigType))), + ), + .@"extern" => try emit_batch( + buffer, + info, + mapping, + @sizeOf(ZigType), + ), + }, + .@"enum" => try emit_enum( + buffer, + ZigType, + mapping, + comptime java_type(std.meta.Int(.unsigned, @bitSizeOf(ZigType))), + ), + else => @compileError("Type cannot be represented: " ++ @typeName(ZigType)), + } +} + +pub fn main() !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + + const allocator = arena.allocator(); + + var args = try std.process.argsWithAllocator(allocator); + defer args.deinit(); + assert(args.skip()); + const target_dir_path = args.next().?; + assert(args.next() == null); + + var target_dir = try std.fs.cwd().openDir(target_dir_path, .{}); + defer target_dir.close(); + + // Emit Java declarations. + inline for (type_mappings) |type_mapping| { + const ZigType = type_mapping[0]; + const mapping = type_mapping[1]; + + var buffer = std.ArrayList(u8).init(allocator); + try generate_bindings(ZigType, mapping, &buffer); + + try target_dir.writeFile(.{ + .sub_path = mapping.name ++ ".java", + .data = buffer.items, + }); + } + + { + var buffer = std.ArrayList(u8).init(allocator); + try buffer.writer().print( + \\package com.tigerbeetle; + \\ + \\interface TBClient {{ + \\ int SIZE = {}; + \\ int ALIGNMENT = {}; + \\}} + \\ + , .{ + @sizeOf(exports.tb_client_t), + @alignOf(exports.tb_client_t), + }); + try target_dir.writeFile(.{ + .sub_path = "TBClient.java", + .data = buffer.items, + }); + } +} diff --git a/ocam/src/clients/java/pom.xml b/ocam/src/clients/java/pom.xml new file mode 100644 index 00000000..897d28ba --- /dev/null +++ b/ocam/src/clients/java/pom.xml @@ -0,0 +1,370 @@ + + + + 4.0.0 + + com.tigerbeetle + tigerbeetle-java + 0.0.1-SNAPSHOT + TigerBeetle Java client + The distributed financial accounting database designed for mission critical safety and performance. + + + The TigerBeetle contributors + hi@tigerbeetle.com + + + + TigerBeetle, Inc. + https://www.tigerbeetle.com + + https://www.tigerbeetle.com + + https://github.com/tigerbeetle/tigerbeetle + + + + + Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + UTF-8 + 11 + 11 + + 95% + 85% + + + + + junit + junit + [4.13.1,) + test + + + + + + Windows + + + Windows + + + + .exe + .bat + + + + unix + + + unix + + + + + .sh + + + + + + + + + maven-clean-plugin + 3.1.0 + + + maven-resources-plugin + 3.0.2 + + + maven-compiler-plugin + 3.8.0 + + + maven-surefire-plugin + 2.22.1 + + ${project.basedir} + + + + maven-jar-plugin + 3.0.2 + + + + **/*.o + **/*.obj + **/*.pdb + **/win*/*.lib + + + + + maven-install-plugin + 2.5.2 + + + maven-deploy-plugin + 2.8.2 + + + maven-site-plugin + 3.7.1 + + + maven-project-info-reports-plugin + 3.0.0 + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.4.1 + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + org.apache.maven.plugins + maven-gpg-plugin + 3.0.1 + + + org.jacoco + jacoco-maven-plugin + 0.8.8 + + + + com/tigerbeetle/InitializationException.class + com/tigerbeetle/RequestException.class + com/tigerbeetle/TooMuchDataException.class + com/tigerbeetle/ClientClosedException.class + com/tigerbeetle/ClientEvictedException.class + com/tigerbeetle/ClientReleaseException.class + com/tigerbeetle/ClientReleaseException$Reason.class + com/tigerbeetle/AssertionError.class + + + com/tigerbeetle/JNILoader$OS.class + com/tigerbeetle/JNILoader$Arch.class + com/tigerbeetle/JNILoader$Abi.class + + + com/tigerbeetle/PacketStatus.class + com/tigerbeetle/PacketAcquireStatus.class + com/tigerbeetle/InitializationStatus.class + com/tigerbeetle/ClientStatus.class + com/tigerbeetle/TransferFlags.class + com/tigerbeetle/AccountFilterFlags.class + com/tigerbeetle/QueryFilterFlags.class + + + + + net.revelc.code.formatter + formatter-maven-plugin + 2.19.0 + + LF + ${project.basedir}/eclipse-formatter.xml + + + com/tigerbeetle/AccountBatch.java + com/tigerbeetle/AccountFlags.java + com/tigerbeetle/CreateAccountStatus.java + com/tigerbeetle/CreateAccountResultBatch.java + com/tigerbeetle/CreateTransferStatus.java + com/tigerbeetle/CreateTransferResultBatch.java + com/tigerbeetle/InitializationStatus.java + com/tigerbeetle/ClientStatus.java + com/tigerbeetle/PacketStatus.java + com/tigerbeetle/TBClient.java + com/tigerbeetle/TransferBatch.java + com/tigerbeetle/TransferFlags.java + com/tigerbeetle/AccountFilterFlags.java + com/tigerbeetle/AccountFilterBatch.java + com/tigerbeetle/AccountBalanceBatch.java + com/tigerbeetle/QueryFilterFlags.java + com/tigerbeetle/QueryFilterBatch.java + + + + + org.apache.maven.plugins + maven-pmd-plugin + 3.19.0 + + false + + + + + + + + + net.revelc.code.formatter + formatter-maven-plugin + + + format + validate + + format + + + + + format-validate + + validate + + + + + + + + org.jacoco + jacoco-maven-plugin + + + jacoco-initialize + + prepare-agent + + + + jacoco-check + test + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + ${jacoco.unit-tests.limit.instruction-ratio} + + + BRANCH + COVEREDRATIO + ${jacoco.unit-tests.limit.branch-ratio} + + + + + + + + + jacoco-report + test + + report + + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + deploy + + sign + + + + --pinentry-mode + loopback + + + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + attach-javadocs + + jar + + + + + + + + org.apache.maven.plugins + maven-pmd-plugin + + + pdm-check + test + + check + + + true + ${project.basedir}/exclude-pmd.properties + + + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.8.0 + true + + central + true + https://central.sonatype.com + + + + + diff --git a/ocam/src/clients/java/samples/basic/README.md b/ocam/src/clients/java/samples/basic/README.md new file mode 100644 index 00000000..aee4bc3f --- /dev/null +++ b/ocam/src/clients/java/samples/basic/README.md @@ -0,0 +1,62 @@ + +# Basic Java Sample + +Code for this sample is in [./src/main/java/Main.java](./src/main/java/Main.java). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Java >= 11 +* Maven >= 3.6 (not strictly necessary but it's what our guides assume) + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/java/samples/basic`. + +Then, install the TigerBeetle client: + +```console +mvn install +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +mvn exec:java +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create a transfer + +Then it transfers `10` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate account balances + +Then it fetches both accounts, checks they both exist, and +checks that **account `1`** has: + * `debits_posted = 10` + * and `credits_posted = 0` + +And that **account `2`** has: + * `debits_posted= 0` + * and `credits_posted = 10` diff --git a/ocam/src/clients/java/samples/basic/pom.xml b/ocam/src/clients/java/samples/basic/pom.xml new file mode 100644 index 00000000..8df99bc6 --- /dev/null +++ b/ocam/src/clients/java/samples/basic/pom.xml @@ -0,0 +1,45 @@ + + 4.0.0 + + com.tigerbeetle + samples + 1.0-SNAPSHOT + + + 11 + 11 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all,-options,-path + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + com.tigerbeetle.samples.Main + + + + + + + + com.tigerbeetle + tigerbeetle-java + 0.0.1-SNAPSHOT + + + diff --git a/ocam/src/clients/java/samples/basic/src/main/java/Main.java b/ocam/src/clients/java/samples/basic/src/main/java/Main.java new file mode 100644 index 00000000..493ab335 --- /dev/null +++ b/ocam/src/clients/java/samples/basic/src/main/java/Main.java @@ -0,0 +1,86 @@ +package com.tigerbeetle.samples; + +import java.util.Arrays; + +import java.math.BigInteger; + +import com.tigerbeetle.*; +import static com.tigerbeetle.AssertionError.assertTrue; + +public final class Main { + public static void main(String[] args) throws Exception { + String replicaAddress = System.getenv("TB_ADDRESS"); + + byte[] clusterID = UInt128.asBytes(0); + String[] replicaAddresses = new String[] {replicaAddress == null ? "3000" : replicaAddress}; + try (var client = new Client(clusterID, replicaAddresses)) { + // Create two accounts + AccountBatch accounts = new AccountBatch(2); + accounts.add(); + accounts.setId(1); + accounts.setLedger(1); + accounts.setCode(1); + + accounts.add(); + accounts.setId(2); + accounts.setLedger(1); + accounts.setCode(1); + + CreateAccountResultBatch accountResults = client.createAccounts(accounts); + while (accountResults.next()) { + switch (accountResults.getStatus()) { + case Created: + break; + default: + throw new Exception(String.format("Error creating account %d: %s\n", + accountResults.getPosition(), + accountResults.getStatus())); + } + } + + TransferBatch transfers = new TransferBatch(1); + transfers.add(); + transfers.setId(1); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(10); + + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + assertTrue(transferResults.getLength() == transfers.getLength()); + + while (transferResults.next()) { + switch (transferResults.getStatus()) { + case Created: + break; + default: + throw new Exception(String.format("Error creating transfer %d: %s\n", + transferResults.getPosition(), + transferResults.getStatus())); + } + } + + IdBatch ids = new IdBatch(2); + ids.add(1); + ids.add(2); + accounts = client.lookupAccounts(ids); + assertTrue(accounts.getLength() == 2); + + while (accounts.next()) { + if (accounts.getId(UInt128.LeastSignificant) == 1 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 10); + assertTrue(accounts.getCreditsPosted().intValueExact() == 0); + } else if (accounts.getId(UInt128.LeastSignificant) == 2 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 0); + assertTrue(accounts.getCreditsPosted().intValueExact() == 10); + } else { + throw new Exception(String.format("Unexpected account: %s\n", + UInt128.asBigInteger(accounts.getId()).toString())); + } + } + } + } +} diff --git a/ocam/src/clients/java/samples/two-phase-many/README.md b/ocam/src/clients/java/samples/two-phase-many/README.md new file mode 100644 index 00000000..48e303e2 --- /dev/null +++ b/ocam/src/clients/java/samples/two-phase-many/README.md @@ -0,0 +1,92 @@ + +# Many Two-Phase Transfers Java Sample + +Code for this sample is in [./src/main/java/Main.java](./src/main/java/Main.java). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Java >= 11 +* Maven >= 3.6 (not strictly necessary but it's what our guides assume) + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/java/samples/two-phase-many`. + +Then, install the TigerBeetle client: + +```console +mvn install +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +mvn exec:java +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfers + +Then it begins 5 pending transfers of amounts `100` to +`500`, incrementing by `100` for each transfer. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 1500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 1500` + +(This is because a pending transfer only affects **pending** +credits and debits on accounts, not **posted** credits and +debits.) + +## 4. Post and void alternating transfers + +Then it alternatively posts and voids each transfer, +checking account balances after each transfer. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +solely *posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 900` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 900` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/java/samples/two-phase-many/pom.xml b/ocam/src/clients/java/samples/two-phase-many/pom.xml new file mode 100644 index 00000000..8df99bc6 --- /dev/null +++ b/ocam/src/clients/java/samples/two-phase-many/pom.xml @@ -0,0 +1,45 @@ + + 4.0.0 + + com.tigerbeetle + samples + 1.0-SNAPSHOT + + + 11 + 11 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all,-options,-path + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + com.tigerbeetle.samples.Main + + + + + + + + com.tigerbeetle + tigerbeetle-java + 0.0.1-SNAPSHOT + + + diff --git a/ocam/src/clients/java/samples/two-phase-many/src/main/java/Main.java b/ocam/src/clients/java/samples/two-phase-many/src/main/java/Main.java new file mode 100644 index 00000000..cb23652d --- /dev/null +++ b/ocam/src/clients/java/samples/two-phase-many/src/main/java/Main.java @@ -0,0 +1,373 @@ +package com.tigerbeetle.samples; + +import java.util.Arrays; +import java.math.BigInteger; + +import com.tigerbeetle.*; +import static com.tigerbeetle.AssertionError.assertTrue; + +public final class Main { + public static void main(String[] args) throws Exception { + String replicaAddress = System.getenv("TB_ADDRESS"); + + byte[] clusterID = UInt128.asBytes(0); + String[] replicaAddresses = new String[] {replicaAddress == null ? "3000" : replicaAddress}; + try (var client = new Client(clusterID, replicaAddresses)) { + // Create two accounts + AccountBatch accounts = new AccountBatch(2); + accounts.add(); + accounts.setId(1); + accounts.setLedger(1); + accounts.setCode(1); + + accounts.add(); + accounts.setId(2); + accounts.setLedger(1); + accounts.setCode(1); + + CreateAccountResultBatch accountResults = client.createAccounts(accounts); + while (accountResults.next()) { + switch (accountResults.getStatus()) { + case Created: + break; + default: + throw new Exception(String.format("Error creating account %d: %s\n", + accountResults.getPosition(), + accountResults.getStatus())); + } + } + + // Start five pending transfer. + TransferBatch transfers = new TransferBatch(5); + transfers.add(); + transfers.setId(1); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(100); + transfers.setFlags(TransferFlags.PENDING); + + transfers.add(); + transfers.setId(2); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(200); + transfers.setFlags(TransferFlags.PENDING); + + transfers.add(); + transfers.setId(3); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(300); + transfers.setFlags(TransferFlags.PENDING); + + transfers.add(); + transfers.setId(4); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(400); + transfers.setFlags(TransferFlags.PENDING); + + transfers.add(); + transfers.setId(5); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(500); + transfers.setFlags(TransferFlags.PENDING); + + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + assertTrue(transferResults.getLength() == transfers.getLength()); + + while (transferResults.next()) { + switch (transferResults.getStatus()) { + case Created: + break; + default: + throw new Exception(String.format("Error creating transfer %d: %s\n", + transferResults.getPosition(), + transferResults.getStatus())); + } + } + + // Validate accounts pending and posted debits/credits + // before finishing the two-phase transfer. + IdBatch ids = new IdBatch(2); + ids.add(1); + ids.add(2); + accounts = client.lookupAccounts(ids); + assertTrue(accounts.getLength() == 2); + + while (accounts.next()) { + if (accounts.getId(UInt128.LeastSignificant) == 1 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 0); + assertTrue(accounts.getCreditsPosted().intValueExact() == 0); + assertTrue(accounts.getDebitsPending().intValueExact() == 1500); + assertTrue(accounts.getCreditsPending().intValueExact() == 0); + } else if (Arrays.equals(accounts.getId(), UInt128.asBytes(2))) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 0); + assertTrue(accounts.getCreditsPosted().intValueExact() == 0); + assertTrue(accounts.getDebitsPending().intValueExact() == 0); + assertTrue(accounts.getCreditsPending().intValueExact() == 1500); + } else { + throw new Exception(String.format("Unexpected account: %s\n", + UInt128.asBigInteger(accounts.getId()).toString())); + } + } + + // Create a 6th transfer posting the 1st transfer. + transfers = new TransferBatch(1); + transfers.add(); + transfers.setId(6); + transfers.setPendingId(1); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(100); + transfers.setFlags(TransferFlags.POST_PENDING_TRANSFER); + + transferResults = client.createTransfers(transfers); + assertTrue(transferResults.getLength() == transfers.getLength()); + + while (transferResults.next()) { + switch (transferResults.getStatus()) { + case Created: + break; + default: + throw new Exception(String.format("Error creating transfer %d: %s\n", + transferResults.getPosition(), + transferResults.getStatus())); + } + } + + // Validate account balances after posting 1st pending transfer. + accounts = client.lookupAccounts(ids); + assertTrue(accounts.getLength() == 2); + + while (accounts.next()) { + if (accounts.getId(UInt128.LeastSignificant) == 1 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 100); + assertTrue(accounts.getCreditsPosted().intValueExact() == 0); + assertTrue(accounts.getDebitsPending().intValueExact() == 1400); + assertTrue(accounts.getCreditsPending().intValueExact() == 0); + } else if (accounts.getId(UInt128.LeastSignificant) == 2 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 0); + assertTrue(accounts.getCreditsPosted().intValueExact() == 100); + assertTrue(accounts.getDebitsPending().intValueExact() == 0); + assertTrue(accounts.getCreditsPending().intValueExact() == 1400); + } else { + throw new Exception(String.format("Unexpected account: %s\n", + UInt128.asBigInteger(accounts.getId()).toString())); + } + } + + // Create a 6th transfer voiding the 2nd transfer. + transfers = new TransferBatch(1); + transfers.add(); + transfers.setId(7); + transfers.setPendingId(2); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(200); + transfers.setFlags(TransferFlags.VOID_PENDING_TRANSFER); + + transferResults = client.createTransfers(transfers); + assertTrue(transferResults.getLength() == transfers.getLength()); + + while (transferResults.next()) { + switch (transferResults.getStatus()) { + case Created: + break; + default: + throw new Exception(String.format("Error creating transfer %d: %s\n", + transferResults.getPosition(), + transferResults.getStatus())); + } + } + + // Validate account balances after voiding 2nd pending transfer. + accounts = client.lookupAccounts(ids); + assertTrue(accounts.getLength() == 2); + + while (accounts.next()) { + if (accounts.getId(UInt128.LeastSignificant) == 1 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 100); + assertTrue(accounts.getCreditsPosted().intValueExact() == 0); + assertTrue(accounts.getDebitsPending().intValueExact() == 1200); + assertTrue(accounts.getCreditsPending().intValueExact() == 0); + } else if (accounts.getId(UInt128.LeastSignificant) == 2 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 0); + assertTrue(accounts.getCreditsPosted().intValueExact() == 100); + assertTrue(accounts.getDebitsPending().intValueExact() == 0); + assertTrue(accounts.getCreditsPending().intValueExact() == 1200); + } else { + throw new Exception(String.format("Unexpected account: %s\n", + UInt128.asBigInteger(accounts.getId()).toString())); + } + } + + // Create an 8th transfer posting the 3rd transfer. + transfers = new TransferBatch(1); + transfers.add(); + transfers.setId(8); + transfers.setPendingId(3); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(300); + transfers.setFlags(TransferFlags.POST_PENDING_TRANSFER); + + transferResults = client.createTransfers(transfers); + assertTrue(transferResults.getLength() == transfers.getLength()); + + while (transferResults.next()) { + switch (transferResults.getStatus()) { + case Created: + break; + default: + throw new Exception(String.format("Error creating transfer %d: %s\n", + transferResults.getPosition(), + transferResults.getStatus())); + } + } + + // Validate account balances after posting 3rd pending transfer. + accounts = client.lookupAccounts(ids); + assertTrue(accounts.getLength() == 2); + + while (accounts.next()) { + if (accounts.getId(UInt128.LeastSignificant) == 1 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValue() == 400); + assertTrue(accounts.getCreditsPosted().intValue() == 0); + assertTrue(accounts.getDebitsPending().intValue() == 900); + assertTrue(accounts.getCreditsPending().intValue() == 0); + } else if (accounts.getId(UInt128.LeastSignificant) == 2 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValue() == 0); + assertTrue(accounts.getCreditsPosted().intValue() == 400); + assertTrue(accounts.getDebitsPending().intValue() == 0); + assertTrue(accounts.getCreditsPending().intValue() == 900); + } else { + throw new Exception(String.format("Unexpected account: %s\n", + UInt128.asBigInteger(accounts.getId()).toString())); + } + } + + // Create a 9th transfer voiding the 4th transfer. + transfers = new TransferBatch(1); + transfers.add(); + transfers.setId(9); + transfers.setPendingId(4); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(400); + transfers.setFlags(TransferFlags.VOID_PENDING_TRANSFER); + + transferResults = client.createTransfers(transfers); + assertTrue(transferResults.getLength() == transfers.getLength()); + + while (transferResults.next()) { + switch (transferResults.getStatus()) { + case Created: + break; + default: + throw new Exception(String.format("Error creating transfer %d: %s\n", + transferResults.getPosition(), + transferResults.getStatus())); + } + } + + // Validate account balances after voiding 4th pending transfer. + accounts = client.lookupAccounts(ids); + assertTrue(accounts.getLength() == 2); + + while (accounts.next()) { + if (accounts.getId(UInt128.LeastSignificant) == 1 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValue() == 400); + assertTrue(accounts.getCreditsPosted().intValue() == 0); + assertTrue(accounts.getDebitsPending().intValue() == 500); + assertTrue(accounts.getCreditsPending().intValue() == 0); + } else if (accounts.getId(UInt128.LeastSignificant) == 2 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValue() == 0); + assertTrue(accounts.getCreditsPosted().intValue() == 400); + assertTrue(accounts.getDebitsPending().intValue() == 0); + assertTrue(accounts.getCreditsPending().intValue() == 500); + } else { + throw new Exception(String.format("Unexpected account: %s\n", + UInt128.asBigInteger(accounts.getId()).toString())); + } + } + + // Create a 10th transfer posting the 5th transfer. + transfers = new TransferBatch(1); + transfers.add(); + transfers.setId(10); + transfers.setPendingId(5); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(500); + transfers.setFlags(TransferFlags.POST_PENDING_TRANSFER); + + transferResults = client.createTransfers(transfers); + assertTrue(transferResults.getLength() == transfers.getLength()); + + while (transferResults.next()) { + switch (transferResults.getStatus()) { + case Created: + break; + default: + throw new Exception(String.format("Error creating transfer %d: %s\n", + transferResults.getPosition(), + transferResults.getStatus())); + } + } + + // Validate account balances after posting 5th pending transfer. + accounts = client.lookupAccounts(ids); + assertTrue(accounts.getLength() == 2); + + while (accounts.next()) { + if (accounts.getId(UInt128.LeastSignificant) == 1 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 900); + assertTrue(accounts.getCreditsPosted().intValueExact() == 0); + assertTrue(accounts.getDebitsPending().intValueExact() == 0); + assertTrue(accounts.getCreditsPending().intValueExact() == 0); + } else if (accounts.getId(UInt128.LeastSignificant) == 2 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 0); + assertTrue(accounts.getCreditsPosted().intValueExact() == 900); + assertTrue(accounts.getDebitsPending().intValueExact() == 0); + assertTrue(accounts.getCreditsPending().intValueExact() == 0); + } else { + throw new Exception(String.format("Unexpected account: %s\n", + UInt128.asBigInteger(accounts.getId()).toString())); + } + } + } + } +} diff --git a/ocam/src/clients/java/samples/two-phase/README.md b/ocam/src/clients/java/samples/two-phase/README.md new file mode 100644 index 00000000..128f7299 --- /dev/null +++ b/ocam/src/clients/java/samples/two-phase/README.md @@ -0,0 +1,101 @@ + +# Two-Phase Transfer Java Sample + +Code for this sample is in [./src/main/java/Main.java](./src/main/java/Main.java). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Java >= 11 +* Maven >= 3.6 (not strictly necessary but it's what our guides assume) + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/java/samples/two-phase`. + +Then, install the TigerBeetle client: + +```console +mvn install +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +mvn exec:java +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfer + +Then it begins a +pending transfer of `500` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 500` + +(This is because a pending +transfer only affects **pending** credits and debits on accounts, +not **posted** credits and debits.) + +## 4. Post pending transfer + +Then it creates a second transfer that marks the first +transfer as posted. + +## 5. Fetch and validate transfers + +Then it fetches both transfers, validates +that the two transfers exist, validates that the first +transfer had (and still has) a `pending` flag, and validates +that the second transfer had (and still has) a +`post_pending_transfer` flag. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +*posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 500` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 500` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/java/samples/two-phase/pom.xml b/ocam/src/clients/java/samples/two-phase/pom.xml new file mode 100644 index 00000000..8df99bc6 --- /dev/null +++ b/ocam/src/clients/java/samples/two-phase/pom.xml @@ -0,0 +1,45 @@ + + 4.0.0 + + com.tigerbeetle + samples + 1.0-SNAPSHOT + + + 11 + 11 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all,-options,-path + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + com.tigerbeetle.samples.Main + + + + + + + + com.tigerbeetle + tigerbeetle-java + 0.0.1-SNAPSHOT + + + diff --git a/ocam/src/clients/java/samples/two-phase/src/main/java/Main.java b/ocam/src/clients/java/samples/two-phase/src/main/java/Main.java new file mode 100644 index 00000000..248952f1 --- /dev/null +++ b/ocam/src/clients/java/samples/two-phase/src/main/java/Main.java @@ -0,0 +1,150 @@ +package com.tigerbeetle.samples; + +import java.util.Arrays; +import java.util.UUID; +import java.math.BigInteger; + +import com.tigerbeetle.*; +import static com.tigerbeetle.AssertionError.assertTrue; + + +public final class Main { + + public static void main(String[] args) throws Exception { + String replicaAddress = System.getenv("TB_ADDRESS"); + + byte[] clusterID = UInt128.asBytes(0); + String[] replicaAddresses = new String[] {replicaAddress == null ? "3000" : replicaAddress}; + try (var client = new Client(clusterID, replicaAddresses)) { + // Create two accounts + AccountBatch accounts = new AccountBatch(2); + accounts.add(); + accounts.setId(1); + accounts.setLedger(1); + accounts.setCode(1); + + accounts.add(); + accounts.setId(2); + accounts.setLedger(1); + accounts.setCode(1); + + CreateAccountResultBatch accountResults = client.createAccounts(accounts); + while (accountResults.next()) { + switch (accountResults.getStatus()) { + case Created: + break; + default: + throw new Exception(String.format("Error creating account %d: %s\n", + accountResults.getPosition(), + accountResults.getStatus())); + } + } + + // Start a pending transfer + TransferBatch transfers = new TransferBatch(1); + transfers.add(); + transfers.setId(1); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(500); + transfers.setFlags(TransferFlags.PENDING); + + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + assertTrue(transferResults.getLength() == transfers.getLength()); + + while (transferResults.next()) { + switch (transferResults.getStatus()) { + case Created: + break; + default: + throw new Exception(String.format("Error creating transfer %d: %s\n", + transferResults.getPosition(), + transferResults.getStatus())); + } + } + + // Validate accounts pending and posted debits/credits before finishing the + // two-phase transfer + IdBatch ids = new IdBatch(2); + ids.add(1); + ids.add(2); + accounts = client.lookupAccounts(ids); + assertTrue(accounts.getLength() == 2); + + while (accounts.next()) { + if (accounts.getId(UInt128.LeastSignificant) == 1 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 0); + assertTrue(accounts.getCreditsPosted().intValueExact() == 0); + assertTrue(accounts.getDebitsPending().intValueExact() == 500); + assertTrue(accounts.getCreditsPending().intValueExact() == 0); + } else if (accounts.getId(UInt128.LeastSignificant) == 2 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 0); + assertTrue(accounts.getCreditsPosted().intValueExact() == 0); + assertTrue(accounts.getDebitsPending().intValueExact() == 0); + assertTrue(accounts.getCreditsPending().intValueExact() == 500); + } else { + throw new Exception(String.format("Unexpected account: %s\n", + UInt128.asBigInteger(accounts.getId()).toString())); + } + } + + // Create a second transfer simply posting the first transfer + transfers = new TransferBatch(1); + transfers.add(); + transfers.setId(2); + transfers.setPendingId(1); + transfers.setDebitAccountId(1); + transfers.setCreditAccountId(2); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setAmount(500); + transfers.setFlags(TransferFlags.POST_PENDING_TRANSFER); + + transferResults = client.createTransfers(transfers); + assertTrue(transferResults.getLength() == transfers.getLength()); + + while (transferResults.next()) { + switch (transferResults.getStatus()) { + case Created: + break; + default: + throw new Exception(String.format("Error creating transfer %d: %s\n", + transferResults.getPosition(), + transferResults.getStatus())); + } + } + + // Validate accounts pending and posted debits/credits after finishing the + // two-phase transfer + ids = new IdBatch(2); + ids.add(1); + ids.add(2); + accounts = client.lookupAccounts(ids); + assertTrue(accounts.getLength() == 2); + + while (accounts.next()) { + if (accounts.getId(UInt128.LeastSignificant) == 1 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 500); + assertTrue(accounts.getCreditsPosted().intValueExact() == 0); + assertTrue(accounts.getDebitsPending().intValueExact() == 0); + assertTrue(accounts.getCreditsPending().intValueExact() == 0); + } else if (accounts.getId(UInt128.LeastSignificant) == 2 + && accounts.getId(UInt128.MostSignificant) == 0) { + assertTrue(accounts.getDebitsPosted().intValueExact() == 0); + assertTrue(accounts.getCreditsPosted().intValueExact() == 500); + assertTrue(accounts.getDebitsPending().intValueExact() == 0); + assertTrue(accounts.getCreditsPending().intValueExact() == 0); + } else { + throw new Exception(String.format("Unexpected account: %s\n", + UInt128.asBigInteger(accounts.getId()).toString())); + } + } + } + } +} + diff --git a/ocam/src/clients/java/samples/walkthrough/README.md b/ocam/src/clients/java/samples/walkthrough/README.md new file mode 100644 index 00000000..b657597b --- /dev/null +++ b/ocam/src/clients/java/samples/walkthrough/README.md @@ -0,0 +1 @@ +Code from the [top-level README.md](../../README.md) collected into a single runnable project. diff --git a/ocam/src/clients/java/samples/walkthrough/pom.xml b/ocam/src/clients/java/samples/walkthrough/pom.xml new file mode 100644 index 00000000..8df99bc6 --- /dev/null +++ b/ocam/src/clients/java/samples/walkthrough/pom.xml @@ -0,0 +1,45 @@ + + 4.0.0 + + com.tigerbeetle + samples + 1.0-SNAPSHOT + + + 11 + 11 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all,-options,-path + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + com.tigerbeetle.samples.Main + + + + + + + + com.tigerbeetle + tigerbeetle-java + 0.0.1-SNAPSHOT + + + diff --git a/ocam/src/clients/java/samples/walkthrough/src/main/java/Main.java b/ocam/src/clients/java/samples/walkthrough/src/main/java/Main.java new file mode 100644 index 00000000..e93c6e3d --- /dev/null +++ b/ocam/src/clients/java/samples/walkthrough/src/main/java/Main.java @@ -0,0 +1,535 @@ +package com.tigerbeetle.samples; + +import java.sql.ResultSet; + +// section:imports +import com.tigerbeetle.*; + +public final class Main { + public static void main(String[] args) throws Exception { + System.out.println("Import ok!"); + // endsection:imports + + // section:client + String replicaAddress = System.getenv("TB_ADDRESS"); + byte[] clusterID = UInt128.asBytes(0); + String[] replicaAddresses = new String[] {replicaAddress == null ? "3000" : replicaAddress}; + try (var client = new Client(clusterID, replicaAddresses)) { + // Use client + } + // endsection:client + + try (var client = new Client(clusterID, replicaAddresses)) { + + try { + // section:create-accounts + AccountBatch accounts = new AccountBatch(1); + accounts.add(); + accounts.setId(UInt128.id()); // TigerBeetle time-based ID. + accounts.setUserData128(0, 0); + accounts.setUserData64(0); + accounts.setUserData32(0); + accounts.setLedger(1); + accounts.setCode(718); + accounts.setFlags(AccountFlags.NONE); + accounts.setTimestamp(0); + + CreateAccountResultBatch accountResults = client.createAccounts(accounts); + // Results handling omitted. + // endsection:create-accounts + } catch (Throwable any) {} + + try { + // section:account-flags + AccountBatch accounts = new AccountBatch(2); + + accounts.add(); + accounts.setId(100); + accounts.setLedger(1); + accounts.setCode(718); + accounts.setFlags(AccountFlags.LINKED | AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS); + + accounts.add(); + accounts.setId(101); + accounts.setLedger(1); + accounts.setCode(718); + accounts.setFlags(AccountFlags.HISTORY); + + CreateAccountResultBatch accountResults = client.createAccounts(accounts); + // Results handling omitted. + // endsection:account-flags + } catch (Throwable any) {} + + try { + // section:create-accounts-errors + AccountBatch accounts = new AccountBatch(3); + + accounts.add(); + accounts.setId(102); + accounts.setLedger(1); + accounts.setCode(718); + accounts.setFlags(AccountFlags.NONE); + + accounts.add(); + accounts.setId(103); + accounts.setLedger(1); + accounts.setCode(718); + accounts.setFlags(AccountFlags.NONE); + + accounts.add(); + accounts.setId(104); + accounts.setLedger(1); + accounts.setCode(718); + accounts.setFlags(AccountFlags.NONE); + + CreateAccountResultBatch accountResults = client.createAccounts(accounts); + while (accountResults.next()) { + switch (accountResults.getStatus()) { + case Created: + System.out.printf("Batch account at %d successfully created with timestamp %d.\n", + accountResults.getPosition(), accountResults.getTimestamp()); + break; + case Exists: + System.err.printf("Batch account at %d already exists with timestamp %d.\n", + accountResults.getPosition(), accountResults.getTimestamp()); + break; + default: + System.err.printf("Batch account at %d failed to create: %s.\n", + accountResults.getPosition(), accountResults.getStatus()); + break; + } + } + // endsection:create-accounts-errors + } catch (Throwable any) {} + + try { + // section:lookup-accounts + IdBatch ids = new IdBatch(2); + ids.add(100); + ids.add(101); + + AccountBatch accounts = client.lookupAccounts(ids); + // endsection:lookup-accounts + } catch (Throwable any) {} + + try { + // section:create-transfers + TransferBatch transfers = new TransferBatch(1); + + transfers.add(); + transfers.setId(UInt128.id()); + transfers.setDebitAccountId(102); + transfers.setCreditAccountId(103); + transfers.setAmount(10); + transfers.setUserData128(0, 0); + transfers.setUserData64(0); + transfers.setUserData32(0); + transfers.setTimeout(0); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setFlags(TransferFlags.NONE); + transfers.setTimeout(0); + + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + // Results handling omitted. + // endsection:create-transfers + } catch (Throwable any) {} + + try { + // section:create-transfers-errors + TransferBatch transfers = new TransferBatch(3); + + transfers.add(); + transfers.setId(1); + transfers.setDebitAccountId(102); + transfers.setCreditAccountId(103); + transfers.setAmount(10); + transfers.setLedger(1); + transfers.setCode(1); + + transfers.add(); + transfers.setId(2); + transfers.setDebitAccountId(102); + transfers.setCreditAccountId(103); + transfers.setAmount(10); + transfers.setLedger(1); + transfers.setCode(1); + + transfers.add(); + transfers.setId(3); + transfers.setDebitAccountId(102); + transfers.setCreditAccountId(103); + transfers.setAmount(10); + transfers.setLedger(1); + transfers.setCode(1); + + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + while (transferResults.next()) { + switch (transferResults.getStatus()) { + case Created: + System.out.printf("Batch transfer at %d successfully created with timestamp %d.\n", + transferResults.getPosition(), transferResults.getTimestamp()); + break; + case Exists: + System.err.printf("Batch transfer at %d already exists with timestamp %d.\n", + transferResults.getPosition(), transferResults.getTimestamp()); + break; + default: + System.err.printf("Batch transfer at %d failed to create: %s\n", + transferResults.getPosition(), transferResults.getStatus()); + break; + } + } + // endsection:create-transfers-errors + } catch (Throwable any) {} + + try { + // section:batch + ResultSet dataSource = null; /* Loaded from an external source. */; + + var BATCH_SIZE = 8189; + TransferBatch batch = new TransferBatch(BATCH_SIZE); + while(dataSource.next()) { + batch.add(); + batch.setId(dataSource.getBytes("id")); + batch.setDebitAccountId(dataSource.getBytes("debit_account_id")); + batch.setCreditAccountId(dataSource.getBytes("credit_account_id")); + batch.setAmount(dataSource.getBigDecimal("amount").toBigInteger()); + batch.setLedger(dataSource.getInt("ledger")); + batch.setCode(dataSource.getInt("code")); + + if (batch.getLength() == BATCH_SIZE) { + CreateTransferResultBatch transferResults = client.createTransfers(batch); + // Results handling omitted. + + // Reset the batch for the next iteration. + batch.beforeFirst(); + } + } + + if (batch.getLength() > 0) { + // Send the remaining items. + CreateTransferResultBatch transferResults = client.createTransfers(batch); + // Results handling omitted. + } + + // endsection:batch + } catch (Throwable any) {} + + try { + // section:transfer-flags-link + TransferBatch transfers = new TransferBatch(2); + + // First transfer + transfers.add(); + transfers.setId(4); + transfers.setDebitAccountId(102); + transfers.setCreditAccountId(103); + transfers.setAmount(10); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setFlags(TransferFlags.LINKED); + + transfers.add(); + transfers.setId(5); + transfers.setDebitAccountId(102); + transfers.setCreditAccountId(103); + transfers.setAmount(10); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setFlags(TransferFlags.NONE); + + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + // Results handling omitted. + // endsection:transfer-flags-link + } catch (Throwable any) {} + + try { + // section:transfer-flags-post + TransferBatch transfers = new TransferBatch(1); + + transfers.add(); + transfers.setId(6); + transfers.setDebitAccountId(102); + transfers.setCreditAccountId(103); + transfers.setAmount(10); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setFlags(TransferFlags.PENDING); + + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + // Results handling omitted. + + transfers = new TransferBatch(1); + + transfers.add(); + transfers.setId(7); + transfers.setAmount(TransferBatch.AMOUNT_MAX); + transfers.setPendingId(6); + transfers.setFlags(TransferFlags.POST_PENDING_TRANSFER); + + transferResults = client.createTransfers(transfers); + // Results handling omitted. + // endsection:transfer-flags-post + } catch (Throwable any) {} + + try { + // section:transfer-flags-void + TransferBatch transfers = new TransferBatch(1); + + transfers.add(); + transfers.setId(8); + transfers.setDebitAccountId(102); + transfers.setCreditAccountId(103); + transfers.setAmount(10); + transfers.setLedger(1); + transfers.setCode(1); + transfers.setFlags(TransferFlags.PENDING); + + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + // Results handling omitted. + + transfers = new TransferBatch(1); + + transfers.add(); + transfers.setId(9); + transfers.setAmount(0); + transfers.setPendingId(8); + transfers.setFlags(TransferFlags.VOID_PENDING_TRANSFER); + + transferResults = client.createTransfers(transfers); + // Results handling omitted. + // endsection:transfer-flags-void + } catch (Throwable any) {} + + try { + // section:lookup-transfers + IdBatch ids = new IdBatch(2); + ids.add(1); + ids.add(2); + + TransferBatch transfers = client.lookupTransfers(ids); + // endsection:lookup-transfers + } catch (Throwable any) {} + + try { + // section:get-account-transfers + AccountFilter filter = new AccountFilter(); + filter.setAccountId(2); + filter.setUserData128(0); // No filter by UserData. + filter.setUserData64(0); + filter.setUserData32(0); + filter.setCode(0); // No filter by Code. + filter.setTimestampMin(0); // No filter by Timestamp. + filter.setTimestampMax(0); // No filter by Timestamp. + filter.setLimit(10); // Limit to ten transfers at most. + filter.setDebits(true); // Include transfer from the debit side. + filter.setCredits(true); // Include transfer from the credit side. + filter.setReversed(true); // Sort by timestamp in reverse-chronological order. + + TransferBatch transfers = client.getAccountTransfers(filter); + // endsection:get-account-transfers + } catch (Throwable any) {} + + try { + // section:get-account-balances + AccountFilter filter = new AccountFilter(); + filter.setAccountId(2); + filter.setUserData128(0); // No filter by UserData. + filter.setUserData64(0); + filter.setUserData32(0); + filter.setCode(0); // No filter by Code. + filter.setTimestampMin(0); // No filter by Timestamp. + filter.setTimestampMax(0); // No filter by Timestamp. + filter.setLimit(10); // Limit to ten balances at most. + filter.setDebits(true); // Include transfer from the debit side. + filter.setCredits(true); // Include transfer from the credit side. + filter.setReversed(true); // Sort by timestamp in reverse-chronological order. + + AccountBalanceBatch account_balances = client.getAccountBalances(filter); + // endsection:get-account-balances + } catch (Throwable any) {} + + try { + // section:query-accounts + QueryFilter filter = new QueryFilter(); + filter.setUserData128(1000); // Filter by UserData. + filter.setUserData64(100); + filter.setUserData32(10); + filter.setCode(1); // Filter by Code. + filter.setLedger(0); // No filter by Ledger. + filter.setTimestampMin(0); // No filter by Timestamp. + filter.setTimestampMax(0); // No filter by Timestamp. + filter.setLimit(10); // Limit to ten accounts at most. + filter.setReversed(true); // Sort by timestamp in reverse-chronological order. + + AccountBatch accounts = client.queryAccounts(filter); + // endsection:query-accounts + } catch (Throwable any) {} + + try { + // section:query-transfers + QueryFilter filter = new QueryFilter(); + filter.setUserData128(1000); // Filter by UserData. + filter.setUserData64(100); + filter.setUserData32(10); + filter.setCode(1); // Filter by Code. + filter.setLedger(0); // No filter by Ledger. + filter.setTimestampMin(0); // No filter by Timestamp. + filter.setTimestampMax(0); // No filter by Timestamp. + filter.setLimit(10); // Limit to ten transfers at most. + filter.setReversed(true); // Sort by timestamp in reverse-chronological order. + + TransferBatch transfers = client.queryTransfers(filter); + // endsection:query-transfers + } catch (Throwable any) {} + + try { + // section:linked-events + TransferBatch transfers = new TransferBatch(10); + + // An individual transfer (successful): + transfers.add(); + transfers.setId(1); + // ... rest of transfer ... + transfers.setFlags(TransferFlags.NONE); + + // A chain of 4 transfers (the last transfer in the chain closes the chain with + // linked=false): + transfers.add(); + transfers.setId(2); // Commit/rollback. + // ... rest of transfer ... + transfers.setFlags(TransferFlags.LINKED); + transfers.add(); + transfers.setId(3); // Commit/rollback. + // ... rest of transfer ... + transfers.setFlags(TransferFlags.LINKED); + transfers.add(); + transfers.setId(2); // Fail with exists + // ... rest of transfer ... + transfers.setFlags(TransferFlags.LINKED); + transfers.add(); + transfers.setId(4); // Fail without committing + // ... rest of transfer ... + transfers.setFlags(TransferFlags.NONE); + + // An individual transfer (successful): + // This should not see any effect from the failed chain above. + transfers.add(); + transfers.setId(2); + // ... rest of transfer ... + transfers.setFlags(TransferFlags.NONE); + + // A chain of 2 transfers (the first transfer fails the chain): + transfers.add(); + transfers.setId(2); + // ... rest of transfer ... + transfers.setFlags(TransferFlags.LINKED); + transfers.add(); + transfers.setId(3); + // ... rest of transfer ... + transfers.setFlags(TransferFlags.NONE); + // A chain of 2 transfers (successful): + transfers.add(); + transfers.setId(3); + // ... rest of transfer ... + transfers.setFlags(TransferFlags.LINKED); + transfers.add(); + transfers.setId(4); + // ... rest of transfer ... + transfers.setFlags(TransferFlags.NONE); + + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + // Results handling omitted. + // endsection:linked-events + } catch (Throwable any) {} + + try { + // section:imported-events + // External source of time + long historicalTimestamp = 0L; + ResultSet historicalAccounts = null; // Loaded from an external source; + ResultSet historicalTransfers = null ; // Loaded from an external source. + + var BATCH_SIZE = 8189; + + // First, load and import all accounts with their timestamps from the historical source. + AccountBatch accounts = new AccountBatch(BATCH_SIZE); + while (historicalAccounts.next()) { + // Set a unique and strictly increasing timestamp. + historicalTimestamp += 1; + + accounts.add(); + accounts.setId(historicalAccounts.getBytes("id")); + accounts.setLedger(historicalAccounts.getInt("ledger")); + accounts.setCode(historicalAccounts.getInt("code")); + accounts.setTimestamp(historicalTimestamp); + + // Set the account as `imported`. + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + if (accounts.getLength() < BATCH_SIZE) { + accounts.setFlags(AccountFlags.IMPORTED | AccountFlags.LINKED); + } else { + accounts.setFlags(AccountFlags.IMPORTED); + + CreateAccountResultBatch accountResults = client.createAccounts(accounts); + // Results handling omitted. + + // Reset the batch for the next iteration. + accounts.beforeFirst(); + } + } + + if (accounts.getLength() > 0) { + // Send the remaining items. + CreateAccountResultBatch accountResults = client.createAccounts(accounts); + // Results handling omitted. + } + + // Then, load and import all transfers with their timestamps from the historical source. + TransferBatch transfers = new TransferBatch(BATCH_SIZE); + while (historicalTransfers.next()) { + // Set a unique and strictly increasing timestamp. + historicalTimestamp += 1; + + transfers.add(); + transfers.setId(historicalTransfers.getBytes("id")); + transfers.setDebitAccountId(historicalTransfers.getBytes("debit_account_id")); + transfers.setCreditAccountId(historicalTransfers.getBytes("credit_account_id")); + transfers.setAmount(historicalTransfers.getBigDecimal("amount").toBigInteger()); + transfers.setLedger(historicalTransfers.getInt("ledger")); + transfers.setCode(historicalTransfers.getInt("code")); + transfers.setTimestamp(historicalTimestamp); + + // Set the transfer as `imported`. + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + if (transfers.getLength() < BATCH_SIZE) { + transfers.setFlags(TransferFlags.IMPORTED | TransferFlags.LINKED); + } else { + transfers.setFlags(TransferFlags.IMPORTED); + + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + // Results handling omitted. + + // Reset the batch for the next iteration. + transfers.beforeFirst(); + } + } + + if (transfers.getLength() > 0) { + // Send the remaining items. + CreateTransferResultBatch transferResults = client.createTransfers(transfers); + // Results handling omitted. + } + + // Since it is a linked chain, in case of any error the entire batch is rolled back and can be retried + // with the same historical timestamps without regressing the cluster timestamp. + // endsection:imported-events + } catch (Throwable any) {} + } + // section:imports + } +} +// endsection:imports diff --git a/ocam/src/clients/java/src/client.zig b/ocam/src/clients/java/src/client.zig new file mode 100644 index 00000000..80e87596 --- /dev/null +++ b/ocam/src/clients/java/src/client.zig @@ -0,0 +1,752 @@ +///! Java Native Interfaces for TigerBeetle Client +///! Please refer to the JNI Best Practices Guide: +///! https://developer.ibm.com/articles/j-jni/ + +// IMPORTANT: Running code from a native thread, the JVM will +// never automatically free local references until the thread detaches. +// To avoid leaks, we *ALWAYS* free all references we acquire, +// even local references, so we don't need to distinguish if the code +// is called from the JVM or the native thread via callback. + +const std = @import("std"); +const builtin = @import("builtin"); +const jni = @import("jni.zig"); +const JNIThreadCleaner = @import("jni_thread_cleaner.zig").JNIThreadCleaner; + +comptime { + if (!builtin.link_libc) { + @compileError("JNI client must be built with libc"); + } +} + +pub const vsr = @import("vsr"); + +const tb = vsr.tb_client; + +const log = std.log.scoped(.tb_client_jni); +const assert = std.debug.assert; + +const jni_version = jni.jni_version_10; + +const global_allocator = std.heap.c_allocator; + +pub const std_options: std.Options = .{ + .log_level = .debug, + .logFn = tb.exports.Logging.application_logger, +}; + +/// NativeClient implementation. +const NativeClient = struct { + /// On JVM loads this library. + fn on_load(vm: *jni.JavaVM) jni.JInt { + const env = JNIHelper.get_env(vm); + ReflectionHelper.load(env); + return jni_version; + } + + /// On JVM unloads this library. + fn on_unload(vm: *jni.JavaVM) void { + const env = JNIHelper.get_env(vm); + ReflectionHelper.unload(env); + } + + /// Native clientInit and clientInitEcho implementation. + fn client_init( + comptime echo_client: bool, + env: *jni.JNIEnv, + client: *tb.ClientInterface, + cluster_id: u128, + addresses_obj: jni.JString, + ) void { + const addresses = JNIHelper.get_string_utf(env, addresses_obj) orelse { + ReflectionHelper.initialization_exception_throw( + env, + tb.exports.tb_init_status.address_invalid, + ); + return; + }; + defer env.release_string_utf_chars(addresses_obj, addresses.ptr); + + const init_fn = if (echo_client) tb.init_echo else tb.init; + const jvm = JNIHelper.get_java_vm(env); + init_fn( + global_allocator, + client, + cluster_id, + addresses, + @intFromPtr(jvm), + on_completion, + ) catch |err| { + const status = tb.exports.init_error_to_status(err); + ReflectionHelper.initialization_exception_throw(env, status); + }; + } + + /// Native clientDeinit implementation. + fn client_deinit(client: *tb.ClientInterface) void { + client.deinit() catch { + // Ignore multiple calls to `deinit`. + }; + } + + /// Native submit implementation. + fn submit( + env: *jni.JNIEnv, + client: *tb.ClientInterface, + request_obj: jni.JObject, + ) void { + assert(request_obj != null); + + const operation = ReflectionHelper.get_request_operation(env, request_obj); + const send_buffer: []u8 = ReflectionHelper.get_send_buffer_slice(env, request_obj) orelse { + ReflectionHelper.assertion_error_throw( + env, + "Request.sendBuffer is null or invalid", + ); + return; + }; + + const packet: *tb.Packet = global_allocator.create(tb.Packet) catch { + ReflectionHelper.assertion_error_throw(env, "Request could not allocate a packet"); + return; + }; + + // Holds a global reference to prevent GC before the callback. + const global_ref = JNIHelper.new_global_reference(env, request_obj); + packet.* = .{ + .user_data = global_ref, + .operation = operation, + .data_size = @intCast(send_buffer.len), + .data = send_buffer.ptr, + .user_tag = 0, + .status = undefined, + }; + + client.submit(packet) catch |err| { + env.delete_global_ref(global_ref); + global_allocator.destroy(packet); + switch (err) { + error.ClientInvalid => ReflectionHelper.client_closed_exception_throw(env), + } + }; + } + + /// Completion callback, always called from the native thread. + fn on_completion( + context_ptr: usize, + packet: *tb.Packet, + timestamp: u64, + result: ?[*]const u8, + result_size: u32, + ) callconv(.c) void { + const jvm: *jni.JavaVM = @ptrFromInt(context_ptr); + + const env = JNIHelper.try_get_env(jvm) orelse + JNIThreadCleaner.attach_current_thread_with_cleanup(jvm); + + // Retrieves the request instance, and drops the GC reference. + assert(packet.user_data != null); + const request_obj: jni.JObject = @ptrCast(packet.user_data); + defer env.delete_global_ref(request_obj); + + // Extract the packet details before freeing it. + const packet_operation = packet.operation; + const packet_status = packet.status; + global_allocator.destroy(packet); + + if (packet_status != .ok) { + assert(timestamp == 0); + assert(result == null); + assert(result_size == 0); + } + + if (result_size > 0) { + switch (packet_status) { + .ok => if (result) |ptr| { + // Copying the reply before returning from the callback. + ReflectionHelper.set_reply_buffer( + env, + request_obj, + ptr[0..result_size], + ); + }, + else => {}, + } + } + + ReflectionHelper.end_request( + env, + request_obj, + packet_operation, + packet_status, + timestamp, + ); + } +}; + +// Declares and exports all functions using the JNI naming/calling convention. +comptime { + // https://docs.oracle.com/en/java/javase/17/docs/specs/jni/design.html#compiling-loading-and-linking-native-methods. + const prefix = "Java_com_tigerbeetle_NativeClient_"; + + const Exports = struct { + fn on_load(vm: *jni.JavaVM) callconv(.c) jni.JInt { + return NativeClient.on_load(vm); + } + + fn on_unload(vm: *jni.JavaVM) callconv(.c) void { + NativeClient.on_unload(vm); + } + + fn client_init( + env: *jni.JNIEnv, + class: jni.JClass, + tb_client_buffer: jni.JObject, + cluster_id: jni.JByteArray, + addresses: jni.JString, + ) callconv(.c) void { + _ = class; + assert(env.get_array_length(cluster_id) == 16); + + const cluster_id_elements = env.get_byte_array_elements(cluster_id, null).?; + defer env.release_byte_array_elements(cluster_id, cluster_id_elements, .abort); + + NativeClient.client_init( + false, + env, + ReflectionHelper.get_client_from_buffer(env, tb_client_buffer), + @bitCast(cluster_id_elements[0..16].*), + addresses, + ); + } + + fn client_init_echo( + env: *jni.JNIEnv, + class: jni.JClass, + tb_client_buffer: jni.JObject, + cluster_id: jni.JByteArray, + addresses: jni.JString, + ) callconv(.c) void { + _ = class; + assert(env.get_array_length(cluster_id) == 16); + + const cluster_id_elements = env.get_byte_array_elements(cluster_id, null).?; + defer env.release_byte_array_elements(cluster_id, cluster_id_elements, .abort); + + NativeClient.client_init( + true, + env, + ReflectionHelper.get_client_from_buffer(env, tb_client_buffer), + @as(u128, @bitCast(cluster_id_elements[0..16].*)), + addresses, + ); + } + + fn client_deinit( + env: *jni.JNIEnv, + class: jni.JClass, + tb_client_buffer: jni.JObject, + ) callconv(.c) void { + _ = class; + NativeClient.client_deinit( + ReflectionHelper.get_client_from_buffer(env, tb_client_buffer), + ); + } + + fn submit( + env: *jni.JNIEnv, + class: jni.JClass, + tb_client_buffer: jni.JObject, + request_obj: jni.JObject, + ) callconv(.c) void { + _ = class; + NativeClient.submit( + env, + ReflectionHelper.get_client_from_buffer(env, tb_client_buffer), + request_obj, + ); + } + }; + + @export(&Exports.on_load, .{ .name = "JNI_OnLoad", .linkage = .strong }); + @export(&Exports.on_unload, .{ .name = "JNI_OnUnload", .linkage = .strong }); + + @export(&Exports.client_init, .{ .name = prefix ++ "clientInit", .linkage = .strong }); + @export(&Exports.client_init_echo, .{ .name = prefix ++ "clientInitEcho", .linkage = .strong }); + @export(&Exports.client_deinit, .{ .name = prefix ++ "clientDeinit", .linkage = .strong }); + @export(&Exports.submit, .{ .name = prefix ++ "submit", .linkage = .strong }); +} + +/// Reflection helper and metadata cache. +const ReflectionHelper = struct { + var initialization_exception_class: jni.JClass = null; + var initialization_exception_ctor_id: jni.JMethodID = null; + var client_closed_exception_class: jni.JClass = null; + var assertion_error_class: jni.JClass = null; + + var request_class: jni.JClass = null; + var request_send_buffer_field_id: jni.JFieldID = null; + var request_send_buffer_len_field_id: jni.JFieldID = null; + var reply_buffer_field_id: jni.JFieldID = null; + var request_operation_method_id: jni.JMethodID = null; + var request_end_request_method_id: jni.JMethodID = null; + + pub fn load(env: *jni.JNIEnv) void { + // Asserting we are not initialized yet: + assert(initialization_exception_class == null); + assert(initialization_exception_ctor_id == null); + assert(client_closed_exception_class == null); + assert(assertion_error_class == null); + assert(request_class == null); + assert(request_send_buffer_field_id == null); + assert(request_send_buffer_len_field_id == null); + assert(reply_buffer_field_id == null); + assert(request_operation_method_id == null); + assert(request_end_request_method_id == null); + + initialization_exception_class = JNIHelper.find_class( + env, + "com/tigerbeetle/InitializationException", + ); + initialization_exception_ctor_id = JNIHelper.find_method( + env, + initialization_exception_class, + "", + "(I)V", + ); + + client_closed_exception_class = JNIHelper.find_class( + env, + "com/tigerbeetle/ClientClosedException", + ); + + assertion_error_class = JNIHelper.find_class( + env, + "com/tigerbeetle/AssertionError", + ); + + request_class = JNIHelper.find_class( + env, + "com/tigerbeetle/Request", + ); + request_send_buffer_field_id = JNIHelper.find_field( + env, + request_class, + "sendBuffer", + "Ljava/nio/ByteBuffer;", + ); + request_send_buffer_len_field_id = JNIHelper.find_field( + env, + request_class, + "sendBufferLen", + "J", + ); + reply_buffer_field_id = JNIHelper.find_field( + env, + request_class, + "replyBuffer", + "[B", + ); + request_operation_method_id = JNIHelper.find_method( + env, + request_class, + "getOperation", + "()B", + ); + request_end_request_method_id = JNIHelper.find_method( + env, + request_class, + "endRequest", + "(BBJ)V", + ); + + // Asserting we are full initialized: + assert(initialization_exception_class != null); + assert(initialization_exception_ctor_id != null); + assert(client_closed_exception_class != null); + assert(assertion_error_class != null); + assert(request_class != null); + assert(request_send_buffer_field_id != null); + assert(request_send_buffer_len_field_id != null); + assert(reply_buffer_field_id != null); + assert(request_operation_method_id != null); + assert(request_end_request_method_id != null); + } + + pub fn unload(env: *jni.JNIEnv) void { + env.delete_global_ref(initialization_exception_class); + env.delete_global_ref(client_closed_exception_class); + env.delete_global_ref(assertion_error_class); + env.delete_global_ref(request_class); + + initialization_exception_class = null; + initialization_exception_ctor_id = null; + client_closed_exception_class = null; + assertion_error_class = null; + request_class = null; + request_send_buffer_field_id = null; + request_send_buffer_len_field_id = null; + reply_buffer_field_id = null; + request_operation_method_id = null; + request_end_request_method_id = null; + } + + pub fn initialization_exception_throw( + env: *jni.JNIEnv, + status: tb.exports.tb_init_status, + ) void { + assert(initialization_exception_class != null); + assert(initialization_exception_ctor_id != null); + + const exception = env.new_object( + initialization_exception_class, + initialization_exception_ctor_id, + &[_]jni.JValue{jni.JValue.to_jvalue(@as(jni.JInt, @bitCast(@intFromEnum(status))))}, + ) orelse { + // It's unexpected here: we did not initialize correctly or the JVM is out of memory. + JNIHelper.vm_panic( + env, + "Unexpected error creating a new InitializationException.", + .{}, + ); + }; + defer env.delete_local_ref(exception); + + const jni_result = env.throw(exception); + JNIHelper.check_jni_result( + env, + jni_result, + "Unexpected error throwing InitializationException.", + .{}, + ); + assert(env.exception_check() == .jni_true); + } + + pub fn client_closed_exception_throw( + env: *jni.JNIEnv, + ) void { + assert(client_closed_exception_class != null); + + const jni_result = env.throw_new(client_closed_exception_class, null); + JNIHelper.check_jni_result( + env, + jni_result, + "Unexpected error throwing ClientClosedException.", + .{}, + ); + assert(env.exception_check() == .jni_true); + } + + pub fn assertion_error_throw(env: *jni.JNIEnv, message: [:0]const u8) void { + assert(assertion_error_class != null); + + const jni_result = env.throw_new(assertion_error_class, message.ptr); + JNIHelper.check_jni_result( + env, + jni_result, + "Unexpected error throwing AssertionError.", + .{}, + ); + assert(env.exception_check() == .jni_true); + } + + pub fn get_client_from_buffer(env: *jni.JNIEnv, buffer: jni.JObject) *tb.ClientInterface { + // The Java buffer isn't aligned, + // so it is allocated with extra bytes to accommodate a potential `alignForward`. + const buffer_capacity = env.get_direct_buffer_capacity(buffer); + assert(buffer_capacity >= @sizeOf(tb.ClientInterface) + @alignOf(tb.ClientInterface)); + + const address = env.get_direct_buffer_address(buffer) orelse { + // Unexpected here: `tb_client_buffer` should be initialized by the Java side. + JNIHelper.vm_panic( + env, + "Unexpected tb_client direct nio buffer.", + .{}, + ); + }; + return @ptrFromInt(std.mem.alignForward( + usize, + @intFromPtr(address), + @alignOf(tb.ClientInterface), + )); + } + + pub fn get_send_buffer_slice(env: *jni.JNIEnv, this_obj: jni.JObject) ?[]u8 { + assert(this_obj != null); + assert(request_send_buffer_field_id != null); + assert(request_send_buffer_len_field_id != null); + + const buffer_obj = env.get_object_field(this_obj, request_send_buffer_field_id) orelse + return null; + defer env.delete_local_ref(buffer_obj); + + const direct_buffer: []u8 = JNIHelper.get_direct_buffer(env, buffer_obj) orelse + return null; + + const buffer_len = env.get_long_field(this_obj, request_send_buffer_len_field_id); + if (buffer_len < 0 or buffer_len > direct_buffer.len) + return null; + + return direct_buffer[0..@as(usize, @intCast(buffer_len))]; + } + + pub fn set_reply_buffer( + env: *jni.JNIEnv, + this_obj: jni.JObject, + reply: []const u8, + ) void { + assert(this_obj != null); + assert(reply_buffer_field_id != null); + assert(reply.len > 0); + + const reply_buffer_obj = env.new_byte_array( + @intCast(reply.len), + ) orelse { + // Cannot allocate an array, it's likely the JVM has run out of resources. + // Printing the buffer size here just to help diagnosing how much memory was required. + JNIHelper.vm_panic( + env, + "Unexpected error calling NewByteArray len={}", + .{reply.len}, + ); + }; + defer env.delete_local_ref(reply_buffer_obj); + + env.set_byte_array_region( + reply_buffer_obj, + 0, + @intCast(reply.len), + @ptrCast(reply.ptr), + ); + + if (env.exception_check() == .jni_true) { + // Since out-of-bounds isn't expected here, we can only panic if it fails. + JNIHelper.vm_panic( + env, + "Unexpected exception calling JNIEnv.SetByteArrayRegion len={}", + .{reply.len}, + ); + } + + // Setting the request with the reply. + env.set_object_field( + this_obj, + reply_buffer_field_id, + reply_buffer_obj, + ); + } + + pub fn get_request_operation(env: *jni.JNIEnv, this_obj: jni.JObject) u8 { + assert(this_obj != null); + assert(request_class != null); + assert(request_operation_method_id != null); + + const value = env.call_nonvirtual_byte_method( + this_obj, + request_class, + request_operation_method_id, + null, + ); + + if (env.exception_check() == .jni_true) { + // This method isn't expected to throw any exception. + JNIHelper.vm_panic( + env, + "Unexpected exception calling NativeClient.getOperation", + .{}, + ); + } + return @bitCast(value); + } + + pub fn end_request( + env: *jni.JNIEnv, + this_obj: jni.JObject, + packet_operation: u8, + packet_status: tb.PacketStatus, + timestamp: u64, + ) void { + assert(this_obj != null); + assert(request_class != null); + assert(request_end_request_method_id != null); + assert((timestamp > 0) == (packet_status == .ok)); + + env.call_nonvirtual_void_method( + this_obj, + request_class, + request_end_request_method_id, + &[_]jni.JValue{ + jni.JValue.to_jvalue(@as(jni.JByte, @bitCast(packet_operation))), + jni.JValue.to_jvalue(@as(jni.JByte, @bitCast(@intFromEnum(packet_status)))), + jni.JValue.to_jvalue(@as(jni.JLong, @bitCast(timestamp))), + }, + ); + + if (env.exception_check() == .jni_true) { + // The "endRequest" method isn't expected to throw any exception, + // We can't rethrow here, since this function is called from the native callback. + JNIHelper.vm_panic( + env, + "Unexpected exception calling NativeClient.endRequest", + .{}, + ); + } + } +}; + +/// Common functions for handling errors and results in JNI calls. +const JNIHelper = struct { + pub inline fn get_env(vm: *jni.JavaVM) *jni.JNIEnv { + var env: *jni.JNIEnv = undefined; + const jni_result = vm.get_env(&env, jni_version); + if (jni_result != .ok) { + const message = "Unexpected result calling JavaVM.GetEnv"; + log.err( + message ++ "; Error = {} ({s})", + .{ @intFromEnum(jni_result), @tagName(jni_result) }, + ); + @panic("JNI: " ++ message); + } + + return env; + } + + pub inline fn try_get_env(vm: *jni.JavaVM) ?*jni.JNIEnv { + var env: *jni.JNIEnv = undefined; + const jni_result = vm.get_env(&env, jni_version); + return switch (jni_result) { + .ok => env, + .thread_detached => null, + else => { + const message = "Unexpected result calling JavaVM.GetEnv"; + log.err( + message ++ "; Error = {} ({s})", + .{ @intFromEnum(jni_result), @tagName(jni_result) }, + ); + @panic("JNI: " ++ message); + }, + }; + } + + pub inline fn get_java_vm(env: *jni.JNIEnv) *jni.JavaVM { + var jvm: *jni.JavaVM = undefined; + const jni_result = env.get_java_vm(&jvm); + check_jni_result( + env, + jni_result, + "Unexpected result calling JNIEnv.GetJavaVM", + .{}, + ); + + return jvm; + } + + pub inline fn vm_panic( + env: *jni.JNIEnv, + comptime fmt: []const u8, + args: anytype, + ) noreturn { + env.exception_describe(); + log.err(fmt, args); + + var buf: [256]u8 = undefined; + const message: [:0]const u8 = std.fmt.bufPrintZ(&buf, fmt, args) catch |err| switch (err) { + error.NoSpaceLeft => blk: { + buf[255] = 0; + break :blk @ptrCast(buf[0..255]); + }, + }; + + env.fatal_error(message.ptr); + } + + pub inline fn check_jni_result( + env: *jni.JNIEnv, + jni_result: jni.JNIResultType, + comptime fmt: []const u8, + args: anytype, + ) void { + if (jni_result != .ok) { + vm_panic( + env, + fmt ++ "; Error = {} ({s})", + args ++ .{ @intFromEnum(jni_result), @tagName(jni_result) }, + ); + } + } + + pub inline fn find_class(env: *jni.JNIEnv, comptime class_name: [:0]const u8) jni.JClass { + const class_obj = env.find_class(class_name.ptr) orelse { + vm_panic( + env, + "Unexpected result calling JNIEnv.FindClass for {s}", + .{class_name}, + ); + }; + defer env.delete_local_ref(class_obj); + + return env.new_global_ref(class_obj) orelse { + vm_panic( + env, + "Unexpected result calling JNIEnv.NewGlobalRef for {s}", + .{class_name}, + ); + }; + } + + pub inline fn find_field( + env: *jni.JNIEnv, + class: jni.JClass, + comptime name: [:0]const u8, + comptime signature: [:0]const u8, + ) jni.JFieldID { + return env.get_field_id(class, name.ptr, signature.ptr) orelse + vm_panic( + env, + "Field could not be found {s} {s}", + .{ name, signature }, + ); + } + + pub inline fn find_method( + env: *jni.JNIEnv, + class: jni.JClass, + comptime name: [:0]const u8, + comptime signature: [:0]const u8, + ) jni.JMethodID { + return env.get_method_id(class, name.ptr, signature.ptr) orelse + vm_panic( + env, + "Method could not be found {s} {s}", + .{ name, signature }, + ); + } + + pub inline fn get_direct_buffer( + env: *jni.JNIEnv, + buffer_obj: jni.JObject, + ) ?[]u8 { + const buffer_capacity = env.get_direct_buffer_capacity(buffer_obj); + if (buffer_capacity < 0) return null; + + const buffer_address = env.get_direct_buffer_address(buffer_obj) orelse return null; + return buffer_address[0..@as(u32, @intCast(buffer_capacity))]; + } + + pub inline fn new_global_reference(env: *jni.JNIEnv, obj: jni.JObject) jni.JObject { + return env.new_global_ref(obj) orelse { + // NewGlobalRef fails only when the JVM runs out of memory. + JNIHelper.vm_panic(env, "Unexpected result calling JNIEnv.NewGlobalRef", .{}); + }; + } + + pub inline fn get_string_utf(env: *jni.JNIEnv, string: jni.JString) ?[:0]const u8 { + if (string == null) return null; + + const address = env.get_string_utf_chars(string, null) orelse return null; + const length = env.get_string_utf_length(string); + if (length < 0) return null; + + return @ptrCast(address[0..@as(usize, @intCast(length))]); + } +}; diff --git a/ocam/src/clients/java/src/jni.zig b/ocam/src/clients/java/src/jni.zig new file mode 100644 index 00000000..b1e1667f --- /dev/null +++ b/ocam/src/clients/java/src/jni.zig @@ -0,0 +1,3238 @@ +///! Based on Java Native Interface Specification and Java Invocation API. +///! https://docs.oracle.com/en/java/javase/17/docs/specs/jni. +///! +///! We don't rely on @import("jni.h") to translate the JNI declarations by several factors: +///! - Ability to rewrite it using our naming convention and idiomatic Zig pointers +///! such as ?*, [*], [*:0], ?[*]. +///! - Great stability of the JNI specification makes manual implementation a viable option. +///! - Licensing issues redistributing the jni.h file (or even translating it). +///! - To avoid duplicated function definitions by using comptime generated signatures +///! when calling the function table. +///! - To mitigate the risk of human error by using explicit vtable indexes instead of a +///! struct of function pointers sensitive to the fields ordering. +///! +///! Additionally, each function is unit tested against a real JVM to validate if they are +///! calling the correct vtable entry with the expected arguments. + +// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getversion. +pub const jni_version_1_1: JInt = 0x00010001; +pub const jni_version_1_2: JInt = 0x00010002; +pub const jni_version_1_4: JInt = 0x00010004; +pub const jni_version_1_6: JInt = 0x00010006; +pub const jni_version_1_8: JInt = 0x00010008; +pub const jni_version_9: JInt = 0x00090000; +pub const jni_version_10: JInt = 0x000a0000; + +/// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#return-codes. +pub const JNIResultType = enum(JInt) { + /// Success. + ok = 0, + + /// Unknown error. + unknown = -1, + + /// Thread detached from the VM. + thread_detached = -2, + + /// JNI version error. + bad_version = -3, + + /// Not enough memory. + out_of_memory = -4, + + /// VM already created. + vm_already_exists = -5, + + /// Invalid arguments. + invalid_arguments = -6, + + /// Non exhaustive enum. + _, +}; + +// Primitive types: +// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/types.html#primitive-types. + +pub const JBoolean = enum(u8) { jni_true = 1, jni_false = 0 }; +pub const JByte = i8; +pub const JChar = u16; +pub const JShort = i16; +pub const JInt = i32; +pub const JLong = i64; +pub const JFloat = f32; +pub const JDouble = f64; +pub const JSize = JInt; + +// Reference types: +// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/types.html#reference-types. + +pub const JObject = ?*opaque {}; +pub const JClass = JObject; +pub const JThrowable = JObject; +pub const JString = JObject; +pub const JArray = JObject; +pub const JBooleanArray = JArray; +pub const JByteArray = JArray; +pub const JCharArray = JArray; +pub const JShortArray = JArray; +pub const JIntArray = JArray; +pub const JLongArray = JArray; +pub const JFloatArray = JArray; +pub const JDoubleArray = JArray; +pub const JObjectArray = JArray; +pub const JWeakReference = JObject; + +// Method and field IDs: +// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/types.html#field-and-method-ids. + +pub const JFieldID = ?*opaque {}; +pub const JMethodID = ?*opaque {}; + +/// This union is used as the element type in argument arrays. +/// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/types.html#the-value-type. +pub const JValue = extern union { + object: JObject, + boolean: JBoolean, + byte: JByte, + char: JChar, + short: JShort, + int: JInt, + long: JLong, + float: JFloat, + double: JDouble, + + pub fn to_jvalue(value: anytype) JValue { + return switch (@TypeOf(value)) { + JBoolean => .{ .boolean = value }, + JByte => .{ .byte = value }, + JChar => .{ .char = value }, + JShort => .{ .short = value }, + JInt => .{ .int = value }, + JLong => .{ .long = value }, + JFloat => .{ .float = value }, + JDouble => .{ .double = value }, + JObject => .{ .object = value }, + else => unreachable, + }; + } +}; + +/// Mode has no effect if elems is not a copy of the elements in array. +/// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#releaseprimitivetypearrayelements-routines. +pub const JArrayReleaseMode = enum(JInt) { + /// Copy back the content and free the elems buffer. + default = 0, + + /// Copy back the content but do not free the elems buffer. + commit = 1, + + /// Free the buffer without copying back the possible changes. + abort = 2, +}; + +/// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getobjectreftype. +pub const JObjectRefType = enum(JInt) { + invalid = 0, + local = 1, + global = 2, + weak_global = 3, + _, +}; + +/// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#registernatives. +pub const JNINativeMethod = extern struct { + name: [*:0]const u8, + signature: [*:0]const u8, + fn_ptr: ?*anyopaque, +}; + +pub const JNIEnv = opaque { + /// Each function is accessible at a fixed offset through the JNIEnv argument. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#interface-function-table. + const FunctionTable = enum(usize) { + /// Index 4: GetVersion + get_version = 4, + + /// Index 5: DefineClass + define_class = 5, + + /// Index 6: FindClass + find_class = 6, + + /// Index 7: FromReflectedMethod + from_reflected_method = 7, + + /// Index 8: FromReflectedField + from_feflected_field = 8, + + /// Index 9: ToReflectedMethod + to_reflected_method = 9, + + /// Index 10: GetSuperclass + get_super_class = 10, + + /// Index 11: IsAssignableFrom + is_assignable_from = 11, + + /// Index 12: ToReflectedField + to_reflected_field = 12, + + /// Index 13: Throw + throw = 13, + + /// Index 14: ThrowNew + throw_new = 14, + + /// Index 15: ExceptionOccurred + exception_occurred = 15, + + /// Index 16: ExceptionDescribe + exception_describe = 16, + + /// Index 17: ExceptionClear + exception_clear = 17, + + /// Index 18: FatalError + fatal_error = 18, + + /// Index 19: PushLocalFrame + push_local_frame = 19, + + /// Index 20: PopLocalFrame + pop_local_frame = 20, + + /// Index 21: NewGlobalRef + new_global_ref = 21, + + /// Index 22: DeleteGlobalRef + delete_global_ref = 22, + + /// Index 23: DeleteLocalRef + delete_local_ref = 23, + + /// Index 24: IsSameObject + is_same_object = 24, + + /// Index 25: NewLocalRef + new_local_ref = 25, + + /// Index 26: EnsureLocalCapacity + ensure_local_capacity = 26, + + /// Index 27: AllocObject + alloc_object = 27, + + /// Index 28: NewObject + /// Index 29: NewObjectV + /// Index 30: NewObjectA + new_object = 30, + + /// Index 31: GetObjectClass + get_object_class = 31, + + /// Index 32: IsInstanceOf + is_instance_of = 32, + + /// Index 33: GetMethodID + get_method_id = 33, + + /// Index 34: CallObjectMethod (omitted) + /// Index 35: CallObjectMethodV (omitted) + /// Index 36: CallObjectMethodA + call_object_method = 36, + + /// Index 37: CallBooleanMethod (omitted) + /// Index 38: CallBooleanMethodV (omitted) + /// Index 39: CallBooleanMethodA + call_boolean_method = 39, + + /// Index 40: CallByteMethod (omitted) + /// Index 41: CallByteMethodV (omitted) + /// Index 42: CallByteMethodA + call_byte_method = 42, + + /// Index 43: CallCharMethod (omitted) + /// Index 44: CallCharMethodV (omitted) + /// Index 45: CallCharMethodA + call_char_method = 45, + + /// Index 46: CallShortMethod (omitted) + /// Index 47: CallShortMethodV (omitted) + /// Index 48: CallShortMethodA + call_short_method = 48, + + /// Index 49: CallIntMethod (omitted) + /// Index 50: CallIntMethodV (omitted) + /// Index 51: CallIntMethodA + call_int_method = 51, + + /// Index 52: CallLongMethod (omitted) + /// Index 53: CallLongMethodV (omitted) + /// Index 54: CallLongMethodA + call_long_method = 54, + + /// Index 55: CallFloatMethod (omitted) + /// Index 56: CallFloatMethodV (omitted) + /// Index 57: CallFloatMethodA + call_float_method = 57, + + /// Index 58: CallDoubleMethod (omitted) + /// Index 59: CallDoubleMethodV (omitted) + /// Index 60: CallDoubleMethodA + call_double_method = 60, + + /// Index 61: CallVoidMethod (omitted) + /// Index 62: CallVoidMethodV (omitted) + /// Index 63: CallVoidMethodA + call_void_method = 63, + + /// Index 64: CallNonvirtualObjectMethod (omitted) + /// Index 65: CallNonvirtualObjectMethodV (omitted) + /// Index 66: CallNonvirtualObjectMethodA (omitted) + call_nonvirtual_object_method = 66, + + /// Index 67: CallNonvirtualBooleanMethod (omitted) + /// Index 68: CallNonvirtualBooleanMethodV (omitted) + /// Index 69: CallNonvirtualBooleanMethodA (omitted) + call_nonvirtual_boolean_method = 69, + + /// Index 70: CallNonvirtualByteMethod (omitted) + /// Index 71: CallNonvirtualByteMethodV (omitted) + /// Index 72: CallNonvirtualByteMethodA (omitted) + call_nonvirtual_byte_method = 72, + + /// Index 73: CallNonvirtualCharMethod (omitted) + /// Index 74: CallNonvirtualCharMethodV (omitted) + /// Index 75: CallNonvirtualCharMethodA (omitted) + call_nonvirtual_char_method = 75, + + /// Index 76: CallNonvirtualShortMethod (omitted) + /// Index 77: CallNonvirtualShortMethodV (omitted) + /// Index 78: CallNonvirtualShortMethodA (omitted) + call_nonvirtual_short_method = 78, + + /// Index 79: CallNonvirtualIntMethod (omitted) + /// Index 80: CallNonvirtualIntMethodV (omitted) + /// Index 81: CallNonvirtualIntMethodA (omitted) + call_nonvirtual_int_method = 81, + + /// Index 82: CallNonvirtualLongMethod (omitted) + /// Index 83: CallNonvirtualLongMethodV (omitted) + /// Index 84: CallNonvirtualLongMethodA (omitted) + call_nonvirtual_long_method = 84, + + /// Index 85: CallNonvirtualFloatMethod (omitted) + /// Index 86: CallNonvirtualFloatMethodV (omitted) + /// Index 87: CallNonvirtualFloatMethodA (omitted) + call_nonvirtual_float_method = 87, + + /// Index 88: CallNonvirtualDoubleMethod (omitted) + /// Index 89: CallNonvirtualDoubleMethodV (omitted) + /// Index 90: CallNonvirtualDoubleMethodA (omitted) + call_nonvirtual_double_method = 90, + + /// Index 91: CallNonvirtualVoidMethod (omitted) + /// Index 92: CallNonvirtualVoidMethodV (omitted) + /// Index 93: CallNonvirtualVoidMethodA (omitted) + call_nonvirtual_void_method = 93, + + /// Index 94: GetFieldID + get_field_id = 94, + + /// Index 95: GetObjectField + get_object_field = 95, + + /// Index 96: GetBooleanField + get_boolean_field = 96, + + /// Index 97: GetByteField + get_byte_field = 97, + + /// Index 98: GetCharField + get_char_field = 98, + + /// Index 99: GetShortField + get_short_field = 99, + + /// Index 100: GetIntField + get_int_field = 100, + + /// Index 101: GetLongField + get_long_field = 101, + + /// Index 102: GetFloatField + get_float_field = 102, + + /// Index 103: GetDoubleField + get_double_field = 103, + + /// Index 104: SetObjectField + set_object_field = 104, + + /// Index 105: SetBooleanField + set_boolean_field = 105, + + /// Index 106: SetByteField + set_byte_field = 106, + + /// Index 107: SetCharField + set_char_field = 107, + + /// Index 108: SetShortField + set_short_field = 108, + + /// Index 109: SetIntField + set_int_field = 109, + + /// Index 110: SetLongField + set_long_field = 110, + + /// Index 111: SetFloatField + set_float_field = 111, + + /// Index 112: SetDoubleField + set_double_field = 112, + + /// Index 113: GetStaticMethodID + get_static_method_id = 113, + + /// Index 114: CallStaticObjectMethod (omitted) + /// Index 115: CallStaticObjectMethodV (omitted) + /// Index 116: CallStaticObjectMethodA + call_static_object_method = 116, + + /// Index 117: CallStaticBooleanMethod (omitted) + /// Index 118: CallStaticBooleanMethodV (omitted) + /// Index 119: CallStaticBooleanMethodA + call_static_boolean_method = 119, + + /// Index 120: CallStaticBooleanMethod (omitted) + /// Index 121: CallStaticBooleanMethodV (omitted) + /// Index 122: CallStaticBooleanMethodA + call_static_byte_method = 122, + + /// Index 123: CallStaticCharMethod (omitted) + /// Index 124: CallStaticCharMethodV (omitted) + /// Index 125: CallStaticCharMethodA + call_static_char_method = 125, + + /// Index 126: CallStaticCharMethod (omitted) + /// Index 127: CallStaticCharMethodV (omitted) + /// Index 128: CallStaticCharMethodA + call_static_short_method = 128, + + /// Index 129: CallStaticIntMethod (omitted) + /// Index 130: CallStaticIntMethodV (omitted) + /// Index 131: CallStaticIntMethodA + call_static_int_method = 131, + + /// Index 132: CallStaticLongMethod (omitted) + /// Index 133: CallStaticLongMethodV (omitted) + /// Index 134: CallStaticLongMethodA + call_static_long_method = 134, + + /// Index 135: CallStaticFloatMethod (omitted) + /// Index 136: CallStaticFloatMethodV (omitted) + /// Index 137: CallStaticFloatMethodA + call_static_float_method = 137, + + /// Index 138: CallStaticDoubleMethod (omitted) + /// Index 139: CallStaticDoubleMethodV (omitted) + /// Index 140: CallStaticDoubleMethodA + call_static_double_method = 140, + + /// Index 141: CallStaticVoidMethod (omitted) + /// Index 142: CallStaticVoidMethodV (omitted) + /// Index 143: CallStaticVoidMethodA + call_static_void_method = 143, + + /// Index 144: GetStaticFieldID + get_static_field_id = 144, + + /// Index 145: GetStaticObjectField + get_static_object_field = 145, + + /// Index 146: GetStaticBooleanField + get_static_boolean_field = 146, + + /// Index 147: GetStaticByteField + get_static_byte_field = 147, + + /// Index 148: GetStaticCharField + get_static_char_field = 148, + + /// Index 149: GetStaticShortField + get_static_short_field = 149, + + /// Index 150: GetStaticIntField + get_static_int_field = 150, + + /// Index 151: GetStaticLongField + get_static_long_field = 151, + + /// Index 152: GetStaticFloatField + /// Returns the value of a static field of an object. + get_static_float_field = 152, + + /// Index 153: GetStaticDoubleField + get_static_double_field = 153, + + /// Index 154: SetStaticObjectField + set_static_object_field = 154, + + /// Index 155: SetStaticBooleanField + set_static_boolean_field = 155, + + /// Index 156: SetStaticByteField + /// Sets the value of a static field of an object. + set_static_byte_field = 156, + + /// Index 157: SetStaticCharField + set_static_char_field = 157, + + /// Index 158: SetStaticShortField + set_static_short_field = 158, + + /// Index 159: SetStaticIntField + set_static_int_field = 159, + + /// Index 160: SetStaticLongField + set_static_long_field = 160, + + /// Index 161: SetStaticFloatField + set_static_float_field = 161, + + /// Index 162: SetStaticDoubleField + set_static_double_field = 162, + + /// Index 163: NewString + new_string = 163, + + /// Index 164: GetStringLength + get_string_length = 164, + + /// Index 165: GetStringChars + get_string_chars = 165, + + /// Index 166: ReleaseStringChars + release_string_chars = 166, + + /// Index 167: NewStringUTF + new_string_utf = 167, + + /// Index 168: GetStringUTFLength + get_string_utf_length = 168, + + /// Index 169: GetStringUTFChars + get_string_utf_chars = 169, + + /// Index 170: ReleaseStringUTFChars + release_string_utf_chars = 170, + + /// Index 171: GetArrayLength + get_array_length = 171, + + /// Index 172: NewObjectArray + new_object_array = 172, + + /// Index 173: GetObjectArrayElement + get_object_array_element = 173, + + /// Index 174: SetObjectArrayElement + set_object_array_element = 174, + + /// Index 175: NewBooleanArray + new_boolean_array = 175, + + /// Index 176: NewByteArray + new_byte_array = 176, + + /// Index 177: NewCharArray + new_char_array = 177, + + /// Index 178: NewShortArray + new_short_array = 178, + + /// Index 179: NewIntArray + new_int_array = 179, + + /// Index 180: NewLongArray + new_long_array = 180, + + /// Index 181: NewFloatArray + new_float_array = 181, + + /// Index 182: NewDoubleArray + new_double_array = 182, + + /// Index 183: GetBooleanArrayElements + get_boolean_array_elements = 183, + + /// Index 184: GetByteArrayElements + get_byte_array_elements = 184, + + /// Index 185: GetCharArrayElements + get_char_array_elements = 185, + + /// Index 186: GetShortArrayElements + get_short_array_elements = 186, + + /// Index 187: GetIntArrayElements + get_int_array_elements = 187, + + /// Index 188: GetLongArrayElements + get_long_array_elements = 188, + + /// Index 189: GetFloatArrayElements + get_float_array_elements = 189, + + /// Index 190: GetDoubleArrayElements + get_double_array_elements = 190, + + /// Index 191: ReleaseBooleanArrayElements + release_boolean_array_elements = 191, + + /// Index 192: ReleaseByteArrayElements + release_byte_array_elements = 192, + + /// Index 193: ReleaseCharArrayElements + release_char_array_elements = 193, + + /// Index 194: ReleaseShortArrayElements + release_short_array_elements = 194, + + /// Index 195: ReleaseIntArrayElements + release_int_array_elements = 195, + + /// Index 196: ReleaseLongArrayElements + release_long_array_elements = 196, + + /// Index 197: ReleaseFloatArrayElements + release_float_array_elements = 197, + + /// Index 198: ReleaseDoubleArrayElements + release_double_array_elements = 198, + + /// Index 199: GetBooleanArrayRegion + get_boolean_array_region = 199, + + /// Index 200: GetByteArrayRegion + get_byte_array_region = 200, + + /// Index 201: GetCharArrayRegion + get_char_array_region = 201, + + /// Index 202: GetShortArrayRegion + get_short_array_region = 202, + + /// Index 203: GetIntArrayRegion + get_int_array_region = 203, + + /// Index 204: GetLongArrayRegion + get_long_array_region = 204, + + /// Index 205: GetFloatArrayRegion + get_float_array_region = 205, + + /// Index 206: GetDoubleArrayRegion + get_double_array_region = 206, + + /// Index 207: SetBooleanArrayRegion + set_boolean_array_region = 207, + + /// Index 208: SetByteArrayRegion + set_byte_array_region = 208, + + /// Index 209: SetCharArrayRegion + set_char_array_region = 209, + + /// Index 210: SetShortArrayRegion + set_short_array_region = 210, + + /// Index 211: SetIntArrayRegion + set_int_array_region = 211, + + /// Index 212: SetLongArrayRegion + set_long_array_region = 212, + + /// Index 213: SetFloatArrayRegion + set_float_array_region = 213, + + /// Index 214: SetDoubleArrayRegion + set_double_array_region = 214, + + /// Index 215: RegisterNatives + register_natives = 215, + + /// Index 216: UnregisterNatives + unregister_natives = 216, + + /// Index 217: MonitorEnter + monitor_enter = 217, + + /// Index 218: MonitorExit + monitor_exit = 218, + + /// GetJavaVM - Index 219 in the JNIEnv interface function table + get_java_vm = 219, + + /// Index 220: GetStringRegion + get_string_region = 220, + + /// Index 221: GetStringUTFRegion + get_string_utf_region = 221, + + /// Index 222: GetPrimitiveArrayCritical + get_primitive_array_critical = 222, + + /// Index 223: ReleasePrimitiveArrayCritical + release_primitive_array_critical = 223, + + /// Index 224: GetStringCritical + get_string_critical = 224, + + /// Index 225: ReleaseStringCritical + release_string_critical = 225, + + /// Index 226: NewWeakGlobalRef + new_weak_global_ref = 226, + + /// Index 227: DeleteWeakGlobalRef + delete_weak_global_ref = 227, + + /// Index 228: ExceptionCheck + exception_check = 228, + + /// Index 229: NewDirectByteBuffer + new_direct_byte_buffer = 229, + + /// Index 230: GetDirectBufferAddress + get_direct_buffer_address = 230, + + /// GetDirectBufferCapacity - Index 231 in the JNIEnv interface function table + get_direct_buffer_capacity = 231, + + /// Index 232: GetObjectRefType + get_object_ref_type = 232, + + /// Index 233: GetModule + get_module = 233, + }; + + const JNIInterface = JNIInterfaceType(JNIEnv); + + /// Returns the major version number in the higher 16 bits + /// and the minor version number in the lower 16 bits. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getversion. + pub inline fn get_version( + env: *JNIEnv, + ) JInt { + return JNIInterface.call( + env, + .get_version, + .{}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#defineclass. + pub inline fn define_class( + env: *JNIEnv, + name: [*:0]const u8, + loader: JObject, + buf: [*]const JByte, + buf_len: JSize, + ) JClass { + return JNIInterface.call( + env, + .define_class, + .{ name, loader, buf, buf_len }, + ); + } + + /// The name argument is a fully-qualified class name or an array type signature. + /// For example "java/lang/String" + /// Returns a class object from a fully-qualified name, or NULL if the class cannot be found. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#findclass. + pub inline fn find_class( + env: *JNIEnv, + name: [*:0]const u8, + ) JClass { + return JNIInterface.call( + env, + .find_class, + .{name}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#fromreflectedmethod + pub inline fn from_reflected_method( + env: *JNIEnv, + jobject: JObject, + ) JMethodID { + return JNIInterface.call( + env, + .from_reflected_method, + .{jobject}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#fromfeflectedfield + pub inline fn from_feflected_field( + env: *JNIEnv, + jobject: JObject, + ) JFieldID { + return JNIInterface.call( + env, + .from_feflected_field, + .{jobject}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#toreflectedmethod + pub inline fn to_reflected_method( + env: *JNIEnv, + cls: JClass, + method_id: JMethodID, + is_static: JBoolean, + ) JObject { + return JNIInterface.call( + env, + .to_reflected_method, + .{ cls, method_id, is_static }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getsuperclass. + pub inline fn get_super_class( + env: *JNIEnv, + clazz: JClass, + ) JClass { + return JNIInterface.call( + env, + .get_super_class, + .{clazz}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#isassignablefrom. + pub inline fn is_assignable_from( + env: *JNIEnv, + clazz_1: JClass, + clazz_2: JClass, + ) JBoolean { + return JNIInterface.call( + env, + .is_assignable_from, + .{ clazz_1, clazz_2 }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#toreflectedfield. + pub inline fn to_reflected_field( + env: *JNIEnv, + cls: JClass, + field_id: JFieldID, + is_static: JBoolean, + ) JObject { + return JNIInterface.call( + env, + .to_reflected_field, + .{ cls, field_id, is_static }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#throw. + pub inline fn throw( + env: *JNIEnv, + obj: JThrowable, + ) JNIResultType { + return JNIInterface.call( + env, + .throw, + .{obj}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#thrownew. + pub inline fn throw_new( + env: *JNIEnv, + clazz: JClass, + message: ?[*:0]const u8, + ) JNIResultType { + return JNIInterface.call( + env, + .throw_new, + .{ clazz, message }, + ); + } + + /// Returns the exception object that is currently in the process of being thrown, + /// or NULL if no exception is currently being thrown. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#exceptionoccurred. + pub inline fn exception_occurred( + env: *JNIEnv, + ) JThrowable { + return JNIInterface.call( + env, + .exception_occurred, + .{}, + ); + } + + /// Prints an exception and a backtrace of the stack to a system error-reporting channel, + /// such as stderr. This is a convenience routine provided for debugging. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#exceptiondescribe. + pub inline fn exception_describe( + env: *JNIEnv, + ) void { + JNIInterface.call( + env, + .exception_describe, + .{}, + ); + } + + /// Clears any exception that is currently being thrown. + /// If no exception is currently being thrown, this routine has no effect. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#exceptionclear. + pub inline fn exception_clear( + env: *JNIEnv, + ) void { + JNIInterface.call( + env, + .exception_clear, + .{}, + ); + } + + /// Raises a fatal error and does not expect the VM to recover. + /// This function does not return. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#fatalerror. + pub inline fn fatal_error( + env: *JNIEnv, + msg: [*:0]const u8, + ) noreturn { + JNIInterface.call( + env, + .fatal_error, + .{msg}, + ); + unreachable; + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#pushlocalframe. + pub inline fn push_local_frame( + env: *JNIEnv, + capacity: JInt, + ) JNIResultType { + return JNIInterface.call( + env, + .push_local_frame, + .{capacity}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#poplocalframe. + pub inline fn pop_local_frame( + env: *JNIEnv, + result: JObject, + ) JObject { + return JNIInterface.call( + env, + .pop_local_frame, + .{result}, + ); + } + + /// Returns a global reference to the given obj. + /// May return NULL if: + /// - obj refers to null; + /// - the system has run out of memory; + /// - obj was a weak global reference and has already been garbage collected. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newglobalref. + pub inline fn new_global_ref( + env: *JNIEnv, + obj: JObject, + ) JObject { + return JNIInterface.call( + env, + .new_global_ref, + .{obj}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#deleteglobalref. + pub inline fn delete_global_ref( + env: *JNIEnv, + global_ref: JObject, + ) void { + JNIInterface.call( + env, + .delete_global_ref, + .{global_ref}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#deletelocalref. + pub inline fn delete_local_ref( + env: *JNIEnv, + local_ref: JObject, + ) void { + JNIInterface.call( + env, + .delete_local_ref, + .{local_ref}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#issameobject. + pub inline fn is_same_object( + env: *JNIEnv, + ref_1: JObject, + ref_2: JObject, + ) JBoolean { + return JNIInterface.call( + env, + .is_same_object, + .{ ref_1, ref_2 }, + ); + } + + /// Returns NULL if ref refers to null. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newlocalref. + pub inline fn new_local_ref( + env: *JNIEnv, + ref: JObject, + ) JObject { + return JNIInterface.call( + env, + .new_local_ref, + .{ref}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#ensurelocalcapacity. + pub inline fn ensure_local_capacity( + env: *JNIEnv, + capacity: JInt, + ) JNIResultType { + return JNIInterface.call( + env, + .ensure_local_capacity, + .{capacity}, + ); + } + + /// Allocates a new Java object *without* invoking the constructor. + /// Returns a reference to the object. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#allocobject. + pub inline fn alloc_object( + env: *JNIEnv, + clazz: JClass, + ) JObject { + return JNIInterface.call( + env, + .alloc_object, + .{clazz}, + ); + } + + /// Returns a Java object, or NULL if the object cannot be constructed. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newobject. + pub inline fn new_object( + env: *JNIEnv, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JObject { + return JNIInterface.call( + env, + .new_object, + .{ clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getobjectclass. + pub inline fn get_object_class( + env: *JNIEnv, + jobject: JObject, + ) JClass { + return JNIInterface.call( + env, + .get_object_class, + .{jobject}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#isinstanceof. + pub inline fn is_instance_of( + env: *JNIEnv, + jobject: JObject, + clazz: JClass, + ) JBoolean { + return JNIInterface.call( + env, + .is_instance_of, + .{ jobject, clazz }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getmethodid. + pub inline fn get_method_id( + env: *JNIEnv, + clazz: JClass, + name: [*:0]const u8, + sig: [*:0]const u8, + ) JMethodID { + return JNIInterface.call( + env, + .get_method_id, + .{ clazz, name, sig }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#calltypemethod-routines-calltypemethoda-routines-calltypemethodv-routines. + pub inline fn call_object_method( + env: *JNIEnv, + obj: JObject, + method_id: JMethodID, + args: ?[*]const JValue, + ) JObject { + return JNIInterface.call( + env, + .call_object_method, + .{ obj, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#calltypemethod-routines-calltypemethoda-routines-calltypemethodv-routines. + pub inline fn call_boolean_method( + env: *JNIEnv, + obj: JObject, + method_id: JMethodID, + args: ?[*]const JValue, + ) JBoolean { + return JNIInterface.call( + env, + .call_boolean_method, + .{ obj, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#calltypemethod-routines-calltypemethoda-routines-calltypemethodv-routines. + pub inline fn call_byte_method( + env: *JNIEnv, + obj: JObject, + method_id: JMethodID, + args: ?[*]const JValue, + ) JByte { + return JNIInterface.call( + env, + .call_byte_method, + .{ obj, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#calltypemethod-routines-calltypemethoda-routines-calltypemethodv-routines. + pub inline fn call_char_method( + env: *JNIEnv, + obj: JObject, + method_id: JMethodID, + args: ?[*]const JValue, + ) JChar { + return JNIInterface.call( + env, + .call_char_method, + .{ obj, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#calltypemethod-routines-calltypemethoda-routines-calltypemethodv-routines. + pub inline fn call_short_method( + env: *JNIEnv, + obj: JObject, + method_id: JMethodID, + args: ?[*]const JValue, + ) JShort { + return JNIInterface.call( + env, + .call_short_method, + .{ obj, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#calltypemethod-routines-calltypemethoda-routines-calltypemethodv-routines. + pub inline fn call_int_method( + env: *JNIEnv, + obj: JObject, + method_id: JMethodID, + args: ?[*]const JValue, + ) JInt { + return JNIInterface.call( + env, + .call_int_method, + .{ obj, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#calltypemethod-routines-calltypemethoda-routines-calltypemethodv-routines. + pub inline fn call_long_method( + env: *JNIEnv, + obj: JObject, + method_id: JMethodID, + args: ?[*]const JValue, + ) JLong { + return JNIInterface.call( + env, + .call_long_method, + .{ obj, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#calltypemethod-routines-calltypemethoda-routines-calltypemethodv-routines. + pub inline fn call_float_method( + env: *JNIEnv, + obj: JObject, + method_id: JMethodID, + args: ?[*]const JValue, + ) JFloat { + return JNIInterface.call( + env, + .call_float_method, + .{ obj, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#calltypemethod-routines-calltypemethoda-routines-calltypemethodv-routines. + pub inline fn call_double_method( + env: *JNIEnv, + obj: JObject, + method_id: JMethodID, + args: ?[*]const JValue, + ) JDouble { + return JNIInterface.call( + env, + .call_double_method, + .{ obj, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#calltypemethod-routines-calltypemethoda-routines-calltypemethodv-routines. + pub inline fn call_void_method( + env: *JNIEnv, + obj: JObject, + method_id: JMethodID, + args: ?[*]const JValue, + ) void { + JNIInterface.call( + env, + .call_void_method, + .{ obj, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callnonvirtualtypemethod-routines-callnonvirtualtypemethoda-routines-callnonvirtualtypemethodv-routines. + pub inline fn call_nonvirtual_object_method( + env: *JNIEnv, + obj: JObject, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JObject { + return JNIInterface.call( + env, + .call_nonvirtual_object_method, + .{ obj, clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callnonvirtualtypemethod-routines-callnonvirtualtypemethoda-routines-callnonvirtualtypemethodv-routines. + pub inline fn call_nonvirtual_boolean_method( + env: *JNIEnv, + obj: JObject, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JBoolean { + return JNIInterface.call( + env, + .call_nonvirtual_boolean_method, + .{ obj, clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callnonvirtualtypemethod-routines-callnonvirtualtypemethoda-routines-callnonvirtualtypemethodv-routines. + pub inline fn call_nonvirtual_byte_method( + env: *JNIEnv, + obj: JObject, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JByte { + return JNIInterface.call( + env, + .call_nonvirtual_byte_method, + .{ obj, clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callnonvirtualtypemethod-routines-callnonvirtualtypemethoda-routines-callnonvirtualtypemethodv-routines. + pub inline fn call_nonvirtual_char_method( + env: *JNIEnv, + obj: JObject, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JChar { + return JNIInterface.call( + env, + .call_nonvirtual_char_method, + .{ obj, clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callnonvirtualtypemethod-routines-callnonvirtualtypemethoda-routines-callnonvirtualtypemethodv-routines. + pub inline fn call_nonvirtual_short_method( + env: *JNIEnv, + obj: JObject, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JShort { + return JNIInterface.call( + env, + .call_nonvirtual_short_method, + .{ obj, clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callnonvirtualtypemethod-routines-callnonvirtualtypemethoda-routines-callnonvirtualtypemethodv-routines. + pub inline fn call_nonvirtual_int_method( + env: *JNIEnv, + obj: JObject, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JInt { + return JNIInterface.call( + env, + .call_nonvirtual_int_method, + .{ obj, clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callnonvirtualtypemethod-routines-callnonvirtualtypemethoda-routines-callnonvirtualtypemethodv-routines. + pub inline fn call_nonvirtual_long_method( + env: *JNIEnv, + obj: JObject, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JLong { + return JNIInterface.call( + env, + .call_nonvirtual_long_method, + .{ obj, clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callnonvirtualtypemethod-routines-callnonvirtualtypemethoda-routines-callnonvirtualtypemethodv-routines. + pub inline fn call_nonvirtual_float_method( + env: *JNIEnv, + obj: JObject, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JFloat { + return JNIInterface.call( + env, + .call_nonvirtual_float_method, + .{ obj, clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callnonvirtualtypemethod-routines-callnonvirtualtypemethoda-routines-callnonvirtualtypemethodv-routines. + pub inline fn call_nonvirtual_double_method( + env: *JNIEnv, + obj: JObject, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JDouble { + return JNIInterface.call( + env, + .call_nonvirtual_double_method, + .{ obj, clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callnonvirtualtypemethod-routines-callnonvirtualtypemethoda-routines-callnonvirtualtypemethodv-routines. + pub inline fn call_nonvirtual_void_method( + env: *JNIEnv, + obj: JObject, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) void { + JNIInterface.call( + env, + .call_nonvirtual_void_method, + .{ obj, clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getfieldid. + pub inline fn get_field_id( + env: *JNIEnv, + clazz: JClass, + name: [*:0]const u8, + sig: [*:0]const u8, + ) JFieldID { + return JNIInterface.call( + env, + .get_field_id, + .{ clazz, name, sig }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#gettypefield-routines. + pub inline fn get_object_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + ) JObject { + return JNIInterface.call( + env, + .get_object_field, + .{ obj, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#gettypefield-routines. + pub inline fn get_boolean_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + ) JBoolean { + return JNIInterface.call( + env, + .get_boolean_field, + .{ obj, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#gettypefield-routines. + pub inline fn get_byte_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + ) JByte { + return JNIInterface.call( + env, + .get_byte_field, + .{ obj, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#gettypefield-routines. + pub inline fn get_char_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + ) JChar { + return JNIInterface.call( + env, + .get_char_field, + .{ obj, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#gettypefield-routines. + pub inline fn get_short_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + ) JShort { + return JNIInterface.call( + env, + .get_short_field, + .{ obj, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#gettypefield-routines. + pub inline fn get_int_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + ) JInt { + return JNIInterface.call( + env, + .get_int_field, + .{ obj, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#gettypefield-routines. + pub inline fn get_long_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + ) JLong { + return JNIInterface.call( + env, + .get_long_field, + .{ obj, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#gettypefield-routines. + pub inline fn get_float_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + ) JFloat { + return JNIInterface.call( + env, + .get_float_field, + .{ obj, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#gettypefield-routines. + pub inline fn get_double_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + ) JDouble { + return JNIInterface.call( + env, + .get_double_field, + .{ obj, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#settypefield-routines. + pub inline fn set_object_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + value: JObject, + ) void { + JNIInterface.call( + env, + .set_object_field, + .{ obj, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#settypefield-routines. + pub inline fn set_boolean_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + value: JBoolean, + ) void { + JNIInterface.call( + env, + .set_boolean_field, + .{ obj, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#settypefield-routines. + pub inline fn set_byte_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + value: JByte, + ) void { + JNIInterface.call( + env, + .set_byte_field, + .{ obj, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#settypefield-routines. + pub inline fn set_char_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + value: JChar, + ) void { + JNIInterface.call( + env, + .set_char_field, + .{ obj, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#settypefield-routines. + pub inline fn set_short_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + value: JShort, + ) void { + JNIInterface.call( + env, + .set_short_field, + .{ obj, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#settypefield-routines. + pub inline fn set_int_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + value: JInt, + ) void { + JNIInterface.call( + env, + .set_int_field, + .{ obj, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#settypefield-routines. + pub inline fn set_long_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + value: JLong, + ) void { + JNIInterface.call( + env, + .set_long_field, + .{ obj, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#settypefield-routines. + pub inline fn set_float_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + value: JFloat, + ) void { + JNIInterface.call( + env, + .set_float_field, + .{ obj, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#settypefield-routines. + pub inline fn set_double_field( + env: *JNIEnv, + obj: JObject, + field_id: JFieldID, + value: JDouble, + ) void { + JNIInterface.call( + env, + .set_double_field, + .{ obj, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstaticmethodid. + pub inline fn get_static_method_id( + env: *JNIEnv, + clazz: JClass, + name: [*:0]const u8, + sig: [*:0]const u8, + ) JMethodID { + return JNIInterface.call( + env, + .get_static_method_id, + .{ clazz, name, sig }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callstatictypemethod-routines-callstatictypemethoda-routines-callstatictypemethodv-routines. + pub inline fn call_static_object_method( + env: *JNIEnv, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JObject { + return JNIInterface.call( + env, + .call_static_object_method, + .{ clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callstatictypemethod-routines-callstatictypemethoda-routines-callstatictypemethodv-routines. + pub inline fn call_static_boolean_method( + env: *JNIEnv, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JBoolean { + return JNIInterface.call( + env, + .call_static_boolean_method, + .{ clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callstatictypemethod-routines-callstatictypemethoda-routines-callstatictypemethodv-routines. + pub inline fn call_static_byte_method( + env: *JNIEnv, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JByte { + return JNIInterface.call( + env, + .call_static_byte_method, + .{ clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callstatictypemethod-routines-callstatictypemethoda-routines-callstatictypemethodv-routines. + pub inline fn call_static_char_method( + env: *JNIEnv, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JChar { + return JNIInterface.call( + env, + .call_static_char_method, + .{ clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callstatictypemethod-routines-callstatictypemethoda-routines-callstatictypemethodv-routines. + pub inline fn call_static_short_method( + env: *JNIEnv, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JShort { + return JNIInterface.call( + env, + .call_static_short_method, + .{ clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callstatictypemethod-routines-callstatictypemethoda-routines-callstatictypemethodv-routines. + pub inline fn call_static_int_method( + env: *JNIEnv, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JInt { + return JNIInterface.call( + env, + .call_static_int_method, + .{ clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callstatictypemethod-routines-callstatictypemethoda-routines-callstatictypemethodv-routines. + pub inline fn call_static_long_method( + env: *JNIEnv, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JLong { + return JNIInterface.call( + env, + .call_static_long_method, + .{ clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callstatictypemethod-routines-callstatictypemethoda-routines-callstatictypemethodv-routines. + pub inline fn call_static_float_method( + env: *JNIEnv, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JFloat { + return JNIInterface.call( + env, + .call_static_float_method, + .{ clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callstatictypemethod-routines-callstatictypemethoda-routines-callstatictypemethodv-routines. + pub inline fn call_static_double_method( + env: *JNIEnv, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) JDouble { + return JNIInterface.call( + env, + .call_static_double_method, + .{ clazz, method_id, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#callstatictypemethod-routines-callstatictypemethoda-routines-callstatictypemethodv-routines. + pub inline fn call_static_void_method( + env: *JNIEnv, + clazz: JClass, + method_id: JMethodID, + args: ?[*]const JValue, + ) void { + JNIInterface.call( + env, + .call_static_void_method, + .{ clazz, method_id, args }, + ); + } + + /// Returns a field ID, or NULL if the specified static field cannot be found. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstaticfieldid. + pub inline fn get_static_field_id( + env: *JNIEnv, + clazz: JClass, + name: [*:0]const u8, + sig: [*:0]const u8, + ) JFieldID { + return JNIInterface.call( + env, + .get_static_field_id, + .{ clazz, name, sig }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstatictypefield-routines. + pub inline fn get_static_object_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + ) JObject { + return JNIInterface.call( + env, + .get_static_object_field, + .{ clazz, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstatictypefield-routines. + pub inline fn get_static_boolean_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + ) JBoolean { + return JNIInterface.call( + env, + .get_static_boolean_field, + .{ clazz, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstatictypefield-routines. + pub inline fn get_static_byte_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + ) JByte { + return JNIInterface.call( + env, + .get_static_byte_field, + .{ clazz, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstatictypefield-routines. + pub inline fn get_static_char_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + ) JChar { + return JNIInterface.call( + env, + .get_static_char_field, + .{ clazz, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstatictypefield-routines. + pub inline fn get_static_short_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + ) JShort { + return JNIInterface.call( + env, + .get_static_short_field, + .{ clazz, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstatictypefield-routines. + pub inline fn get_static_int_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + ) JInt { + return JNIInterface.call( + env, + .get_static_int_field, + .{ clazz, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstatictypefield-routines. + pub inline fn get_static_long_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + ) JLong { + return JNIInterface.call( + env, + .get_static_long_field, + .{ clazz, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstatictypefield-routines. + pub inline fn get_static_float_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + ) JFloat { + return JNIInterface.call( + env, + .get_static_float_field, + .{ clazz, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstatictypefield-routines. + pub inline fn get_static_double_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + ) JDouble { + return JNIInterface.call( + env, + .get_static_double_field, + .{ clazz, field_id }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstatictypefield-routines. + pub inline fn set_static_object_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + value: JObject, + ) void { + JNIInterface.call( + env, + .set_static_object_field, + .{ clazz, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setstatictypefield-routines. + pub inline fn set_static_boolean_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + value: JBoolean, + ) void { + JNIInterface.call( + env, + .set_static_boolean_field, + .{ clazz, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setstatictypefield-routines. + pub inline fn set_static_byte_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + value: JByte, + ) void { + JNIInterface.call( + env, + .set_static_byte_field, + .{ clazz, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setstatictypefield-routines. + pub inline fn set_static_char_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + value: JChar, + ) void { + JNIInterface.call( + env, + .set_static_char_field, + .{ clazz, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setstatictypefield-routines. + pub inline fn set_static_short_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + value: JShort, + ) void { + JNIInterface.call( + env, + .set_static_short_field, + .{ clazz, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setstatictypefield-routines. + pub inline fn set_static_int_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + value: JInt, + ) void { + JNIInterface.call( + env, + .set_static_int_field, + .{ clazz, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setstatictypefield-routines. + pub inline fn set_static_long_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + value: JLong, + ) void { + JNIInterface.call( + env, + .set_static_long_field, + .{ clazz, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setstatictypefield-routines. + pub inline fn set_static_float_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + value: JFloat, + ) void { + JNIInterface.call( + env, + .set_static_float_field, + .{ clazz, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setstatictypefield-routines. + pub inline fn set_static_double_field( + env: *JNIEnv, + clazz: JClass, + field_id: JFieldID, + value: JDouble, + ) void { + JNIInterface.call( + env, + .set_static_double_field, + .{ clazz, field_id, value }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newstring. + pub inline fn new_string( + env: *JNIEnv, + unicode_chars: [*]const JChar, + size: JSize, + ) JString { + return JNIInterface.call( + env, + .new_string, + .{ unicode_chars, size }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstringlength. + pub inline fn get_string_length( + env: *JNIEnv, + string: JString, + ) JSize { + return JNIInterface.call( + env, + .get_string_length, + .{string}, + ); + } + + /// Returns a pointer to the array of Unicode characters of the string. + /// This pointer is valid until release_string_chars is called. + /// If is_copy is not NULL, then *is_copy is set to true if a copy is made, + /// or it is set to false if no copy is made. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstringchars. + pub inline fn get_string_chars( + env: *JNIEnv, + string: JString, + is_copy: ?*JBoolean, + ) ?[*]const JChar { + return JNIInterface.call( + env, + .get_string_chars, + .{ string, is_copy }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#releasestringchars. + pub inline fn release_string_chars( + env: *JNIEnv, + string: JString, + chars: [*]const JChar, + ) void { + JNIInterface.call( + env, + .release_string_chars, + .{ string, chars }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newstringutf. + pub inline fn new_string_utf( + env: *JNIEnv, + bytes: [*:0]const u8, + ) JString { + return JNIInterface.call( + env, + .new_string_utf, + .{bytes}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstringutflength. + pub inline fn get_string_utf_length( + env: *JNIEnv, + string: JString, + ) JSize { + return JNIInterface.call( + env, + .get_string_utf_length, + .{string}, + ); + } + + /// Returns a pointer to an array of bytes representing the string in modified UTF-8 encoding. + /// This array is valid until it is released by release_string_utf_chars. + /// If isCopy is not NULL, then *is_copy is set to true if a copy is made, + /// or it is set to false if no copy is made. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstringutfchars. + pub inline fn get_string_utf_chars( + env: *JNIEnv, + string: JString, + is_copy: ?*JBoolean, + ) ?[*:0]const u8 { + return JNIInterface.call( + env, + .get_string_utf_chars, + .{ string, is_copy }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#releasestringutfchars. + pub inline fn release_string_utf_chars( + env: *JNIEnv, + string: JString, + utf: [*:0]const u8, + ) void { + JNIInterface.call( + env, + .release_string_utf_chars, + .{ string, utf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getarraylength. + pub inline fn get_array_length( + env: *JNIEnv, + array: JArray, + ) JSize { + return JNIInterface.call( + env, + .get_array_length, + .{array}, + ); + } + + /// Constructs a new array holding objects in class elementClass. + /// All elements are initially set to initialElement. + /// Returns a Java array object, or NULL if the array cannot be constructed. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newobjectarray. + pub inline fn new_object_array( + env: *JNIEnv, + length: JSize, + element_class: JClass, + initial_element: JObject, + ) JObjectArray { + return JNIInterface.call( + env, + .new_object_array, + .{ length, element_class, initial_element }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getobjectarrayelement. + pub inline fn get_object_array_element( + env: *JNIEnv, + array: JObjectArray, + index: JSize, + ) JObject { + return JNIInterface.call( + env, + .get_object_array_element, + .{ array, index }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setobjectarrayelement. + pub inline fn set_object_array_element( + env: *JNIEnv, + array: JObjectArray, + index: JSize, + value: JObject, + ) void { + JNIInterface.call( + env, + .set_object_array_element, + .{ array, index, value }, + ); + } + + /// Returns a Java array, or NULL if the array cannot be constructed. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newprimitivetypearray-routines. + pub inline fn new_boolean_array( + env: *JNIEnv, + length: JSize, + ) JBooleanArray { + return JNIInterface.call( + env, + .new_boolean_array, + .{length}, + ); + } + + /// Returns a Java array, or NULL if the array cannot be constructed. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newprimitivetypearray-routines. + pub inline fn new_byte_array( + env: *JNIEnv, + length: JSize, + ) JByteArray { + return JNIInterface.call( + env, + .new_byte_array, + .{length}, + ); + } + + /// Returns a Java array, or NULL if the array cannot be constructed. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newprimitivetypearray-routines. + pub inline fn new_char_array( + env: *JNIEnv, + length: JSize, + ) JCharArray { + return JNIInterface.call( + env, + .new_char_array, + .{length}, + ); + } + + /// Returns a Java array, or NULL if the array cannot be constructed. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newprimitivetypearray-routines. + pub inline fn new_short_array( + env: *JNIEnv, + length: JSize, + ) JShortArray { + return JNIInterface.call( + env, + .new_short_array, + .{length}, + ); + } + + /// Returns a Java array, or NULL if the array cannot be constructed. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newprimitivetypearray-routines. + pub inline fn new_int_array( + env: *JNIEnv, + length: JSize, + ) JIntArray { + return JNIInterface.call( + env, + .new_int_array, + .{length}, + ); + } + + /// Returns a Java array, or NULL if the array cannot be constructed. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newprimitivetypearray-routines. + pub inline fn new_long_array( + env: *JNIEnv, + length: JSize, + ) JLongArray { + return JNIInterface.call( + env, + .new_long_array, + .{length}, + ); + } + + /// Returns a Java array, or NULL if the array cannot be constructed. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newprimitivetypearray-routines. + pub inline fn new_float_array( + env: *JNIEnv, + length: JSize, + ) JFloatArray { + return JNIInterface.call( + env, + .new_float_array, + .{length}, + ); + } + + /// Returns a Java array, or NULL if the array cannot be constructed. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newprimitivetypearray-routines. + pub inline fn new_double_array( + env: *JNIEnv, + length: JSize, + ) JDoubleArray { + return JNIInterface.call( + env, + .new_double_array, + .{length}, + ); + } + + /// Returns the body of the primitive array. + /// The result is valid until the corresponding release function is called. + /// If isCopy is not NULL, then *is_copy is set to true if a copy is made, + /// or it is set to false if no copy is made. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayelements-routines. + pub inline fn get_boolean_array_elements( + env: *JNIEnv, + array: JBooleanArray, + is_copy: ?*JBoolean, + ) ?[*]JBoolean { + return JNIInterface.call( + env, + .get_boolean_array_elements, + .{ array, is_copy }, + ); + } + + /// Returns the body of the primitive array. + /// The result is valid until the corresponding release function is called. + /// If isCopy is not NULL, then *is_copy is set to true if a copy is made, + /// or it is set to false if no copy is made. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayelements-routines. + pub inline fn get_byte_array_elements( + env: *JNIEnv, + array: JByteArray, + is_copy: ?*JBoolean, + ) ?[*]JByte { + return JNIInterface.call( + env, + .get_byte_array_elements, + .{ array, is_copy }, + ); + } + + /// Returns the body of the primitive array. + /// The result is valid until the corresponding release function is called. + /// If isCopy is not NULL, then *is_copy is set to true if a copy is made, + /// or it is set to false if no copy is made. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayelements-routines. + pub inline fn get_char_array_elements( + env: *JNIEnv, + array: JCharArray, + is_copy: ?*JBoolean, + ) ?[*]JChar { + return JNIInterface.call( + env, + .get_char_array_elements, + .{ array, is_copy }, + ); + } + + /// Returns the body of the primitive array. + /// The result is valid until the corresponding release function is called. + /// If isCopy is not NULL, then *is_copy is set to true if a copy is made, + /// or it is set to false if no copy is made. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayelements-routines. + pub inline fn get_short_array_elements( + env: *JNIEnv, + array: JShortArray, + is_copy: ?*JBoolean, + ) ?[*]JShort { + return JNIInterface.call( + env, + .get_short_array_elements, + .{ array, is_copy }, + ); + } + + /// Returns the body of the primitive array. + /// The result is valid until the corresponding release function is called. + /// If isCopy is not NULL, then *is_copy is set to true if a copy is made, + /// or it is set to false if no copy is made. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayelements-routines. + pub inline fn get_int_array_elements( + env: *JNIEnv, + array: JIntArray, + is_copy: ?*JBoolean, + ) ?[*]JInt { + return JNIInterface.call( + env, + .get_int_array_elements, + .{ array, is_copy }, + ); + } + + /// Returns the body of the primitive array. + /// The result is valid until the corresponding release function is called. + /// If isCopy is not NULL, then *is_copy is set to true if a copy is made, + /// or it is set to false if no copy is made. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayelements-routines. + pub inline fn get_long_array_elements( + env: *JNIEnv, + array: JLongArray, + is_copy: ?*JBoolean, + ) ?[*]JLong { + return JNIInterface.call( + env, + .get_long_array_elements, + .{ array, is_copy }, + ); + } + + /// Returns the body of the primitive array. + /// The result is valid until the corresponding release function is called. + /// If isCopy is not NULL, then *is_copy is set to true if a copy is made, + /// or it is set to false if no copy is made. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayelements-routines. + pub inline fn get_float_array_elements( + env: *JNIEnv, + array: JFloatArray, + is_copy: ?*JBoolean, + ) ?[*]JFloat { + return JNIInterface.call( + env, + .get_float_array_elements, + .{ array, is_copy }, + ); + } + + /// Index 190: GetDoubleArrayElements + /// Returns the body of the primitive array. + /// The result is valid until the corresponding release function is called. + /// If isCopy is not NULL, then *is_copy is set to true if a copy is made, + /// or it is set to false if no copy is made. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayelements-routines. + pub inline fn get_double_array_elements( + env: *JNIEnv, + array: JDoubleArray, + is_copy: ?*JBoolean, + ) ?[*]JDouble { + return JNIInterface.call( + env, + .get_double_array_elements, + .{ array, is_copy }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#releaseprimitivetypearrayelements-routines. + pub inline fn release_boolean_array_elements( + env: *JNIEnv, + array: JBooleanArray, + elems: [*]JBoolean, + mode: JArrayReleaseMode, + ) void { + JNIInterface.call( + env, + .release_boolean_array_elements, + .{ array, elems, mode }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#releaseprimitivetypearrayelements-routines. + pub inline fn release_byte_array_elements( + env: *JNIEnv, + array: JByteArray, + elems: [*]JByte, + mode: JArrayReleaseMode, + ) void { + JNIInterface.call( + env, + .release_byte_array_elements, + .{ array, elems, mode }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#releaseprimitivetypearrayelements-routines. + pub inline fn release_char_array_elements( + env: *JNIEnv, + array: JCharArray, + elems: [*]JChar, + mode: JArrayReleaseMode, + ) void { + JNIInterface.call( + env, + .release_char_array_elements, + .{ array, elems, mode }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#releaseprimitivetypearrayelements-routines. + pub inline fn release_short_array_elements( + env: *JNIEnv, + array: JShortArray, + elems: [*]JShort, + mode: JArrayReleaseMode, + ) void { + JNIInterface.call( + env, + .release_short_array_elements, + .{ array, elems, mode }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#releaseprimitivetypearrayelements-routines. + pub inline fn release_int_array_elements( + env: *JNIEnv, + array: JIntArray, + elems: [*]JInt, + mode: JArrayReleaseMode, + ) void { + JNIInterface.call( + env, + .release_int_array_elements, + .{ array, elems, mode }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#releaseprimitivetypearrayelements-routines. + pub inline fn release_long_array_elements( + env: *JNIEnv, + array: JLongArray, + elems: [*]JLong, + mode: JArrayReleaseMode, + ) void { + JNIInterface.call( + env, + .release_long_array_elements, + .{ array, elems, mode }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#releaseprimitivetypearrayelements-routines. + pub inline fn release_float_array_elements( + env: *JNIEnv, + array: JFloatArray, + elems: [*]JFloat, + mode: JArrayReleaseMode, + ) void { + JNIInterface.call( + env, + .release_float_array_elements, + .{ array, elems, mode }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#releaseprimitivetypearrayelements-routines. + pub inline fn release_double_array_elements( + env: *JNIEnv, + array: JDoubleArray, + elems: [*]JDouble, + mode: JArrayReleaseMode, + ) void { + JNIInterface.call( + env, + .release_double_array_elements, + .{ array, elems, mode }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayregion-routines. + pub inline fn get_boolean_array_region( + env: *JNIEnv, + array: JBooleanArray, + start: JSize, + len: JSize, + buf: [*]JBoolean, + ) void { + JNIInterface.call( + env, + .get_boolean_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayregion-routines. + pub inline fn get_byte_array_region( + env: *JNIEnv, + array: JByteArray, + start: JSize, + len: JSize, + buf: [*]JByte, + ) void { + JNIInterface.call( + env, + .get_byte_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayregion-routines. + pub inline fn get_char_array_region( + env: *JNIEnv, + array: JCharArray, + start: JSize, + len: JSize, + buf: [*]JChar, + ) void { + JNIInterface.call( + env, + .get_char_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayregion-routines. + pub inline fn get_short_array_region( + env: *JNIEnv, + array: JShortArray, + start: JSize, + len: JSize, + buf: [*]JShort, + ) void { + JNIInterface.call( + env, + .get_short_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayregion-routines. + pub inline fn get_int_array_region( + env: *JNIEnv, + array: JIntArray, + start: JSize, + len: JSize, + buf: [*]JInt, + ) void { + JNIInterface.call( + env, + .get_int_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayregion-routines. + pub inline fn get_long_array_region( + env: *JNIEnv, + array: JLongArray, + start: JSize, + len: JSize, + buf: [*]JLong, + ) void { + JNIInterface.call( + env, + .get_long_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayregion-routines. + pub inline fn get_float_array_region( + env: *JNIEnv, + array: JFloatArray, + start: JSize, + len: JSize, + buf: [*]JFloat, + ) void { + JNIInterface.call( + env, + .get_float_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivetypearrayregion-routines. + pub inline fn get_double_array_region( + env: *JNIEnv, + array: JDoubleArray, + start: JSize, + len: JSize, + buf: [*]JDouble, + ) void { + JNIInterface.call( + env, + .get_double_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setprimitivetypearrayregion-routines. + pub inline fn set_boolean_array_region( + env: *JNIEnv, + array: JBooleanArray, + start: JSize, + len: JSize, + buf: [*]const JBoolean, + ) void { + JNIInterface.call( + env, + .set_boolean_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setprimitivetypearrayregion-routines. + pub inline fn set_byte_array_region( + env: *JNIEnv, + array: JByteArray, + start: JSize, + len: JSize, + buf: [*]const JByte, + ) void { + JNIInterface.call( + env, + .set_byte_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setprimitivetypearrayregion-routines. + pub inline fn set_char_array_region( + env: *JNIEnv, + array: JCharArray, + start: JSize, + len: JSize, + buf: [*]const JChar, + ) void { + JNIInterface.call( + env, + .set_char_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setprimitivetypearrayregion-routines. + pub inline fn set_short_array_region( + env: *JNIEnv, + array: JShortArray, + start: JSize, + len: JSize, + buf: [*]const JShort, + ) void { + JNIInterface.call( + env, + .set_short_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setprimitivetypearrayregion-routines. + pub inline fn set_int_array_region( + env: *JNIEnv, + array: JIntArray, + start: JSize, + len: JSize, + buf: [*]const JInt, + ) void { + JNIInterface.call( + env, + .set_int_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setprimitivetypearrayregion-routines. + pub inline fn set_long_array_region( + env: *JNIEnv, + array: JLongArray, + start: JSize, + len: JSize, + buf: [*]const JLong, + ) void { + JNIInterface.call( + env, + .set_long_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setprimitivetypearrayregion-routines. + pub inline fn set_float_array_region( + env: *JNIEnv, + array: JFloatArray, + start: JSize, + len: JSize, + buf: [*]const JFloat, + ) void { + JNIInterface.call( + env, + .set_float_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#setprimitivetypearrayregion-routines. + pub inline fn set_double_array_region( + env: *JNIEnv, + array: JDoubleArray, + start: JSize, + len: JSize, + buf: [*]const JDouble, + ) void { + JNIInterface.call( + env, + .set_double_array_region, + .{ array, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#registernatives. + pub inline fn register_natives( + env: *JNIEnv, + clazz: JClass, + methods: [*]const JNINativeMethod, + methods_len: JInt, + ) JNIResultType { + return JNIInterface.call( + env, + .register_natives, + .{ clazz, methods, methods_len }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#unregisternatives. + pub inline fn unregister_natives( + env: *JNIEnv, + clazz: JClass, + ) JNIResultType { + return JNIInterface.call( + env, + .unregister_natives, + .{clazz}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#monitorenter. + pub inline fn monitor_enter( + env: *JNIEnv, + obj: JObject, + ) JNIResultType { + return JNIInterface.call( + env, + .monitor_enter, + .{obj}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#monitorexit. + pub inline fn monitor_exit( + env: *JNIEnv, + obj: JObject, + ) JNIResultType { + return JNIInterface.call( + env, + .monitor_exit, + .{obj}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getjavavm. + pub inline fn get_java_vm( + env: *JNIEnv, + vm: **JavaVM, + ) JNIResultType { + return JNIInterface.call( + env, + .get_java_vm, + .{vm}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstringregion. + pub inline fn get_string_region( + env: *JNIEnv, + string: JString, + start: JSize, + len: JSize, + buf: [*]JChar, + ) void { + JNIInterface.call( + env, + .get_string_region, + .{ string, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstringutfregion. + pub inline fn get_string_utf_region( + env: *JNIEnv, + string: JString, + start: JSize, + len: JSize, + buf: [*]u8, + ) void { + JNIInterface.call( + env, + .get_string_utf_region, + .{ string, start, len, buf }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getprimitivearraycritical. + pub inline fn get_primitive_array_critical( + env: *JNIEnv, + array: JArray, + is_copy: ?*JBoolean, + ) ?*anyopaque { + return JNIInterface.call( + env, + .get_primitive_array_critical, + .{ array, is_copy }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#releaseprimitivearraycritical. + pub inline fn release_primitive_array_critical( + env: *JNIEnv, + array: JArray, + c_array: *anyopaque, + mode: JArrayReleaseMode, + ) void { + JNIInterface.call( + env, + .release_primitive_array_critical, + .{ array, c_array, mode }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstringcritical-releasestringcritical. + pub inline fn get_string_critical( + env: *JNIEnv, + string: JString, + is_copy: ?*JBoolean, + ) ?[*]const JChar { + return JNIInterface.call( + env, + .get_string_critical, + .{ string, is_copy }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstringcritical-releasestringcritical. + pub inline fn release_string_critical( + env: *JNIEnv, + string: JString, + chars: [*]const JChar, + ) void { + JNIInterface.call( + env, + .release_string_critical, + .{ string, chars }, + ); + } + + /// Returns NULL if obj refers to null, or if the VM runs out of memory. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newweakglobalref. + pub inline fn new_weak_global_ref( + env: *JNIEnv, + obj: JObject, + ) JWeakReference { + return JNIInterface.call( + env, + .new_weak_global_ref, + .{obj}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#deleteweakglobalref. + pub inline fn delete_weak_global_ref( + env: *JNIEnv, + ref: JWeakReference, + ) void { + JNIInterface.call( + env, + .delete_weak_global_ref, + .{ref}, + ); + } + + /// Returns true when there is a pending exception; otherwise, returns false. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#exceptioncheck. + pub inline fn exception_check( + env: *JNIEnv, + ) JBoolean { + return JNIInterface.call( + env, + .exception_check, + .{}, + ); + } + + /// Allocates and returns a direct java.nio.ByteBuffer. + /// Returns NULL if an exception occurs, + /// or if JNI access to direct buffers is not supported by this virtual machine. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#newdirectbytebuffer. + pub inline fn new_direct_byte_buffer( + env: *JNIEnv, + address: *anyopaque, + capacity: JLong, + ) JObject { + return JNIInterface.call( + env, + .new_direct_byte_buffer, + .{ address, capacity }, + ); + } + + /// Returns the starting address of the memory region referenced by the buffer. + /// Returns NULL if the memory region is undefined, + /// if the given object is not a direct java.nio.Buffer, + /// or if JNI access to direct buffers is not supported by this virtual machine. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getdirectbufferaddress. + pub inline fn get_direct_buffer_address( + env: *JNIEnv, + buf: JObject, + ) ?[*]u8 { + return JNIInterface.call( + env, + .get_direct_buffer_address, + .{buf}, + ); + } + + /// Returns the capacity of the memory region associated with the buffer. + /// Returns -1 if the given object is not a direct java.nio.Buffer, + /// if the object is an unaligned view buffer and the processor architecture + /// does not support unaligned access, + /// or if JNI access to direct buffers is not supported by this virtual machine. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getdirectbuffercapacity. + pub inline fn get_direct_buffer_capacity( + env: *JNIEnv, + buf: JObject, + ) JLong { + return JNIInterface.call( + env, + .get_direct_buffer_capacity, + .{buf}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getobjectreftype. + pub inline fn get_object_ref_type( + env: *JNIEnv, + obj: JObject, + ) JObjectRefType { + return JNIInterface.call( + env, + .get_object_ref_type, + .{obj}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getmodule. + pub inline fn get_module( + env: *JNIEnv, + clazz: JClass, + ) JObject { + return JNIInterface.call( + env, + .get_module, + .{clazz}, + ); + } +}; + +/// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html#jni_createjavavm. +pub const JavaVMOption = extern struct { + /// The option as a string in the default platform encoding. + option_string: [*:0]const u8, + extra_info: ?*anyopaque = null, +}; + +/// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html#jni_createjavavm. +pub const JavaVMInitArgs = extern struct { + version: JInt, + options_len: JInt, + options: ?[*]JavaVMOption, + ignore_unrecognized: JBoolean, +}; + +/// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html#attachcurrentthread. +pub const JavaVMAttachArgs = extern struct { + version: JInt, + name: [*:0]const u8, + group: JObject, +}; + +/// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html +pub const JavaVM = opaque { + /// Each function is accessible at a fixed offset through the JavaVM argument. + /// The JavaVM type is a pointer to the invocation API function table. + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html#invocation-api-functions. + const FunctionTable = enum(usize) { + /// Index 3: DestroyJavaVM. + destroy_java_vm = 3, + + /// Index 4: AttachCurrentThread. + attach_current_thread = 4, + + /// Index 5: DetachCurrentThread. + detach_current_thread = 5, + + /// Index 6: GetEnv. + get_env = 6, + + /// Index 7: AttachCurrentThreadAsDaemon. + attach_current_thread_as_daemon = 7, + }; + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html#jni_getdefaultjavavminitargs. + pub const get_default_java_vm_init_args = struct { + extern "jvm" fn JNI_GetDefaultJavaVMInitArgs( + vm_args: ?*JavaVMInitArgs, + ) callconv(.c) JNIResultType; + }.JNI_GetDefaultJavaVMInitArgs; + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html#jni_get_createdjavavm. + pub const get_created_java_vm = struct { + extern "jvm" fn JNI_GetCreatedJavaVMs( + vm_buf: [*]*JavaVM, + buf_len: JSize, + vm_len: ?*JSize, + ) callconv(.c) JNIResultType; + }.JNI_GetCreatedJavaVMs; + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html#jni_createjavavm. + pub const create_java_vm = struct { + extern "jvm" fn JNI_CreateJavaVM( + jvm: **JavaVM, + env: **JNIEnv, + args: *JavaVMInitArgs, + ) callconv(.c) JNIResultType; + }.JNI_CreateJavaVM; + + const JNIInterface = JNIInterfaceType(JavaVM); + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html#destroyjavavm. + pub inline fn destroy_java_vm( + vm: *JavaVM, + ) JNIResultType { + return JNIInterface.call( + vm, + .destroy_java_vm, + .{}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html#attachcurrentthread. + pub inline fn attach_current_thread( + vm: *JavaVM, + env: **JNIEnv, + args: ?*JavaVMAttachArgs, + ) JNIResultType { + return JNIInterface.call( + vm, + .attach_current_thread, + .{ env, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html#detachcurrentthread. + pub inline fn detach_current_thread( + vm: *JavaVM, + ) JNIResultType { + return JNIInterface.call( + vm, + .detach_current_thread, + .{}, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html#attach_currentthreadasdaemon. + pub inline fn attach_current_thread_as_daemon( + vm: *JavaVM, + env: **JNIEnv, + args: ?*JavaVMAttachArgs, + ) JNIResultType { + return JNIInterface.call( + vm, + .attach_current_thread_as_daemon, + .{ env, args }, + ); + } + + /// https://docs.oracle.com/en/java/javase/17/docs/specs/jni/invocation.html#getenv + pub inline fn get_env( + vm: *JavaVM, + env: **JNIEnv, + version: JInt, + ) JNIResultType { + return JNIInterface.call( + vm, + .get_env, + .{ env, version }, + ); + } +}; + +/// Invokes a function at the offset of the vtable, allowing to utilize the function pointer +/// without the need to declare a VTable layout, where the ordering of fields defines the ABI. +/// The function index is stored within T.FunctionTable enum, which only holds the index value. +/// The function signature is declared as an inline function within the T opaque type. +fn JNIInterfaceType(comptime T: type) type { + return struct { + fn JniFnType(comptime function: T.FunctionTable) type { + const Fn = @TypeOf(@field(T, @tagName(function))); + var fn_info = @typeInfo(Fn); + switch (fn_info) { + .@"fn" => { + fn_info.@"fn".calling_convention = .c; + return @Type(fn_info); + }, + else => @compileError("Expected " ++ @tagName(function) ++ " to be a function"), + } + } + + pub inline fn call( + self: *T, + comptime function: T.FunctionTable, + args: anytype, + ) return_type: { + const type_info = @typeInfo(JniFnType(function)); + break :return_type type_info.@"fn".return_type.?; + } { + const Fn = JniFnType(function); + const VTable = extern struct { + functions: [*]const *const anyopaque, + }; + + const vtable: *VTable = @ptrCast(@alignCast(self)); + const fn_ptr: *const Fn = @ptrCast(@alignCast( + vtable.functions[@intFromEnum(function)], + )); + return @call(.auto, fn_ptr, .{self} ++ args); + } + }; +} diff --git a/ocam/src/clients/java/src/jni_tests.zig b/ocam/src/clients/java/src/jni_tests.zig new file mode 100644 index 00000000..e112fdf2 --- /dev/null +++ b/ocam/src/clients/java/src/jni_tests.zig @@ -0,0 +1,1722 @@ +///! This test hosts an in-process JVM +///! using the JNI Invocation API. +const std = @import("std"); +const assert = std.debug.assert; +const jni = @import("jni.zig"); +const testing = std.testing; + +const JavaVM = jni.JavaVM; +const JNIEnv = jni.JNIEnv; + +test { + _ = @import("jni_thread_cleaner.zig"); +} + +test "JNI: check jvm" { + const env: *JNIEnv = get_testing_env(); + + var jvm: *JavaVM = undefined; + const get_java_vm_result = env.get_java_vm(&jvm); + try testing.expectEqual(jni.JNIResultType.ok, get_java_vm_result); + + var vm_buf: [2]*jni.JavaVM = undefined; + var vm_len: jni.JSize = 0; + const get_created_java_vm_result = JavaVM.get_created_java_vm( + &vm_buf, + @intCast(vm_buf.len), + &vm_len, + ); + + try testing.expectEqual(jni.JNIResultType.ok, get_created_java_vm_result); + try testing.expect(vm_len == 1); + try testing.expectEqual(jvm, vm_buf[0]); +} + +test "JNI: GetVersion" { + const env: *JNIEnv = get_testing_env(); + + const version = env.get_version(); + try testing.expect(version >= jni.jni_version_10); +} + +test "JNI: FindClass" { + const env: *JNIEnv = get_testing_env(); + + const object_class = env.find_class("java/lang/Object"); + try testing.expect(object_class != null); + defer env.delete_local_ref(object_class); + + const not_found = env.find_class("no/such/Class"); + defer env.exception_clear(); + + try testing.expect(not_found == null); + try testing.expect(env.exception_check() == .jni_true); +} + +test "JNI: GetSuperclass" { + const env: *JNIEnv = get_testing_env(); + + const object_class = env.find_class("java/lang/Object"); + try testing.expect(object_class != null); + defer env.delete_local_ref(object_class); + + try testing.expect(env.get_super_class(object_class) == null); + + const string_class = env.find_class("java/lang/String"); + try testing.expect(string_class != null); + defer env.delete_local_ref(string_class); + + const super_class = env.get_super_class(string_class); + try testing.expect(super_class != null); + defer env.delete_local_ref(super_class); + + try testing.expect(env.is_same_object(object_class, super_class) == .jni_true); +} + +test "JNI: IsAssignableFrom" { + const env: *JNIEnv = get_testing_env(); + + const object_class = env.find_class("java/lang/Object"); + try testing.expect(object_class != null); + defer env.delete_local_ref(object_class); + + try testing.expect(env.get_super_class(object_class) == null); + + const string_class = env.find_class("java/lang/String"); + try testing.expect(string_class != null); + defer env.delete_local_ref(string_class); + + const long_class = env.find_class("java/lang/Long"); + try testing.expect(long_class != null); + defer env.delete_local_ref(long_class); + + try testing.expect(env.is_assignable_from(long_class, object_class) == .jni_true); + try testing.expect(env.is_assignable_from(long_class, string_class) == .jni_false); +} + +test "JNI: GetModule" { + const env: *JNIEnv = get_testing_env(); + + const exception_class = env.find_class("java/lang/Exception"); + try testing.expect(exception_class != null); + defer env.delete_local_ref(exception_class); + + const module = env.get_module(exception_class); + try testing.expect(module != null); + defer env.delete_local_ref(module); +} + +test "JNI: Throw" { + const env: *JNIEnv = get_testing_env(); + + const exception_class = env.find_class("java/lang/Exception"); + try testing.expect(exception_class != null); + defer env.delete_local_ref(exception_class); + + const exception = env.alloc_object(exception_class); + try testing.expect(exception != null); + defer env.delete_local_ref(exception); + + const throw_result = env.throw(exception); + try testing.expectEqual(jni.JNIResultType.ok, throw_result); + defer env.exception_clear(); + + try testing.expect(env.exception_check() == .jni_true); +} + +test "JNI: ThrowNew" { + const env: *JNIEnv = get_testing_env(); + + const exception_class = env.find_class("java/lang/Exception"); + try testing.expect(exception_class != null); + defer env.delete_local_ref(exception_class); + + try testing.expect(env.exception_check() == .jni_false); + + const throw_new_result = env.throw_new(exception_class, ""); + try testing.expectEqual(jni.JNIResultType.ok, throw_new_result); + defer env.exception_clear(); + + try testing.expect(env.exception_check() == .jni_true); +} + +test "JNI: ExceptionOccurred" { + const env: *JNIEnv = get_testing_env(); + + try testing.expect(env.exception_occurred() == null); + + const exception_class = env.find_class("java/lang/Exception"); + try testing.expect(exception_class != null); + defer env.delete_local_ref(exception_class); + + const throw_new_result = env.throw_new(exception_class, ""); + try testing.expectEqual(jni.JNIResultType.ok, throw_new_result); + + const exception_occurred = env.exception_occurred(); + try testing.expect(exception_occurred != null); + defer env.delete_local_ref(exception_occurred); + + env.exception_clear(); + + try testing.expect(env.exception_occurred() == null); +} + +test "JNI: ExceptionDescribe" { + const env: *JNIEnv = get_testing_env(); + + try testing.expect(env.exception_check() == .jni_false); + + const exception_class = env.find_class("java/lang/Exception"); + try testing.expect(exception_class != null); + defer env.delete_local_ref(exception_class); + + const throw_new_result = env.throw_new( + exception_class, + "EXCEPTION DESCRIBED CORRECTLY", + ); + try testing.expectEqual(jni.JNIResultType.ok, throw_new_result); + try testing.expect(env.exception_check() == .jni_true); + defer env.exception_clear(); + + env.exception_describe(); +} + +test "JNI: ExceptionClear" { + const env: *JNIEnv = get_testing_env(); + + // Asserting that calling it is a no-op here: + try testing.expect(env.exception_check() == .jni_false); + env.exception_clear(); + try testing.expect(env.exception_check() == .jni_false); + + const exception_class = env.find_class("java/lang/Exception"); + try testing.expect(exception_class != null); + defer env.delete_local_ref(exception_class); + + const throw_new_result = env.throw_new(exception_class, ""); + try testing.expectEqual(jni.JNIResultType.ok, throw_new_result); + + // Asserting that calling it clears the current exception: + try testing.expect(env.exception_check() == .jni_true); + env.exception_clear(); + try testing.expect(env.exception_check() == .jni_false); +} + +test "JNI: ExceptionCheck" { + const env: *JNIEnv = get_testing_env(); + + try testing.expect(env.exception_check() == .jni_false); + + const object_class = env.find_class("java/lang/Object"); + try testing.expect(object_class != null); + defer env.delete_local_ref(object_class); + + const to_string = env.get_method_id(object_class, "toString", "()Ljava/lang/String;"); + try testing.expect(to_string != null); + + // Expected null reference exception: + const result = env.call_object_method(null, to_string, null); + try testing.expect(result == null); + + try testing.expect(env.exception_check() == .jni_true); + env.exception_clear(); + try testing.expect(env.exception_check() == .jni_false); +} + +test "JNI: References" { + const env: *JNIEnv = get_testing_env(); + + const boolean_class = env.find_class("java/lang/Boolean"); + try testing.expect(boolean_class != null); + defer env.delete_local_ref(boolean_class); + + const obj = env.alloc_object(boolean_class); + defer env.delete_local_ref(obj); + + try testing.expect(obj != null); + try testing.expect(env.get_object_ref_type(obj) == .local); + + // Local ref: + { + const local_ref = env.new_local_ref(obj); + try testing.expect(local_ref != null); + + try testing.expect(env.is_same_object(obj, local_ref) == .jni_true); + try testing.expect(env.get_object_ref_type(local_ref) == .local); + + env.delete_local_ref(local_ref); + try testing.expect(env.new_local_ref(local_ref) == null); + } + + // Global ref: + { + const global_ref = env.new_global_ref(obj); + try testing.expect(global_ref != null); + + try testing.expect(env.is_same_object(obj, global_ref) == .jni_true); + try testing.expect(env.get_object_ref_type(global_ref) == .global); + + env.delete_global_ref(global_ref); + try testing.expect(env.get_object_ref_type(global_ref) == .invalid); + } + + // Weak global ref: + { + const weak_global_ref = env.new_weak_global_ref(obj); + try testing.expect(weak_global_ref != null); + + try testing.expect(env.is_same_object(obj, weak_global_ref) == .jni_true); + try testing.expect(env.get_object_ref_type(weak_global_ref) == .weak_global); + + env.delete_weak_global_ref(weak_global_ref); + try testing.expect(env.get_object_ref_type(weak_global_ref) == .invalid); + } +} + +test "JNI: LocalFrame" { + const env: *JNIEnv = get_testing_env(); + + // Creating a new local frame. + const push_local_frame_result = env.push_local_frame(1); + try testing.expectEqual(jni.JNIResultType.ok, push_local_frame_result); + + const ensure_local_capacity_result = env.ensure_local_capacity(10); + try testing.expectEqual(jni.JNIResultType.ok, ensure_local_capacity_result); + + const boolean_class = env.find_class("java/lang/Boolean"); + try testing.expect(boolean_class != null); + defer env.delete_local_ref(boolean_class); + + const local_ref = env.alloc_object(boolean_class); + try testing.expect(local_ref != null); + + // All local references must be invalidated after this frame being dropped, + // except by the frame result. + const pop_local_frame_result = env.pop_local_frame(local_ref); + try testing.expect(pop_local_frame_result != null); + defer env.delete_local_ref(pop_local_frame_result); + + const valid_reference = env.get_object_ref_type(pop_local_frame_result); + try testing.expect(valid_reference == .local); + try testing.expect(pop_local_frame_result != local_ref); +} + +test "JNI: AllocObject" { + const env: *JNIEnv = get_testing_env(); + + // Concrete type: + { + const boolean_class = env.find_class("java/lang/Boolean"); + try testing.expect(boolean_class != null); + defer env.delete_local_ref(boolean_class); + + const obj = env.alloc_object(boolean_class); + defer env.delete_local_ref(obj); + + try testing.expect(obj != null); + } + + // Interface: + { + const serializable_interface = env.find_class("java/io/Serializable"); + try testing.expect(serializable_interface != null); + defer env.delete_local_ref(serializable_interface); + + const obj = env.alloc_object(serializable_interface); + defer env.exception_clear(); + + try testing.expect(obj == null); + try testing.expect(env.exception_check() == .jni_true); + } + + // Abstract class: + { + const calendar_abstract_class = env.find_class("java/util/Calendar"); + try testing.expect(calendar_abstract_class != null); + defer env.delete_local_ref(calendar_abstract_class); + + const obj = env.alloc_object(calendar_abstract_class); + defer env.exception_clear(); + + try testing.expect(obj == null); + try testing.expect(env.exception_check() == .jni_true); + } +} + +test "JNI: NewObject" { + const env: *JNIEnv = get_testing_env(); + + const string_buffer_class = env.find_class("java/lang/StringBuffer"); + try testing.expect(string_buffer_class != null); + defer env.delete_local_ref(string_buffer_class); + + const capacity_ctor = env.get_method_id(string_buffer_class, "", "(I)V"); + try testing.expect(capacity_ctor != null); + + const capacity: jni.JInt = 42; + const obj = env.new_object( + string_buffer_class, + capacity_ctor, + &[_]jni.JValue{jni.JValue.to_jvalue(capacity)}, + ); + try testing.expect(obj != null); + defer env.delete_local_ref(obj); +} + +test "JNI: IsInstanceOf" { + const env: *JNIEnv = get_testing_env(); + + const boolean_class = env.find_class("java/lang/Boolean"); + try testing.expect(boolean_class != null); + defer env.delete_local_ref(boolean_class); + + const obj = env.alloc_object(boolean_class); + defer env.delete_local_ref(obj); + + try testing.expect(obj != null); + + try testing.expect(env.is_instance_of(obj, boolean_class) == .jni_true); + + const long_class = env.find_class("java/lang/Long"); + try testing.expect(long_class != null); + defer env.delete_local_ref(long_class); + + try testing.expect(env.is_instance_of(obj, long_class) == .jni_false); +} + +test "JNI: GetFieldId" { + const env: *JNIEnv = get_testing_env(); + + const boolean_class = env.find_class("java/lang/Boolean"); + try testing.expect(boolean_class != null); + defer env.delete_local_ref(boolean_class); + + const value_field_id = env.get_field_id(boolean_class, "value", "Z"); + try testing.expect(value_field_id != null); + + const invalid_field = env.get_field_id(boolean_class, "not_a_valid_field", "I"); + defer env.exception_clear(); + + try testing.expect(invalid_field == null); + try testing.expect(env.exception_check() == .jni_true); +} + +test "JNI: GetField, SetField" { + const env: *JNIEnv = get_testing_env(); + + // Boolean: + { + const boolean_class = env.find_class("java/lang/Boolean"); + try testing.expect(boolean_class != null); + defer env.delete_local_ref(boolean_class); + + const value_field_id = env.get_field_id(boolean_class, "value", "Z"); + try testing.expect(value_field_id != null); + + const obj = env.alloc_object(boolean_class); + defer env.delete_local_ref(obj); + + try testing.expect(obj != null); + + const value_before = env.get_boolean_field(obj, value_field_id); + try testing.expect(value_before == .jni_false); + + env.set_boolean_field(obj, value_field_id, .jni_true); + try testing.expect(env.exception_check() == .jni_false); + + const value_after = env.get_boolean_field(obj, value_field_id); + try testing.expect(value_after == .jni_true); + } + + // Byte: + { + const byte_class = env.find_class("java/lang/Byte"); + try testing.expect(byte_class != null); + defer env.delete_local_ref(byte_class); + + const value_field_id = env.get_field_id(byte_class, "value", "B"); + try testing.expect(value_field_id != null); + + const obj = env.alloc_object(byte_class); + defer env.delete_local_ref(obj); + + try testing.expect(obj != null); + + const value_before = env.get_byte_field(obj, value_field_id); + try testing.expect(value_before == 0); + + env.set_byte_field(obj, value_field_id, 127); + try testing.expect(env.exception_check() == .jni_false); + + const value_after = env.get_byte_field(obj, value_field_id); + try testing.expect(value_after == 127); + } + + // Char: + { + const char_class = env.find_class("java/lang/Character"); + try testing.expect(char_class != null); + defer env.delete_local_ref(char_class); + + const value_field_id = env.get_field_id(char_class, "value", "C"); + try testing.expect(value_field_id != null); + + const obj = env.alloc_object(char_class); + defer env.delete_local_ref(obj); + + try testing.expect(obj != null); + + const value_before = env.get_char_field(obj, value_field_id); + try testing.expect(value_before == 0); + + env.set_char_field(obj, value_field_id, 'A'); + try testing.expect(env.exception_check() == .jni_false); + + const value_after = env.get_char_field(obj, value_field_id); + try testing.expect(value_after == 'A'); + } + + // Short: + { + const short_class = env.find_class("java/lang/Short"); + try testing.expect(short_class != null); + defer env.delete_local_ref(short_class); + + const value_field_id = env.get_field_id(short_class, "value", "S"); + try testing.expect(value_field_id != null); + + const obj = env.alloc_object(short_class); + defer env.delete_local_ref(obj); + + try testing.expect(obj != null); + + const value_before = env.get_short_field(obj, value_field_id); + try testing.expect(value_before == 0); + + env.set_short_field(obj, value_field_id, 9999); + try testing.expect(env.exception_check() == .jni_false); + + const value_after = env.get_short_field(obj, value_field_id); + try testing.expect(value_after == 9999); + } + + // Int: + { + const int_class = env.find_class("java/lang/Integer"); + try testing.expect(int_class != null); + defer env.delete_local_ref(int_class); + + const value_field_id = env.get_field_id(int_class, "value", "I"); + try testing.expect(value_field_id != null); + + const obj = env.alloc_object(int_class); + defer env.delete_local_ref(obj); + + try testing.expect(obj != null); + + const value_before = env.get_int_field(obj, value_field_id); + try testing.expect(value_before == 0); + + env.set_int_field(obj, value_field_id, 999_999); + try testing.expect(env.exception_check() == .jni_false); + + const value_after = env.get_int_field(obj, value_field_id); + try testing.expect(value_after == 999_999); + } + + // Long: + { + const long_class = env.find_class("java/lang/Long"); + try testing.expect(long_class != null); + defer env.delete_local_ref(long_class); + + const value_field_id = env.get_field_id(long_class, "value", "J"); + try testing.expect(value_field_id != null); + + const obj = env.alloc_object(long_class); + defer env.delete_local_ref(obj); + + try testing.expect(obj != null); + + const value_before = env.get_long_field(obj, value_field_id); + try testing.expect(value_before == 0); + + env.set_long_field(obj, value_field_id, 9_999_999_999); + try testing.expect(env.exception_check() == .jni_false); + + const value_after = env.get_long_field(obj, value_field_id); + try testing.expect(value_after == 9_999_999_999); + } + + // Float: + { + const float_class = env.find_class("java/lang/Float"); + try testing.expect(float_class != null); + defer env.delete_local_ref(float_class); + + const value_field_id = env.get_field_id(float_class, "value", "F"); + try testing.expect(value_field_id != null); + + const obj = env.alloc_object(float_class); + defer env.delete_local_ref(obj); + + try testing.expect(obj != null); + + const value_before = env.get_float_field(obj, value_field_id); + try testing.expect(value_before == 0); + + env.set_float_field(obj, value_field_id, 9.99); + try testing.expect(env.exception_check() == .jni_false); + + const value_after = env.get_float_field(obj, value_field_id); + try testing.expect(value_after == 9.99); + } + + // Double: + { + const double_class = env.find_class("java/lang/Double"); + try testing.expect(double_class != null); + defer env.delete_local_ref(double_class); + + const value_field_id = env.get_field_id(double_class, "value", "D"); + try testing.expect(value_field_id != null); + + const obj = env.alloc_object(double_class); + defer env.delete_local_ref(obj); + + try testing.expect(obj != null); + + const value_before = env.get_double_field(obj, value_field_id); + try testing.expect(value_before == 0); + + env.set_double_field(obj, value_field_id, 9.99); + try testing.expect(env.exception_check() == .jni_false); + + const value_after = env.get_double_field(obj, value_field_id); + try testing.expect(value_after == 9.99); + } + + // Object: + { + const object_class = env.find_class("java/lang/Throwable"); + try testing.expect(object_class != null); + defer env.delete_local_ref(object_class); + + const value_field_id = env.get_field_id(object_class, "cause", "Ljava/lang/Throwable;"); + try testing.expect(value_field_id != null); + + const obj = env.alloc_object(object_class); + defer env.delete_local_ref(obj); + + try testing.expect(obj != null); + + const value_before = env.get_object_field(obj, value_field_id); + try testing.expect(value_before == null); + + env.set_object_field(obj, value_field_id, obj); + try testing.expect(env.exception_check() == .jni_false); + + const value_after = env.get_object_field(obj, value_field_id); + try testing.expect(value_after != null); + } +} + +test "JNI: GetMethodId" { + const env: *JNIEnv = get_testing_env(); + + const object_class = env.find_class("java/lang/Throwable"); + try testing.expect(object_class != null); + defer env.delete_local_ref(object_class); + + const method_id = env.get_method_id(object_class, "toString", "()Ljava/lang/String;"); + try testing.expect(method_id != null); + + const invalid_method = env.get_method_id(object_class, "invalid_method", "()V"); + defer env.exception_clear(); + + try testing.expect(invalid_method == null); + try testing.expect(env.exception_check() == .jni_true); +} + +test "JNI: CallMethod,CallNonVirtualMethod" { + const env: *JNIEnv = get_testing_env(); + + const buffer_class = env.find_class("java/nio/ByteBuffer"); + try testing.expect(buffer_class != null); + defer env.delete_local_ref(buffer_class); + + const direct_buffer_class = env.find_class("java/nio/DirectByteBuffer"); + try testing.expect(direct_buffer_class != null); + defer env.delete_local_ref(direct_buffer_class); + + const element: u8 = 42; + var native_buffer: [256]u8 = @splat(element); + const buffer = env.new_direct_byte_buffer(&native_buffer, @intCast(native_buffer.len)); + try testing.expect(buffer != null); + defer env.delete_local_ref(buffer); + + // Byte: + { + const method_id = env.get_method_id(buffer_class, "get", "()B"); + try testing.expect(method_id != null); + + const non_virtual_method_id = env.get_method_id(direct_buffer_class, "get", "()B"); + try testing.expect(non_virtual_method_id != null); + + const expected: jni.JByte = @bitCast(element); + + const read = env.call_byte_method(buffer, method_id, null); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read); + + const read_non_virtual = env.call_nonvirtual_byte_method( + buffer, + direct_buffer_class, + non_virtual_method_id, + null, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read_non_virtual); + } + + // Short: + { + const method_id = env.get_method_id(buffer_class, "getShort", "()S"); + try testing.expect(method_id != null); + + const non_virtual_method_id = env.get_method_id(direct_buffer_class, "getShort", "()S"); + try testing.expect(non_virtual_method_id != null); + + const Packed = packed struct { a: u8, b: u8 }; + const expected: jni.JShort = @bitCast(Packed{ + .a = element, + .b = element, + }); + + const read = env.call_short_method(buffer, method_id, null); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read); + + const read_non_virtual = env.call_nonvirtual_short_method( + buffer, + direct_buffer_class, + non_virtual_method_id, + null, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read_non_virtual); + } + + // Char: + { + const method_id = env.get_method_id(buffer_class, "getChar", "()C"); + try testing.expect(method_id != null); + + const non_virtual_method_id = env.get_method_id(direct_buffer_class, "getChar", "()C"); + try testing.expect(non_virtual_method_id != null); + + const Packed = packed struct { a: u8, b: u8 }; + const expected: jni.JChar = @bitCast(Packed{ + .a = element, + .b = element, + }); + + const read = env.call_char_method(buffer, method_id, null); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read); + + const read_non_virtual = env.call_nonvirtual_char_method( + buffer, + direct_buffer_class, + non_virtual_method_id, + null, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read_non_virtual); + } + + // Int: + { + const method_id = env.get_method_id(buffer_class, "getInt", "()I"); + try testing.expect(method_id != null); + + const non_virtual_method_id = env.get_method_id(direct_buffer_class, "getInt", "()I"); + try testing.expect(non_virtual_method_id != null); + + const Packed = packed struct { a: u8, b: u8, c: u8, d: u8 }; + const expected: jni.JInt = @bitCast(Packed{ + .a = element, + .b = element, + .c = element, + .d = element, + }); + + const read = env.call_int_method(buffer, method_id, null); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read); + + const read_non_virtual = env.call_nonvirtual_int_method( + buffer, + direct_buffer_class, + non_virtual_method_id, + null, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read_non_virtual); + } + + // Long: + { + const method_id = env.get_method_id(buffer_class, "getLong", "()J"); + try testing.expect(method_id != null); + + const non_virtual_method_id = env.get_method_id(direct_buffer_class, "getLong", "()J"); + try testing.expect(non_virtual_method_id != null); + + const Packed = packed struct { a: u8, b: u8, c: u8, d: u8, e: u8, f: u8, g: u8, h: u8 }; + const expected: jni.JLong = @bitCast(Packed{ + .a = element, + .b = element, + .c = element, + .d = element, + .e = element, + .f = element, + .g = element, + .h = element, + }); + + const read = env.call_long_method(buffer, method_id, null); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read); + + const read_non_virtual = env.call_nonvirtual_long_method( + buffer, + direct_buffer_class, + non_virtual_method_id, + null, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read_non_virtual); + } + + // Float: + { + const method_id = env.get_method_id(buffer_class, "getFloat", "()F"); + try testing.expect(method_id != null); + + const non_virtual_method_id = env.get_method_id(direct_buffer_class, "getFloat", "()F"); + try testing.expect(non_virtual_method_id != null); + + const Packed = packed struct { a: u8, b: u8, c: u8, d: u8 }; + const expected: jni.JFloat = @bitCast(Packed{ + .a = element, + .b = element, + .c = element, + .d = element, + }); + + const read = env.call_float_method(buffer, method_id, null); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read); + + const read_non_virtual = env.call_nonvirtual_float_method( + buffer, + direct_buffer_class, + non_virtual_method_id, + null, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read_non_virtual); + } + + // Double: + { + const method_id = env.get_method_id(buffer_class, "getDouble", "()D"); + try testing.expect(method_id != null); + + const non_virtual_method_id = env.get_method_id(direct_buffer_class, "getDouble", "()D"); + try testing.expect(non_virtual_method_id != null); + + const Packed = packed struct { a: u8, b: u8, c: u8, d: u8, e: u8, f: u8, g: u8, h: u8 }; + const expected: jni.JDouble = @bitCast(Packed{ + .a = element, + .b = element, + .c = element, + .d = element, + .e = element, + .f = element, + .g = element, + .h = element, + }); + + const read = env.call_double_method(buffer, method_id, null); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read); + + const read_non_virtual = env.call_nonvirtual_double_method( + buffer, + direct_buffer_class, + non_virtual_method_id, + null, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqual(expected, read_non_virtual); + } + + // Object: + { + const method_id = env.get_method_id(buffer_class, "put", "(B)Ljava/nio/ByteBuffer;"); + try testing.expect(method_id != null); + + const non_virtual_method_id = env.get_method_id( + direct_buffer_class, + "put", + "(B)Ljava/nio/ByteBuffer;", + ); + try testing.expect(non_virtual_method_id != null); + + const put = env.call_object_method(buffer, method_id, &[_]jni.JValue{ + jni.JValue.to_jvalue(@as(jni.JByte, 0)), + }); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(env.is_same_object(buffer, put) == .jni_true); + defer env.delete_local_ref(put); + + const put_non_virtual = env.call_nonvirtual_object_method( + buffer, + direct_buffer_class, + non_virtual_method_id, + &[_]jni.JValue{jni.JValue.to_jvalue(@as(jni.JByte, 0))}, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(env.is_same_object(buffer, put_non_virtual) == .jni_true); + defer env.delete_local_ref(put_non_virtual); + } +} + +test "JNI: GetStaticFieldId" { + const env: *JNIEnv = get_testing_env(); + + const boolean_class = env.find_class("java/lang/Boolean"); + try testing.expect(boolean_class != null); + defer env.delete_local_ref(boolean_class); + + const field_id = env.get_static_field_id(boolean_class, "serialVersionUID", "J"); + try testing.expect(field_id != null); + + const invalid_field_id = env.get_static_field_id(boolean_class, "invalid_field", "J"); + defer env.exception_clear(); + + try testing.expect(invalid_field_id == null); + try testing.expect(env.exception_check() == .jni_true); +} + +test "JNI: GetStaticField, SetStaticField" { + const env: *JNIEnv = get_testing_env(); + + // Byte: + { + const class = env.find_class("java/lang/Byte"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const field_id = env.get_static_field_id(class, "MIN_VALUE", "B"); + try testing.expect(field_id != null); + + const before = env.get_static_byte_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(before == -128); + + env.set_static_byte_field(class, field_id, -127); + try testing.expect(env.exception_check() == .jni_false); + + const after = env.get_static_byte_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(after == -127); + } + + // Char: + { + const class = env.find_class("java/lang/Character"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const field_id = env.get_static_field_id(class, "MIN_VALUE", "C"); + try testing.expect(field_id != null); + + const before = env.get_static_char_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(before == 0); + + env.set_static_char_field(class, field_id, 1); + try testing.expect(env.exception_check() == .jni_false); + + const after = env.get_static_char_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(after == 1); + } + + // Short: + { + const class = env.find_class("java/lang/Short"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const field_id = env.get_static_field_id(class, "MIN_VALUE", "S"); + try testing.expect(field_id != null); + + const before = env.get_static_short_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(before == -32768); + + env.set_static_short_field(class, field_id, -32767); + try testing.expect(env.exception_check() == .jni_false); + + const after = env.get_static_short_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(after == -32767); + } + + // Int: + { + const class = env.find_class("java/lang/Integer"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const field_id = env.get_static_field_id(class, "MIN_VALUE", "I"); + try testing.expect(field_id != null); + + const before = env.get_static_int_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(before == -2147483648); + + env.set_static_int_field(class, field_id, -2147483647); + try testing.expect(env.exception_check() == .jni_false); + + const after = env.get_static_int_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(after == -2147483647); + } + + // Long: + { + const class = env.find_class("java/lang/Long"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const field_id = env.get_static_field_id(class, "MIN_VALUE", "J"); + try testing.expect(field_id != null); + + const before = env.get_static_long_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(before == -9223372036854775808); + + env.set_static_long_field(class, field_id, -9223372036854775807); + try testing.expect(env.exception_check() == .jni_false); + + const after = env.get_static_long_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(after == -9223372036854775807); + } + + // Float: + { + const class = env.find_class("java/lang/Float"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const field_id = env.get_static_field_id(class, "MIN_VALUE", "F"); + try testing.expect(field_id != null); + + const before = env.get_static_float_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(before == 1.4E-45); + + env.set_static_float_field(class, field_id, 1.4E-44); + try testing.expect(env.exception_check() == .jni_false); + + const after = env.get_static_float_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(after == 1.4E-44); + } + + // Double: + { + const class = env.find_class("java/lang/Double"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const field_id = env.get_static_field_id(class, "MIN_VALUE", "D"); + try testing.expect(field_id != null); + + const before = env.get_static_double_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(before == 4.9E-324); + + env.set_static_double_field(class, field_id, 4.9E-323); + try testing.expect(env.exception_check() == .jni_false); + + const after = env.get_static_double_field(class, field_id); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(after == 4.9E-323); + } +} + +test "JNI: GetStaticMethodId" { + const env: *JNIEnv = get_testing_env(); + + const boolean_class = env.find_class("java/lang/Boolean"); + try testing.expect(boolean_class != null); + defer env.delete_local_ref(boolean_class); + + const method_id = env.get_static_method_id(boolean_class, "valueOf", "(Z)Ljava/lang/Boolean;"); + try testing.expect(method_id != null); + + const invalid_method_id = env.get_static_method_id(boolean_class, "invalid_method", "()J"); + defer env.exception_clear(); + + try testing.expect(invalid_method_id == null); + try testing.expect(env.exception_check() == .jni_true); +} + +test "JNI: CallStaticMethod" { + const env: *JNIEnv = get_testing_env(); + + const str = env.new_string_utf("1"); + try testing.expect(str != null); + defer env.delete_local_ref(str); + + // Boolean: + { + const class = env.find_class("java/lang/Boolean"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const method_id = env.get_static_method_id(class, "parseBoolean", "(Ljava/lang/String;)Z"); + try testing.expect(method_id != null); + + const ret = env.call_static_boolean_method( + class, + method_id, + &[_]jni.JValue{jni.JValue.to_jvalue(str)}, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(ret == .jni_false); + } + + // Byte: + { + const class = env.find_class("java/lang/Byte"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const method_id = env.get_static_method_id(class, "parseByte", "(Ljava/lang/String;)B"); + try testing.expect(method_id != null); + + const ret = env.call_static_byte_method( + class, + method_id, + &[_]jni.JValue{jni.JValue.to_jvalue(str)}, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(ret == 1); + } + + // Char: + { + const class = env.find_class("java/lang/Character"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const method_id = env.get_static_method_id(class, "toLowerCase", "(C)C"); + try testing.expect(method_id != null); + + const ret = env.call_static_char_method( + class, + method_id, + &[_]jni.JValue{jni.JValue.to_jvalue(@as(jni.JChar, 'A'))}, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(ret == 'a'); + } + + // Short: + { + const class = env.find_class("java/lang/Short"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const method_id = env.get_static_method_id(class, "parseShort", "(Ljava/lang/String;)S"); + try testing.expect(method_id != null); + + const ret = env.call_static_short_method( + class, + method_id, + &[_]jni.JValue{jni.JValue.to_jvalue(str)}, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(ret == 1); + } + + // Int: + { + const class = env.find_class("java/lang/Integer"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const method_id = env.get_static_method_id( + class, + "parse" ++ "Int", // dodge tidy + "(Ljava/lang/String;)I", + ); + try testing.expect(method_id != null); + + const ret = env.call_static_int_method( + class, + method_id, + &[_]jni.JValue{jni.JValue.to_jvalue(str)}, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(ret == 1); + } + + // Long: + { + const class = env.find_class("java/lang/Long"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const method_id = env.get_static_method_id(class, "parseLong", "(Ljava/lang/String;)J"); + try testing.expect(method_id != null); + + const ret = env.call_static_long_method( + class, + method_id, + &[_]jni.JValue{jni.JValue.to_jvalue(str)}, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(ret == 1); + } + + // Float: + { + const class = env.find_class("java/lang/Float"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const method_id = env.get_static_method_id(class, "parseFloat", "(Ljava/lang/String;)F"); + try testing.expect(method_id != null); + + const ret = env.call_static_float_method( + class, + method_id, + &[_]jni.JValue{jni.JValue.to_jvalue(str)}, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(ret == 1.0); + } + + // Double: + { + const class = env.find_class("java/lang/Double"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const method_id = env.get_static_method_id(class, "parseDouble", "(Ljava/lang/String;)D"); + try testing.expect(method_id != null); + + const ret = env.call_static_double_method( + class, + method_id, + &[_]jni.JValue{jni.JValue.to_jvalue(str)}, + ); + try testing.expect(env.exception_check() == .jni_false); + try testing.expect(ret == 1.0); + } + + // Object: + { + const class = env.find_class("java/lang/String"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const method_id = env.get_static_method_id( + class, + "valueOf", + "(Ljava/lang/Object;)Ljava/lang/String;", + ); + try testing.expect(method_id != null); + + const ret = env.call_static_object_method( + class, + method_id, + &[_]jni.JValue{jni.JValue.to_jvalue(str)}, + ); + try testing.expect(env.exception_check() == .jni_false); + defer env.delete_local_ref(ret); + + try testing.expect(env.is_instance_of(ret, class) == .jni_true); + } + + // Void: + { + const class = env.find_class("java/lang/System"); + try testing.expect(class != null); + defer env.delete_local_ref(class); + + const method_id = env.get_static_method_id(class, "gc", "()V"); + try testing.expect(method_id != null); + + env.call_static_void_method(class, method_id, null); + try testing.expect(env.exception_check() == .jni_false); + } +} + +test "JNI: strings" { + const env: *JNIEnv = get_testing_env(); + + const content: []const u16 = std.unicode.utf8ToUtf16LeStringLiteral("Hello utf16")[0..]; + const string = env.new_string( + content.ptr, + @intCast(content.len), + ); + try testing.expect(string != null); + defer env.delete_local_ref(string); + + const len = env.get_string_length(string); + try testing.expectEqual(content.len, @as(usize, @intCast(len))); + + const address = env.get_string_chars(string, null) orelse { + try testing.expect(false); + unreachable; + }; + defer env.release_string_chars(string, address); + + try testing.expectEqualSlices(u16, content[0..], address[0..@as(usize, @intCast(len))]); +} + +test "JNI: strings utf" { + const env: *JNIEnv = get_testing_env(); + + const content = "Hello utf8"; + const string = env.new_string_utf(content); + try testing.expect(string != null); + defer env.delete_local_ref(string); + + const len = env.get_string_utf_length(string); + try testing.expectEqual(content.len, @as(usize, @intCast(len))); + + const address = env.get_string_utf_chars(string, null) orelse { + try testing.expect(false); + unreachable; + }; + defer env.release_string_utf_chars(string, address); + + try testing.expectEqualSlices(u8, content[0..], address[0..@as(usize, @intCast(len))]); +} + +test "JNI: GetStringRegion" { + const env: *JNIEnv = get_testing_env(); + + const content: []const u16 = + std.unicode.utf8ToUtf16LeStringLiteral("ABCDEFGHIJKLMNOPQRSTUVXYZ")[0..]; + const string = env.new_string( + content.ptr, + @intCast(content.len), + ); + try testing.expect(string != null); + defer env.delete_local_ref(string); + + var buff: [10]jni.JChar = undefined; + env.get_string_region(string, 5, 10, &buff); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqualSlices(u16, content[5..][0..10], &buff); +} + +test "JNI: GetStringUTFRegion" { + const env: *JNIEnv = get_testing_env(); + + const content = "ABCDEFGHIJKLMNOPQRSTUVXYZ"; + const string = env.new_string_utf(content); + try testing.expect(string != null); + defer env.delete_local_ref(string); + + // From: https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html#getstringutfregion. + // The resulting number modified UTF-8 encoding characters may be greater than + // the given len argument. GetStringUTFLength() may be used to determine the + // maximum size of the required character buffer. + + var buff: [content.len]u8 = undefined; + env.get_string_utf_region(string, 5, 10, &buff); + try testing.expect(env.exception_check() == .jni_false); + try testing.expectEqualSlices(u8, content[5..][0..10], buff[0..10]); +} + +test "JNI: GetStringCritical" { + const env: *JNIEnv = get_testing_env(); + + const content: []const u16 = + std.unicode.utf8ToUtf16LeStringLiteral("ABCDEFGHIJKLMNOPQRSTUVXYZ")[0..]; + const str = env.new_string(content.ptr, @intCast(content.len)); + try testing.expect(str != null); + defer env.delete_local_ref(str); + + const len = env.get_string_length(str); + try testing.expectEqual(content.len, @as(usize, @intCast(len))); + + const region = env.get_string_critical(str, null) orelse { + try testing.expect(false); + unreachable; + }; + defer env.release_string_critical(str, region); + + try testing.expectEqualSlices(u16, content, region[0..@as(usize, @intCast(len))]); +} + +test "JNI: DirectByteBuffer" { + const env: *JNIEnv = get_testing_env(); + + var native_buffer = blk: { + var array: [32]u8 = undefined; + var value: u8 = array.len; + for (&array) |*byte| { + value -= 1; + byte.* = value; + } + break :blk array; + }; + + const buffer = env.new_direct_byte_buffer(&native_buffer, native_buffer.len); + try testing.expect(buffer != null); + defer env.delete_local_ref(buffer); + + const capacity = env.get_direct_buffer_capacity(buffer); + try testing.expect(capacity == native_buffer.len); + + const address = env.get_direct_buffer_address(buffer) orelse { + try testing.expect(false); + unreachable; + }; + + try testing.expectEqualSlices(u8, &native_buffer, address[0..@as(usize, @intCast(capacity))]); +} + +test "JNI: object array" { + const env: *JNIEnv = get_testing_env(); + + const object_class = env.find_class("java/lang/Object"); + try testing.expect(object_class != null); + defer env.delete_local_ref(object_class); + + const array = env.new_object_array(32, object_class, null); + try testing.expect(array != null); + defer env.delete_local_ref(array); + + // ArrayIndexOutOfBoundsException: + env.set_object_array_element(array, -1, null); + try testing.expect(env.exception_check() == .jni_true); + env.exception_clear(); + + env.set_object_array_element(array, 32, null); + try testing.expect(env.exception_check() == .jni_true); + env.exception_clear(); + + // Valid indexes: + var index: jni.JInt = 0; + while (index < 32) : (index += 1) { + const obj_before = env.get_object_array_element(array, index); + try testing.expect(obj_before == null); + + const obj = env.alloc_object(object_class); + try testing.expect(obj != null); + defer env.delete_local_ref(obj); + + env.set_object_array_element(array, index, obj); + try testing.expect(env.exception_check() == .jni_false); + + const obj_after = env.get_object_array_element(array, index); + try testing.expect(obj_after != null); + defer env.delete_local_ref(obj_after); + + try testing.expect(env.is_same_object(obj, obj_after) == .jni_true); + } +} + +test "JNI: primitive arrays" { + const ArrayTest = struct { + fn ArrayTestType(comptime PrimitiveType: type) type { + return struct { + fn cast(value: anytype) PrimitiveType { + return switch (PrimitiveType) { + jni.JBoolean => switch (value) { + 0 => jni.JBoolean.jni_false, + else => jni.JBoolean.jni_true, + }, + jni.JFloat, jni.JDouble => @floatFromInt(value), + else => @intCast(value), + }; + } + + fn get_array_elements(env: *jni.JNIEnv, array: jni.JArray) ?[*]PrimitiveType { + return switch (PrimitiveType) { + jni.JBoolean => env.get_boolean_array_elements(array, null), + jni.JByte => env.get_byte_array_elements(array, null), + jni.JShort => env.get_short_array_elements(array, null), + jni.JChar => env.get_char_array_elements(array, null), + jni.JInt => env.get_int_array_elements(array, null), + jni.JLong => env.get_long_array_elements(array, null), + jni.JFloat => env.get_float_array_elements(array, null), + jni.JDouble => env.get_double_array_elements(array, null), + else => unreachable, + }; + } + + fn release_array_elements( + env: *jni.JNIEnv, + array: jni.JArray, + elements: [*]PrimitiveType, + ) void { + switch (PrimitiveType) { + jni.JBoolean => env.release_boolean_array_elements( + array, + elements, + .default, + ), + jni.JByte => env.release_byte_array_elements( + array, + elements, + .default, + ), + jni.JShort => env.release_short_array_elements( + array, + elements, + .default, + ), + jni.JChar => env.release_char_array_elements( + array, + elements, + .default, + ), + jni.JInt => env.release_int_array_elements( + array, + elements, + .default, + ), + jni.JLong => env.release_long_array_elements( + array, + elements, + .default, + ), + jni.JFloat => env.release_float_array_elements( + array, + elements, + .default, + ), + jni.JDouble => env.release_double_array_elements( + array, + elements, + .default, + ), + else => unreachable, + } + } + + fn get_array_region( + env: *jni.JNIEnv, + array: jni.JArray, + start: jni.JSize, + len: jni.JSize, + buf: [*]PrimitiveType, + ) void { + switch (PrimitiveType) { + jni.JBoolean => env.get_boolean_array_region(array, start, len, buf), + jni.JByte => env.get_byte_array_region(array, start, len, buf), + jni.JShort => env.get_short_array_region(array, start, len, buf), + jni.JChar => env.get_char_array_region(array, start, len, buf), + jni.JInt => env.get_int_array_region(array, start, len, buf), + jni.JLong => env.get_long_array_region(array, start, len, buf), + jni.JFloat => env.get_float_array_region(array, start, len, buf), + jni.JDouble => env.get_double_array_region(array, start, len, buf), + else => unreachable, + } + } + + fn set_array_region( + env: *jni.JNIEnv, + array: jni.JArray, + start: jni.JSize, + len: jni.JSize, + buf: [*]PrimitiveType, + ) void { + switch (PrimitiveType) { + jni.JBoolean => env.set_boolean_array_region(array, start, len, buf), + jni.JByte => env.set_byte_array_region(array, start, len, buf), + jni.JShort => env.set_short_array_region(array, start, len, buf), + jni.JChar => env.set_char_array_region(array, start, len, buf), + jni.JInt => env.set_int_array_region(array, start, len, buf), + jni.JLong => env.set_long_array_region(array, start, len, buf), + jni.JFloat => env.set_float_array_region(array, start, len, buf), + jni.JDouble => env.set_double_array_region(array, start, len, buf), + else => unreachable, + } + } + + pub fn assert(env: *JNIEnv) !void { + const length = 32; + + const array = switch (PrimitiveType) { + jni.JBoolean => env.new_boolean_array(length), + jni.JByte => env.new_byte_array(length), + jni.JChar => env.new_char_array(length), + jni.JShort => env.new_short_array(length), + jni.JInt => env.new_int_array(length), + jni.JLong => env.new_long_array(length), + jni.JFloat => env.new_float_array(length), + jni.JDouble => env.new_double_array(length), + else => unreachable, + }; + + try testing.expect(array != null); + defer env.delete_local_ref(array); + + const len = env.get_array_length(array); + try testing.expect(len == length); + + // Change the array: + { + const elements = get_array_elements(env, array) orelse { + try testing.expect(false); + unreachable; + }; + defer release_array_elements(env, array, elements); + + for (elements[0..@as(usize, @intCast(len))], 0..) |*element, i| { + try testing.expectEqual(cast(0), element.*); + element.* = cast(i); + } + } + + // Check changes: + { + const elements = get_array_elements(env, array) orelse { + try testing.expect(false); + unreachable; + }; + defer release_array_elements(env, array, elements); + + for (elements[0..@as(usize, @intCast(len))], 0..) |element, i| { + try testing.expectEqual(cast(i), element); + } + } + + // ArrayRegion: + { + var buffer: [10]PrimitiveType = undefined; + + // ArrayIndexOutOfBoundsException: + get_array_region(env, array, -1, 10, &buffer); + try testing.expect(env.exception_check() == .jni_true); + env.exception_clear(); + + get_array_region(env, array, 0, 200, &buffer); + try testing.expect(env.exception_check() == .jni_true); + env.exception_clear(); + + // Correct bounds: + get_array_region(env, array, 5, 10, &buffer); + try testing.expect(env.exception_check() == .jni_false); + + for (&buffer, 0..) |*element, i| { + try testing.expectEqual(cast(i + 5), element.*); + element.* = cast(i); + } + + // ArrayIndexOutOfBoundsException: + set_array_region(env, array, -1, 10, &buffer); + try testing.expect(env.exception_check() == .jni_true); + env.exception_clear(); + + set_array_region(env, array, 0, 200, &buffer); + try testing.expect(env.exception_check() == .jni_true); + env.exception_clear(); + + // Correct bounds: + set_array_region(env, array, 5, 10, &buffer); + } + + // Check changes + { + var buffer: [10]PrimitiveType = undefined; + get_array_region(env, array, 5, 10, &buffer); + try testing.expect(env.exception_check() == .jni_false); + + for (buffer, 0..) |element, i| { + try testing.expectEqual(cast(i), element); + } + } + + // Critical + { + const critical = env.get_primitive_array_critical(array, null) orelse { + try testing.expect(false); + unreachable; + }; + defer env.release_primitive_array_critical(array, critical, .default); + + const elements: [*]PrimitiveType = @ptrCast(@alignCast(critical)); + for (elements[0..@intCast(len)], 0..) |*element, i| { + element.* = cast(i + 10); + } + } + + // Check changes + { + const critical = env.get_primitive_array_critical(array, null) orelse { + try testing.expect(false); + unreachable; + }; + defer env.release_primitive_array_critical(array, critical, .default); + + const elements: [*]PrimitiveType = @ptrCast(@alignCast(critical)); + for (elements[0..@intCast(len)], 0..) |element, i| { + try testing.expectEqual(cast(i + 10), element); + } + } + } + }; + } + }.ArrayTestType; + + const env: *JNIEnv = get_testing_env(); + + try ArrayTest(jni.JBoolean).assert(env); + try ArrayTest(jni.JByte).assert(env); + try ArrayTest(jni.JChar).assert(env); + try ArrayTest(jni.JShort).assert(env); + try ArrayTest(jni.JInt).assert(env); + try ArrayTest(jni.JLong).assert(env); + try ArrayTest(jni.JFloat).assert(env); + try ArrayTest(jni.JDouble).assert(env); +} + +const get_testing_env = struct { + var init = std.once(jvm_create); + var env: *JNIEnv = undefined; + + fn jvm_create() void { + var jvm: *jni.JavaVM = undefined; + var args = jni.JavaVMInitArgs{ + .version = jni.jni_version_10, + .options_len = 0, + .options = null, + .ignore_unrecognized = .jni_true, + }; + const jni_result = JavaVM.create_java_vm(&jvm, &env, &args); + assert(jni_result == .ok); + } + + pub fn get_env() *JNIEnv { + init.call(); + return env; + } +}.get_env; diff --git a/ocam/src/clients/java/src/jni_thread_cleaner.zig b/ocam/src/clients/java/src/jni_thread_cleaner.zig new file mode 100644 index 00000000..fa73afc2 --- /dev/null +++ b/ocam/src/clients/java/src/jni_thread_cleaner.zig @@ -0,0 +1,190 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const jni = @import("jni.zig"); + +const log = std.log.scoped(.tb_client_jni); +const assert = std.debug.assert; + +/// Helper for managing the `AttachCurrentThread`/`DetachCurrentThread` lifecycle +/// when the JNI layer is unaware of when the native thread exits. +/// https://developer.android.com/training/articles/perf-jni#threads +pub const JNIThreadCleaner = struct { + var tls_key: ?tls.Key = null; + var create_key_once = std.once(create_key); + + /// This function calls `AttachCurrentThreadAsDaemon` to attach the current native thread to + /// the JVM as a daemon thread. It also registers a callback to call `DetachCurrentThread` + /// when the thread exits (e.g., when the client is closed or evicted). + pub fn attach_current_thread_with_cleanup(jvm: *jni.JavaVM) *jni.JNIEnv { + // Create the tls key once per JVM. + create_key_once.call(); + + // Set the JVM handler to the thread-local storage slot for each time a native + // thread is started. + tls.set_key(tls_key.?, jvm); + + return attach_current_thread(jvm); + } + + /// Create the thread-local storage key and the corresponding destructor callback. + /// Note: We don't need to delete the key because the JNI module cannot be unloaded, + /// so it will always be available for the duration of the JVM process. + fn create_key() void { + assert(tls_key == null); + tls_key = tls.create_key(&destructor_callback); + } + + // Will be called by the OS with the JVM handler when the thread finalizes. + fn destructor_callback(jvm: *anyopaque) callconv(.c) void { + assert(tls_key != null); + detach_current_thread(@ptrCast(jvm)); + } + + fn attach_current_thread(jvm: *jni.JavaVM) *jni.JNIEnv { + var env: *jni.JNIEnv = undefined; + const jni_result = jvm.attach_current_thread_as_daemon(&env, null); + if (jni_result != .ok) { + const message = "Unexpected result calling JavaVM.AttachCurrentThreadAsDaemon"; + log.err( + message ++ "; Error = {} ({s})", + .{ @intFromEnum(jni_result), @tagName(jni_result) }, + ); + @panic("JNI: " ++ message); + } + + return env; + } + + fn detach_current_thread(jvm: *jni.JavaVM) void { + const jni_result = jvm.detach_current_thread(); + if (jni_result != .ok) { + const message = "Unexpected result calling JavaVM.DetachCurrentThread"; + log.err( + message ++ "; Error = {} ({s})", + .{ @intFromEnum(jni_result), @tagName(jni_result) }, + ); + @panic("JNI: " ++ message); + } + } + + /// Thread-local storage abstraction, + /// based on `pthread_key_create` for Linux/MacOS and `FlsAlloc` for Windows. + const tls = switch (builtin.os.tag) { + .linux, .macos => struct { + const Key = std.c.pthread_key_t; + + fn create_key(destructor: ?*const fn (value: *anyopaque) callconv(.c) void) Key { + var key: Key = undefined; + const ret = std.c.pthread_key_create(&key, destructor); + if (ret != .SUCCESS) { + const message = "Unexpected result calling pthread_key_create"; + log.err(message ++ "; Error = {} ({s})", .{ + @intFromEnum(ret), + @tagName(ret), + }); + @panic("JNI: " ++ message); + } + + return key; + } + + fn set_key(key: Key, value: *anyopaque) void { + const ret = std.c.pthread_setspecific(key, value); + if (ret != 0) { + const message = "Unexpected result calling pthread_setspecific"; + log.err(message ++ "; Error = {}", .{ret}); + @panic("JNI: " ++ message); + } + } + }, + .windows => struct { + const windows = struct { + const FLS_OUT_OF_INDEXES: std.os.windows.DWORD = 0xffffffff; + // https://learn.microsoft.com/en-us/windows/win32/api/fibersapi/nf-fibersapi-flsalloc + extern "kernel32" fn FlsAlloc( + ?*const fn (value: *anyopaque) callconv(.c) void, + ) callconv(.c) std.os.windows.DWORD; + // https://learn.microsoft.com/en-us/windows/win32/api/fibersapi/nf-fibersapi-flssetvalue + extern "kernel32" fn FlsSetValue( + std.os.windows.DWORD, + *anyopaque, + ) callconv(.c) std.os.windows.BOOL; + }; + + const Key = std.os.windows.DWORD; + + fn create_key(destructor: ?*const fn (value: *anyopaque) callconv(.c) void) Key { + const key = windows.FlsAlloc(destructor); + if (key == windows.FLS_OUT_OF_INDEXES) { + const message = "Unexpected result calling FlsAlloc"; + log.err(message ++ "; Error = {}", .{key}); + @panic("JNI: " ++ message); + } + + return key; + } + + fn set_key(key: Key, value: *anyopaque) void { + const ret = windows.FlsSetValue(key, value); + if (ret == std.os.windows.FALSE) { + const message = "Unexpected result calling FlsSetValue"; + log.err(message ++ "; Error = {}", .{ret}); + @panic("JNI: " ++ message); + } + } + }, + else => unreachable, + }; +}; + +test "JNIThreadCleaner:tls" { + const tls = JNIThreadCleaner.tls; + const TestContext = struct { + const TestContext = @This(); + + var tls_key: ?tls.Key = null; + var event: std.Thread.ResetEvent = .{}; + + counter: std.atomic.Value(u32), + + fn init() TestContext { + if (tls_key == null) { + tls_key = tls.create_key(&destructor_callback); + } + + return .{ + .counter = std.atomic.Value(u32).init(0), + }; + } + + fn thread_main(self: *TestContext) void { + tls.set_key(tls_key.?, self); + event.wait(); + } + + fn destructor_callback(tls_value: *anyopaque) callconv(.c) void { + assert(tls_key != null); + + const self: *TestContext = @ptrCast(@alignCast(tls_value)); + _ = self.counter.fetchAdd(1, .monotonic); + } + }; + + var context = TestContext.init(); + var threads: [10]std.Thread = undefined; + for (&threads) |*thread| { + thread.* = try std.Thread.spawn(.{}, TestContext.thread_main, .{&context}); + } + + // Assert that the callback only fires when the thread finishes. + try std.testing.expect(context.counter.load(.monotonic) == 0); + + // Signal all threads to complete and wait for them. + TestContext.event.set(); + for (&threads) |*thread| { + thread.join(); + } + + // Assert that all callbacks have fired. + try std.testing.expect(context.counter.load(.monotonic) == threads.len); +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountBalanceBatch.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountBalanceBatch.java new file mode 100644 index 00000000..80c71097 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountBalanceBatch.java @@ -0,0 +1,308 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +import java.nio.ByteBuffer; +import java.math.BigInteger; + +public final class AccountBalanceBatch extends Batch { + + + interface Struct { + int SIZE = 128; + + int DebitsPending = 0; + int DebitsPosted = 16; + int CreditsPending = 32; + int CreditsPosted = 48; + int Timestamp = 64; + int Reserved = 72; + } + + /** + * Creates an empty batch with the desired maximum capacity. + *

+ * Once created, an instance cannot be resized, however it may contain any number of elements + * between zero and its {@link #getCapacity capacity}. + * + * @param capacity the maximum capacity. + * @throws IllegalArgumentException if capacity is negative. + */ + public AccountBalanceBatch(final int capacity) { + super(capacity, Struct.SIZE); + } + + AccountBalanceBatch(final ByteBuffer buffer) { + super(buffer, Struct.SIZE); + } + + /** + * @return a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see debits_pending + */ + public BigInteger getDebitsPending() { + final var index = at(Struct.DebitsPending); + return UInt128.asBigInteger( + getUInt128(index, UInt128.LeastSignificant), + getUInt128(index, UInt128.MostSignificant)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see debits_pending + */ + public long getDebitsPending(final UInt128 part) { + return getUInt128(at(Struct.DebitsPending), part); + } + + /** + * @param debitsPending a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debits_pending + */ + void setDebitsPending(final BigInteger debitsPending) { + putUInt128(at(Struct.DebitsPending), UInt128.asBytes(debitsPending)); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debits_pending + */ + void setDebitsPending(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.DebitsPending), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debits_pending + */ + void setDebitsPending(final long leastSignificant) { + putUInt128(at(Struct.DebitsPending), leastSignificant, 0); + } + + /** + * @return a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see debits_posted + */ + public BigInteger getDebitsPosted() { + final var index = at(Struct.DebitsPosted); + return UInt128.asBigInteger( + getUInt128(index, UInt128.LeastSignificant), + getUInt128(index, UInt128.MostSignificant)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see debits_posted + */ + public long getDebitsPosted(final UInt128 part) { + return getUInt128(at(Struct.DebitsPosted), part); + } + + /** + * @param debitsPosted a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debits_posted + */ + void setDebitsPosted(final BigInteger debitsPosted) { + putUInt128(at(Struct.DebitsPosted), UInt128.asBytes(debitsPosted)); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debits_posted + */ + void setDebitsPosted(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.DebitsPosted), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debits_posted + */ + void setDebitsPosted(final long leastSignificant) { + putUInt128(at(Struct.DebitsPosted), leastSignificant, 0); + } + + /** + * @return a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see credits_pending + */ + public BigInteger getCreditsPending() { + final var index = at(Struct.CreditsPending); + return UInt128.asBigInteger( + getUInt128(index, UInt128.LeastSignificant), + getUInt128(index, UInt128.MostSignificant)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see credits_pending + */ + public long getCreditsPending(final UInt128 part) { + return getUInt128(at(Struct.CreditsPending), part); + } + + /** + * @param creditsPending a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credits_pending + */ + void setCreditsPending(final BigInteger creditsPending) { + putUInt128(at(Struct.CreditsPending), UInt128.asBytes(creditsPending)); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credits_pending + */ + void setCreditsPending(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.CreditsPending), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credits_pending + */ + void setCreditsPending(final long leastSignificant) { + putUInt128(at(Struct.CreditsPending), leastSignificant, 0); + } + + /** + * @return a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see credits_posted + */ + public BigInteger getCreditsPosted() { + final var index = at(Struct.CreditsPosted); + return UInt128.asBigInteger( + getUInt128(index, UInt128.LeastSignificant), + getUInt128(index, UInt128.MostSignificant)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see credits_posted + */ + public long getCreditsPosted(final UInt128 part) { + return getUInt128(at(Struct.CreditsPosted), part); + } + + /** + * @param creditsPosted a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credits_posted + */ + void setCreditsPosted(final BigInteger creditsPosted) { + putUInt128(at(Struct.CreditsPosted), UInt128.asBytes(creditsPosted)); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credits_posted + */ + void setCreditsPosted(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.CreditsPosted), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credits_posted + */ + void setCreditsPosted(final long leastSignificant) { + putUInt128(at(Struct.CreditsPosted), leastSignificant, 0); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see timestamp + */ + public long getTimestamp() { + final var value = getUInt64(at(Struct.Timestamp)); + return value; + } + + /** + * @param timestamp + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see timestamp + */ + void setTimestamp(final long timestamp) { + putUInt64(at(Struct.Timestamp), timestamp); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see reserved + */ + byte[] getReserved() { + return getArray(at(Struct.Reserved), 56); + } + + /** + * @param reserved + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see reserved + */ + void setReserved(byte[] reserved) { + if (reserved == null) + reserved = new byte[56]; + if (reserved.length != 56) + throw new IllegalArgumentException("Reserved must be 56 bytes long"); + putArray(at(Struct.Reserved), reserved); + } + +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountBatch.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountBatch.java new file mode 100644 index 00000000..398d2cba --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountBatch.java @@ -0,0 +1,515 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +import java.nio.ByteBuffer; +import java.math.BigInteger; + +public final class AccountBatch extends Batch { + + + interface Struct { + int SIZE = 128; + + int Id = 0; + int DebitsPending = 16; + int DebitsPosted = 32; + int CreditsPending = 48; + int CreditsPosted = 64; + int UserData128 = 80; + int UserData64 = 96; + int UserData32 = 104; + int Reserved = 108; + int Ledger = 112; + int Code = 116; + int Flags = 118; + int Timestamp = 120; + } + + /** + * Creates an empty batch with the desired maximum capacity. + *

+ * Once created, an instance cannot be resized, however it may contain any number of elements + * between zero and its {@link #getCapacity capacity}. + * + * @param capacity the maximum capacity. + * @throws IllegalArgumentException if capacity is negative. + */ + public AccountBatch(final int capacity) { + super(capacity, Struct.SIZE); + } + + AccountBatch(final ByteBuffer buffer) { + super(buffer, Struct.SIZE); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see id + */ + public byte[] getId() { + return getUInt128(at(Struct.Id)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see id + */ + public long getId(final UInt128 part) { + return getUInt128(at(Struct.Id), part); + } + + /** + * @param id an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code id} is not 16 bytes long. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see id + */ + public void setId(final byte[] id) { + putUInt128(at(Struct.Id), id); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see id + */ + public void setId(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.Id), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see id + */ + public void setId(final long leastSignificant) { + putUInt128(at(Struct.Id), leastSignificant, 0); + } + + /** + * @return a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see debits_pending + */ + public BigInteger getDebitsPending() { + final var index = at(Struct.DebitsPending); + return UInt128.asBigInteger( + getUInt128(index, UInt128.LeastSignificant), + getUInt128(index, UInt128.MostSignificant)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see debits_pending + */ + public long getDebitsPending(final UInt128 part) { + return getUInt128(at(Struct.DebitsPending), part); + } + + /** + * @param debitsPending a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debits_pending + */ + void setDebitsPending(final BigInteger debitsPending) { + putUInt128(at(Struct.DebitsPending), UInt128.asBytes(debitsPending)); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debits_pending + */ + void setDebitsPending(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.DebitsPending), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debits_pending + */ + void setDebitsPending(final long leastSignificant) { + putUInt128(at(Struct.DebitsPending), leastSignificant, 0); + } + + /** + * @return a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see debits_posted + */ + public BigInteger getDebitsPosted() { + final var index = at(Struct.DebitsPosted); + return UInt128.asBigInteger( + getUInt128(index, UInt128.LeastSignificant), + getUInt128(index, UInt128.MostSignificant)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see debits_posted + */ + public long getDebitsPosted(final UInt128 part) { + return getUInt128(at(Struct.DebitsPosted), part); + } + + /** + * @param debitsPosted a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debits_posted + */ + void setDebitsPosted(final BigInteger debitsPosted) { + putUInt128(at(Struct.DebitsPosted), UInt128.asBytes(debitsPosted)); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debits_posted + */ + void setDebitsPosted(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.DebitsPosted), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debits_posted + */ + void setDebitsPosted(final long leastSignificant) { + putUInt128(at(Struct.DebitsPosted), leastSignificant, 0); + } + + /** + * @return a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see credits_pending + */ + public BigInteger getCreditsPending() { + final var index = at(Struct.CreditsPending); + return UInt128.asBigInteger( + getUInt128(index, UInt128.LeastSignificant), + getUInt128(index, UInt128.MostSignificant)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see credits_pending + */ + public long getCreditsPending(final UInt128 part) { + return getUInt128(at(Struct.CreditsPending), part); + } + + /** + * @param creditsPending a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credits_pending + */ + void setCreditsPending(final BigInteger creditsPending) { + putUInt128(at(Struct.CreditsPending), UInt128.asBytes(creditsPending)); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credits_pending + */ + void setCreditsPending(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.CreditsPending), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credits_pending + */ + void setCreditsPending(final long leastSignificant) { + putUInt128(at(Struct.CreditsPending), leastSignificant, 0); + } + + /** + * @return a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see credits_posted + */ + public BigInteger getCreditsPosted() { + final var index = at(Struct.CreditsPosted); + return UInt128.asBigInteger( + getUInt128(index, UInt128.LeastSignificant), + getUInt128(index, UInt128.MostSignificant)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see credits_posted + */ + public long getCreditsPosted(final UInt128 part) { + return getUInt128(at(Struct.CreditsPosted), part); + } + + /** + * @param creditsPosted a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credits_posted + */ + void setCreditsPosted(final BigInteger creditsPosted) { + putUInt128(at(Struct.CreditsPosted), UInt128.asBytes(creditsPosted)); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credits_posted + */ + void setCreditsPosted(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.CreditsPosted), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credits_posted + */ + void setCreditsPosted(final long leastSignificant) { + putUInt128(at(Struct.CreditsPosted), leastSignificant, 0); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see user_data_128 + */ + public byte[] getUserData128() { + return getUInt128(at(Struct.UserData128)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see user_data_128 + */ + public long getUserData128(final UInt128 part) { + return getUInt128(at(Struct.UserData128), part); + } + + /** + * @param userData128 an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code userData128} is not 16 bytes long. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see user_data_128 + */ + public void setUserData128(final byte[] userData128) { + putUInt128(at(Struct.UserData128), userData128); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see user_data_128 + */ + public void setUserData128(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.UserData128), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see user_data_128 + */ + public void setUserData128(final long leastSignificant) { + putUInt128(at(Struct.UserData128), leastSignificant, 0); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see user_data_64 + */ + public long getUserData64() { + final var value = getUInt64(at(Struct.UserData64)); + return value; + } + + /** + * @param userData64 + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see user_data_64 + */ + public void setUserData64(final long userData64) { + putUInt64(at(Struct.UserData64), userData64); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see user_data_32 + */ + public int getUserData32() { + final var value = getUInt32(at(Struct.UserData32)); + return value; + } + + /** + * @param userData32 + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see user_data_32 + */ + public void setUserData32(final int userData32) { + putUInt32(at(Struct.UserData32), userData32); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see reserved + */ + int getReserved() { + final var value = getUInt32(at(Struct.Reserved)); + return value; + } + + /** + * @param reserved + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see reserved + */ + void setReserved(final int reserved) { + putUInt32(at(Struct.Reserved), reserved); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see ledger + */ + public int getLedger() { + final var value = getUInt32(at(Struct.Ledger)); + return value; + } + + /** + * @param ledger + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see ledger + */ + public void setLedger(final int ledger) { + putUInt32(at(Struct.Ledger), ledger); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see code + */ + public int getCode() { + final var value = getUInt16(at(Struct.Code)); + return value; + } + + /** + * @param code + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see code + */ + public void setCode(final int code) { + putUInt16(at(Struct.Code), code); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see flags + */ + public int getFlags() { + final var value = getUInt16(at(Struct.Flags)); + return value; + } + + /** + * @param flags + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see flags + */ + public void setFlags(final int flags) { + putUInt16(at(Struct.Flags), flags); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see timestamp + */ + public long getTimestamp() { + final var value = getUInt64(at(Struct.Timestamp)); + return value; + } + + /** + * @param timestamp + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see timestamp + */ + public void setTimestamp(final long timestamp) { + putUInt64(at(Struct.Timestamp), timestamp); + } + +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountFilter.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountFilter.java new file mode 100644 index 00000000..5288b790 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountFilter.java @@ -0,0 +1,287 @@ +package com.tigerbeetle; + +public final class AccountFilter { + + // @formatter:off + /* + * Summary: + * + * Wraps the `AccountFilterBatch` auto-generated binding in a single-item batch. + * Since `getAccountTransfers()` expects only one item, we avoid exposing the `Batch` class externally. + * + * This is an ad-hoc feature meant to be replaced by a proper querying API shortly, + * therefore, it is not worth the effort to modify the binding generator to emit single-item batchs. + * + */ + // @formatter:on + + AccountFilterBatch batch; + + public AccountFilter() { + this.batch = new AccountFilterBatch(1); + this.batch.add(); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @see account_id + */ + public byte[] getAccountId() { + return batch.getAccountId(); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value is to be + * retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @see account_id + */ + public long getAccountId(final UInt128 part) { + return batch.getAccountId(part); + } + + /** + * @param accountId an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code id} is not 16 bytes long. + * @see account_id + */ + public void setAccountId(final byte[] accountId) { + batch.setAccountId(accountId); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @see account_id + */ + public void setAccountId(final long leastSignificant, final long mostSignificant) { + batch.setAccountId(leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @see account_id + */ + public void setAccountId(final long leastSignificant) { + batch.setAccountId(leastSignificant); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @see user_data_128 + */ + public byte[] getUserData128() { + return this.batch.getUserData128(); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value is to be + * retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @see user_data_128 + */ + public long getUserData128(final UInt128 part) { + return this.batch.getUserData128(part); + } + + /** + * @param userData128 an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code userData128} is not 16 bytes long. + * @see user_data_128 + */ + public void setUserData128(final byte[] userData128) { + this.batch.setUserData128(userData128); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @see user_data_128 + */ + public void setUserData128(final long leastSignificant, final long mostSignificant) { + this.batch.setUserData128(leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @see user_data_128 + */ + public void setUserData128(final long leastSignificant) { + this.batch.setUserData128(leastSignificant); + } + + /** + * @see user_data_64 + */ + public long getUserData64() { + return this.batch.getUserData64(); + } + + /** + * @param userData64 + * @see user_data_64 + */ + public void setUserData64(final long userData64) { + this.batch.setUserData64(userData64); + } + + /** + * @see user_data_32 + */ + public int getUserData32() { + return this.batch.getUserData32(); + } + + /** + * @param userData32 + * @see user_data_32 + */ + public void setUserData32(final int userData32) { + this.batch.setUserData32(userData32); + } + + /** + * @see code + */ + public int getCode() { + return this.batch.getCode(); + } + + /** + * @param code + * @see code + */ + public void setCode(final int code) { + this.batch.setCode(code); + } + + /** + * @see timestamp_min + */ + public long getTimestampMin() { + return batch.getTimestampMin(); + } + + /** + * @param timestamp + * @see timestamp_min + */ + public void setTimestampMin(final long timestamp) { + batch.setTimestampMin(timestamp); + } + + /** + * @see timestamp_max + */ + public long getTimestampMax() { + return batch.getTimestampMax(); + } + + /** + * @param timestamp + * @see timestamp_max + */ + public void setTimestampMax(final long timestamp) { + batch.setTimestampMax(timestamp); + } + + /** + * @see limit + */ + public int getLimit() { + return batch.getLimit(); + } + + /** + * @param limit + * @see limit + */ + public void setLimit(final int limit) { + batch.setLimit(limit); + } + + /** + * @see debits + */ + public boolean getDebits() { + return getFlags(AccountFilterFlags.DEBITS); + } + + /** + * @param value + * @see debits + */ + public void setDebits(boolean value) { + setFlags(AccountFilterFlags.DEBITS, value); + } + + /** + * @see credits + */ + public boolean getCredits() { + return getFlags(AccountFilterFlags.CREDITS); + } + + /** + * @param value + * @see credits + */ + public void setCredits(boolean value) { + setFlags(AccountFilterFlags.CREDITS, value); + } + + /** + * @see reversed + */ + public boolean getReversed() { + return getFlags(AccountFilterFlags.REVERSED); + } + + /** + * @param value + * @see reversed + */ + public void setReversed(boolean value) { + setFlags(AccountFilterFlags.REVERSED, value); + } + + boolean getFlags(final int flag) { + final var value = batch.getFlags(); + return (value & flag) != 0; + } + + void setFlags(final int flag, final boolean enabled) { + var value = batch.getFlags(); + if (enabled) + value |= flag; + else + value &= ~flag; + batch.setFlags(value); + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountFilterBatch.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountFilterBatch.java new file mode 100644 index 00000000..c0d44f58 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountFilterBatch.java @@ -0,0 +1,284 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +import java.nio.ByteBuffer; + + +final class AccountFilterBatch extends Batch { + + + interface Struct { + int SIZE = 128; + + int AccountId = 0; + int UserData128 = 16; + int UserData64 = 32; + int UserData32 = 40; + int Code = 44; + int Reserved = 46; + int TimestampMin = 104; + int TimestampMax = 112; + int Limit = 120; + int Flags = 124; + } + + /** + * Creates an empty batch with the desired maximum capacity. + *

+ * Once created, an instance cannot be resized, however it may contain any number of elements + * between zero and its {@link #getCapacity capacity}. + * + * @param capacity the maximum capacity. + * @throws IllegalArgumentException if capacity is negative. + */ + public AccountFilterBatch(final int capacity) { + super(capacity, Struct.SIZE); + } + + AccountFilterBatch(final ByteBuffer buffer) { + super(buffer, Struct.SIZE); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public byte[] getAccountId() { + return getUInt128(at(Struct.AccountId)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public long getAccountId(final UInt128 part) { + return getUInt128(at(Struct.AccountId), part); + } + + /** + * @param accountId an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code accountId} is not 16 bytes long. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setAccountId(final byte[] accountId) { + putUInt128(at(Struct.AccountId), accountId); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setAccountId(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.AccountId), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setAccountId(final long leastSignificant) { + putUInt128(at(Struct.AccountId), leastSignificant, 0); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public byte[] getUserData128() { + return getUInt128(at(Struct.UserData128)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public long getUserData128(final UInt128 part) { + return getUInt128(at(Struct.UserData128), part); + } + + /** + * @param userData128 an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code userData128} is not 16 bytes long. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setUserData128(final byte[] userData128) { + putUInt128(at(Struct.UserData128), userData128); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setUserData128(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.UserData128), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setUserData128(final long leastSignificant) { + putUInt128(at(Struct.UserData128), leastSignificant, 0); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public long getUserData64() { + final var value = getUInt64(at(Struct.UserData64)); + return value; + } + + /** + * @param userData64 + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setUserData64(final long userData64) { + putUInt64(at(Struct.UserData64), userData64); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public int getUserData32() { + final var value = getUInt32(at(Struct.UserData32)); + return value; + } + + /** + * @param userData32 + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setUserData32(final int userData32) { + putUInt32(at(Struct.UserData32), userData32); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public int getCode() { + final var value = getUInt16(at(Struct.Code)); + return value; + } + + /** + * @param code + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setCode(final int code) { + putUInt16(at(Struct.Code), code); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + byte[] getReserved() { + return getArray(at(Struct.Reserved), 58); + } + + /** + * @param reserved + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + void setReserved(byte[] reserved) { + if (reserved == null) + reserved = new byte[58]; + if (reserved.length != 58) + throw new IllegalArgumentException("Reserved must be 58 bytes long"); + putArray(at(Struct.Reserved), reserved); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public long getTimestampMin() { + final var value = getUInt64(at(Struct.TimestampMin)); + return value; + } + + /** + * @param timestampMin + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setTimestampMin(final long timestampMin) { + putUInt64(at(Struct.TimestampMin), timestampMin); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public long getTimestampMax() { + final var value = getUInt64(at(Struct.TimestampMax)); + return value; + } + + /** + * @param timestampMax + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setTimestampMax(final long timestampMax) { + putUInt64(at(Struct.TimestampMax), timestampMax); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public int getLimit() { + final var value = getUInt32(at(Struct.Limit)); + return value; + } + + /** + * @param limit + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setLimit(final int limit) { + putUInt32(at(Struct.Limit), limit); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public int getFlags() { + final var value = getUInt32(at(Struct.Flags)); + return value; + } + + /** + * @param flags + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setFlags(final int flags) { + putUInt32(at(Struct.Flags), flags); + } + +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountFilterFlags.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountFilterFlags.java new file mode 100644 index 00000000..8a4e8dfa --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountFilterFlags.java @@ -0,0 +1,26 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +interface AccountFilterFlags { + int NONE = (int) 0; + int DEBITS = (int) (1 << 0); + int CREDITS = (int) (1 << 1); + int REVERSED = (int) (1 << 2); + + static boolean hasDebits(final int flags) { + return (flags & DEBITS) == DEBITS; + } + + static boolean hasCredits(final int flags) { + return (flags & CREDITS) == CREDITS; + } + + static boolean hasReversed(final int flags) { + return (flags & REVERSED) == REVERSED; + } + +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountFlags.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountFlags.java new file mode 100644 index 00000000..875142cd --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AccountFlags.java @@ -0,0 +1,65 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +public interface AccountFlags { + int NONE = (int) 0; + + /** + * @see linked + */ + int LINKED = (int) (1 << 0); + + /** + * @see debits_must_not_exceed_credits + */ + int DEBITS_MUST_NOT_EXCEED_CREDITS = (int) (1 << 1); + + /** + * @see credits_must_not_exceed_debits + */ + int CREDITS_MUST_NOT_EXCEED_DEBITS = (int) (1 << 2); + + /** + * @see history + */ + int HISTORY = (int) (1 << 3); + + /** + * @see imported + */ + int IMPORTED = (int) (1 << 4); + + /** + * @see closed + */ + int CLOSED = (int) (1 << 5); + + static boolean hasLinked(final int flags) { + return (flags & LINKED) == LINKED; + } + + static boolean hasDebitsMustNotExceedCredits(final int flags) { + return (flags & DEBITS_MUST_NOT_EXCEED_CREDITS) == DEBITS_MUST_NOT_EXCEED_CREDITS; + } + + static boolean hasCreditsMustNotExceedDebits(final int flags) { + return (flags & CREDITS_MUST_NOT_EXCEED_DEBITS) == CREDITS_MUST_NOT_EXCEED_DEBITS; + } + + static boolean hasHistory(final int flags) { + return (flags & HISTORY) == HISTORY; + } + + static boolean hasImported(final int flags) { + return (flags & IMPORTED) == IMPORTED; + } + + static boolean hasClosed(final int flags) { + return (flags & CLOSED) == CLOSED; + } + +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/AssertionError.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AssertionError.java new file mode 100644 index 00000000..a8473401 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AssertionError.java @@ -0,0 +1,25 @@ +package com.tigerbeetle; + +public final class AssertionError extends java.lang.AssertionError { + AssertionError(String format, Object... args) { + super(String.format(format, args)); + } + + AssertionError(Throwable cause, String format, Object... args) { + super(String.format(format, args), cause); + } + + AssertionError() { + super(); + } + + public static void assertTrue(boolean condition) { + if (!condition) + throw new AssertionError(); + } + + public static void assertTrue(boolean condition, String format, Object... args) { + if (!condition) + throw new AssertionError(format, args); + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/AsyncRequest.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AsyncRequest.java new file mode 100644 index 00000000..a699795f --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/AsyncRequest.java @@ -0,0 +1,106 @@ +package com.tigerbeetle; + +import java.util.concurrent.CompletableFuture; + +final class AsyncRequest extends Request { + + // @formatter:off + /* + * Overview: + * + * Implements a Request to be used when invoked asynchronously. + * Exposes a CompletableFuture to be awaited by an executor or thread pool until signaled as completed by the TB's callback. + * + * See BlockingRequest.java for the sync implementation. + * + */ + // @formatter:on + + private final CompletableFuture future; + + AsyncRequest(final NativeClient nativeClient, final Operations operation, final Batch batch) { + super(nativeClient, operation, batch); + + future = new CompletableFuture(); + } + + public static AsyncRequest createAccounts( + final NativeClient nativeClient, final AccountBatch batch) { + return new AsyncRequest(nativeClient, + Request.Operations.CREATE_ACCOUNTS, batch); + } + + public static AsyncRequest lookupAccounts(final NativeClient nativeClient, + final IdBatch batch) { + return new AsyncRequest(nativeClient, Request.Operations.LOOKUP_ACCOUNTS, + batch); + } + + public static AsyncRequest createTransfers( + final NativeClient nativeClient, final TransferBatch batch) { + return new AsyncRequest(nativeClient, + Request.Operations.CREATE_TRANSFERS, batch); + } + + public static AsyncRequest lookupTransfers(final NativeClient nativeClient, + final IdBatch batch) { + return new AsyncRequest(nativeClient, Request.Operations.LOOKUP_TRANSFERS, + batch); + } + + public static AsyncRequest getAccountTransfers(final NativeClient nativeClient, + final AccountFilter filter) { + return new AsyncRequest(nativeClient, + Request.Operations.GET_ACCOUNT_TRANSFERS, filter.batch); + } + + public static AsyncRequest getAccountBalances( + final NativeClient nativeClient, final AccountFilter filter) { + return new AsyncRequest(nativeClient, + Request.Operations.GET_ACCOUNT_BALANCES, filter.batch); + } + + public static AsyncRequest queryAccounts(final NativeClient nativeClient, + final QueryFilter filter) { + return new AsyncRequest(nativeClient, Request.Operations.QUERY_ACCOUNTS, + filter.batch); + } + + public static AsyncRequest queryTransfers(final NativeClient nativeClient, + final QueryFilter filter) { + return new AsyncRequest(nativeClient, Request.Operations.QUERY_TRANSFERS, + filter.batch); + } + + public static AsyncRequest echo(final NativeClient nativeClient, + final AccountBatch batch) { + return new AsyncRequest(nativeClient, Request.Operations.ECHO_ACCOUNTS, + batch); + } + + public static AsyncRequest echo(final NativeClient nativeClient, + final TransferBatch batch) { + return new AsyncRequest(nativeClient, Request.Operations.ECHO_TRANSFERS, + batch); + } + + public CompletableFuture getFuture() { + return future; + } + + @Override + protected void setResult(final TResponse result) { + final var completed = future.complete(result); + if (!completed) { + throw new IllegalStateException("Request has already been completed"); + } + } + + @Override + protected void setException(final Throwable exception) { + final var completed = future.completeExceptionally(exception); + if (!completed) { + throw new IllegalStateException("Request has already been completed"); + } + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/Batch.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/Batch.java new file mode 100644 index 00000000..006131f3 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/Batch.java @@ -0,0 +1,337 @@ +package com.tigerbeetle; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Objects; +import static com.tigerbeetle.AssertionError.assertTrue; + +/** + * A Batch is contiguous memory block representing a collection of elements of the same type with a + * cursor pointing to its current position. + *

+ * Initially the cursor is positioned before the first element and must be positioned by calling + * {@link #next}, {@link #add}, or {@link #setPosition} prior to reading or writing an element. + */ +public abstract class Batch { + /** + * Information about the protocol header for debugging and diagnostic purposes. This is a + * temporary API and may be removed without notice. + */ + public static class Header { + private final long timestamp; + + Header(final long timestamp) { + this.timestamp = timestamp; + } + + /** + * Gets the cluster timestamp when the reply was generated. + */ + public final long getTimestamp() { + return this.timestamp; + } + } + + // @formatter:off + /* + * Overview + * + * Batch uses a ByteArray to hold direct memory that both the Java side and the JNI side can access. + * + * We expose the API using the concept of "Cursor" familiar to JDBC's ResultSet, where a single Batch + * instance points to multiple elements depending on the cursor position, eliminating individual instances + * for each element. + * + */ + // @formatter:off + + private enum CursorStatus { + + Begin, + Valid, + End; + + public static final int INVALID_POSITION = -1; + } + + final static ByteOrder BYTE_ORDER = ByteOrder.nativeOrder(); + + static { + // We require little-endian architectures everywhere for efficient network + // deserialization: + assertTrue(BYTE_ORDER == ByteOrder.LITTLE_ENDIAN, "Native byte order LITTLE ENDIAN expected"); + } + + private int position; + private CursorStatus cursorStatus; + private int length; + + private Header header = null; + + private final int capacity; + private final ByteBuffer buffer; + + private final int ELEMENT_SIZE; + + Batch(final int capacity, final int ELEMENT_SIZE) { + + assertTrue(ELEMENT_SIZE > 0, "Element size cannot be zero or negative"); + + if (capacity < 0) throw new IllegalArgumentException("Buffer capacity cannot be negative"); + if ((long)capacity * (long)ELEMENT_SIZE > Integer.MAX_VALUE) + throw new IllegalArgumentException("Buffer capacity overflows"); + + this.ELEMENT_SIZE = ELEMENT_SIZE; + + this.length = 0; + this.capacity = capacity; + + this.position = CursorStatus.INVALID_POSITION; + this.cursorStatus = CursorStatus.Begin; + + final var bufferCapacity = capacity * ELEMENT_SIZE; + this.buffer = ByteBuffer.allocateDirect(bufferCapacity).order(BYTE_ORDER); + } + + Batch(final ByteBuffer buffer, final int ELEMENT_SIZE) { + + assertTrue(ELEMENT_SIZE > 0, "Element size cannot be zero or negative"); + Objects.requireNonNull(buffer, "Buffer cannot be null"); + + this.ELEMENT_SIZE = ELEMENT_SIZE; + final var bufferLen = buffer.capacity(); + + // Make sure the completion handler is giving us valid data + assertTrue(bufferLen % ELEMENT_SIZE == 0, "Invalid data received from completion handler: bufferLen=%d, elementSize=%d.", + bufferLen, ELEMENT_SIZE); + + this.capacity = bufferLen / ELEMENT_SIZE; + this.length = capacity; + + this.position = CursorStatus.INVALID_POSITION; + this.cursorStatus = CursorStatus.Begin; + + this.buffer = buffer.order(BYTE_ORDER); + } + + /** + * Retrieves information about the protocol header for debugging and diagnostic purposes. + * This is a temporary API and may be removed without notice. + * + * @return may return null if this batch wasn't generated in response to a VSR operation. + */ + public final Header getHeader() { + return this.header; + } + + void setHeader(Header header) { + Objects.requireNonNull(header); + this.header = header; + } + + /** + * Tells whether or not this batch is read-only. + * + * @return true if this batch is read-only + */ + public final boolean isReadOnly() { + return buffer.isReadOnly(); + } + + /** + * Adds a new element at the end of this batch. + *

+ * If successful, moves the current {@link #setPosition position} to the newly created + * element. + * + * @throws IllegalStateException if this batch is read-only. + * @throws IndexOutOfBoundsException if exceeds the batch's capacity. + */ + public final void add() { + + if (isReadOnly()) + throw new IllegalStateException("Cannot add an element in a read-only batch"); + + final var currentLen = this.length; + if (currentLen >= capacity) + throw new IndexOutOfBoundsException(String.format( + "Cannot add an element because the batch's capacity of %d was exceeded", + capacity)); + + this.length = currentLen + 1; + setPosition(currentLen); + } + + /** + * Tries to move the current {@link #setPosition position} to the next element in this batch. + * + * @return true if moved or false if the end of the batch was reached. + * + * @throws IndexOutOfBoundsException if the batch is already at the end. + */ + public final boolean next() { + + if (cursorStatus == CursorStatus.End) + throw new IndexOutOfBoundsException("This batch reached the end"); + + final var nextPosition = position + 1; + if (nextPosition >= this.length) { + position = CursorStatus.INVALID_POSITION; + cursorStatus = this.length > 0 ? CursorStatus.End : CursorStatus.Begin; + return false; + } else { + setPosition(nextPosition); + return true; + } + } + + /** + * Tells if the current position points to an valid element. + * + * @return false if the cursor is positioned before the first element or at the end. + */ + public final boolean isValidPosition() { + return cursorStatus == CursorStatus.Valid; + } + + /** + * Moves the cursor to the front of this Batch, before the first element. + *

+ * This causes the batch to be iterable again by calling {@link #next()}. + */ + public final void beforeFirst() { + position = CursorStatus.INVALID_POSITION; + cursorStatus = CursorStatus.Begin; + } + + /** + * Returns the current element's position. + * + * @return a zero-based index or {@code -1} if at the end of the batch. + */ + public final int getPosition() { + return this.position; + } + + /** + * Moves to the element int the specified position. + * + * @param newPosition a zero-based index. + * @throws IndexOutOfBoundsException if {@code newPosition} is negative, or greater than or + * equal to the batch's {@link #getLength length}. + */ + public final void setPosition(final int newPosition) { + if (newPosition < 0 || newPosition >= this.length) + throw new IndexOutOfBoundsException(); + + this.position = newPosition; + this.cursorStatus = CursorStatus.Valid; + } + + /** + * Gets the number of elements in this batch + */ + public final int getLength() { + return length; + } + + /** + * Gets the maximum number of elements this batch can contain. + */ + public final int getCapacity() { + return capacity; + } + + final ByteBuffer getBuffer() { + return buffer.position(0); + } + + final int getBufferLen() { + return this.length * ELEMENT_SIZE; + } + + protected final int at(final int fieldOffSet) { + + if (this.cursorStatus != CursorStatus.Valid) + throw new IllegalStateException(); + + final var elementPosition = this.position * ELEMENT_SIZE; + return elementPosition + fieldOffSet; + } + + protected final byte[] getUInt128(final int index) { + byte[] bytes = new byte[16]; + buffer.position(index).get(bytes); + return bytes; + } + + protected final long getUInt128(final int index, final UInt128 part) { + if (part == UInt128.LeastSignificant) { + return buffer.getLong(index); + } else { + return buffer.getLong(index + Long.BYTES); + } + } + + protected final void putUInt128(final int index, final byte[] value) { + + if (value == null) { + + // By default we assume null as zero + // The caller may throw a NullPointerException instead + putUInt128(index, 0L, 0L); + + } else { + + if (value.length != 16) + throw new IllegalArgumentException("UInt128 must be 16 bytes long"); + + buffer.position(index).put(value); + } + } + + protected final void putUInt128(final int index, final long leastSignificant, + final long mostSignificant) { + buffer.putLong(index, leastSignificant); + buffer.putLong(index + Long.BYTES, mostSignificant); + } + + protected final long getUInt64(final int index) { + return buffer.getLong(index); + } + + protected final void putUInt64(final int index, final long value) { + buffer.putLong(index, value); + } + + protected final int getUInt32(final int index) { + return buffer.getInt(index); + } + + protected final void putUInt32(final int index, final int value) { + buffer.putInt(index, value); + } + + protected final int getUInt16(final int index) { + return Short.toUnsignedInt(buffer.getShort(index)); + } + + protected final void putUInt16(final int index, final int value) { + if (value < 0 || value > Character.MAX_VALUE) + throw new IllegalArgumentException("Value must be a 16-bit unsigned integer"); + buffer.putShort(index, (short) value); + } + + protected final byte[] getArray(final int index, final int len) { + final byte[] array = new byte[len]; + buffer.position(index); + buffer.get(array); + return array; + } + + protected final void putArray(final int index, final byte[] array) { + Objects.requireNonNull(array, "Array cannot be null"); + buffer.position(index); + buffer.put(array); + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/BlockingRequest.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/BlockingRequest.java new file mode 100644 index 00000000..299b1058 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/BlockingRequest.java @@ -0,0 +1,178 @@ +package com.tigerbeetle; + +import static com.tigerbeetle.AssertionError.assertTrue; + +final class BlockingRequest extends Request { + + // @formatter:off + /* + * Overview: + * + * Implements a Request that blocks the caller thread until signaled as completed by the TB's callback. + * See AsyncRequest.java for the async implementation. + * + * We could have used the same AsyncRequest implementation by just waiting the CompletableFuture. + * + * CompletableFuture implements a sophisticated lock using CAS + waiter stack: + * https://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/share/classes/java/util/concurrent/CompletableFuture.java#l114 + * + * This BlockingRequest implements a much simpler general-purpose "synchronized" block that relies on the + * standard Monitor.wait(). + * + * This approach is particularly good here for 3 reasons: + * + * 1. We are always dealing with just one waiter thread, no need for a waiter stack. + * 2. It is expected for a request to be at least 2 io-ticks long, making sense to suspend the waiter thread immediately. + * 3. To avoid putting more pressure on the GC with additional object allocations required by the CompletableFuture + * + */ + // @formatter:on + + private TResponse result; + private Throwable exception; + + BlockingRequest(final NativeClient nativeClient, final Operations operation, + final Batch batch) { + super(nativeClient, operation, batch); + + result = null; + exception = null; + } + + public static BlockingRequest createAccounts( + final NativeClient nativeClient, final AccountBatch batch) { + return new BlockingRequest(nativeClient, + Request.Operations.CREATE_ACCOUNTS, batch); + } + + public static BlockingRequest lookupAccounts(final NativeClient nativeClient, + final IdBatch batch) { + return new BlockingRequest(nativeClient, Request.Operations.LOOKUP_ACCOUNTS, + batch); + } + + public static BlockingRequest createTransfers( + final NativeClient nativeClient, final TransferBatch batch) { + return new BlockingRequest(nativeClient, + Request.Operations.CREATE_TRANSFERS, batch); + } + + public static BlockingRequest lookupTransfers(final NativeClient nativeClient, + final IdBatch batch) { + return new BlockingRequest(nativeClient, Request.Operations.LOOKUP_TRANSFERS, + batch); + } + + public static BlockingRequest getAccountTransfers( + final NativeClient nativeClient, final AccountFilter filter) { + return new BlockingRequest(nativeClient, + Request.Operations.GET_ACCOUNT_TRANSFERS, filter.batch); + } + + public static BlockingRequest getAccountBalances( + final NativeClient nativeClient, final AccountFilter filter) { + return new BlockingRequest(nativeClient, + Request.Operations.GET_ACCOUNT_BALANCES, filter.batch); + } + + public static BlockingRequest queryAccounts(final NativeClient nativeClient, + final QueryFilter filter) { + return new BlockingRequest(nativeClient, Request.Operations.QUERY_ACCOUNTS, + filter.batch); + } + + public static BlockingRequest queryTransfers(final NativeClient nativeClient, + final QueryFilter filter) { + return new BlockingRequest(nativeClient, Request.Operations.QUERY_TRANSFERS, + filter.batch); + } + + public static BlockingRequest echo(final NativeClient nativeClient, + final AccountBatch batch) { + return new BlockingRequest(nativeClient, Request.Operations.ECHO_ACCOUNTS, + batch); + } + + public static BlockingRequest echo(final NativeClient nativeClient, + final TransferBatch batch) { + return new BlockingRequest(nativeClient, Request.Operations.ECHO_TRANSFERS, + batch); + } + + public boolean isDone() { + return result != null || exception != null; + } + + public TResponse waitForResult() throws InterruptedException { + + waitForCompletion(); + return getResult(); + } + + @Override + protected void setResult(final TResponse result) { + + synchronized (this) { + + if (isDone()) { + throw new IllegalStateException("Request has already been completed"); + } else { + this.result = result; + this.exception = null; + } + + notify(); + } + + } + + @Override + protected void setException(final Throwable exception) { + + synchronized (this) { + + if (isDone()) { + throw new IllegalStateException("Request has already been completed"); + } else { + this.result = null; + this.exception = exception; + } + + notify(); + } + + } + + private void waitForCompletion() throws InterruptedException { + if (!isDone()) { + synchronized (this) { + while (!isDone()) { + wait(); + } + } + } + } + + TResponse getResult() { + + assertTrue(result != null || exception != null, "Unexpected request result: result=null"); + + // Handling checked and unchecked exceptions accordingly + if (exception != null) { + + if (exception instanceof RuntimeException) + throw (RuntimeException) exception; + + if (exception instanceof Error) + throw (Error) exception; + + // Wrapping checked exceptions. + throw new AssertionError(exception, "Unexpected exception"); + + } else { + + return this.result; + } + } + +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/Client.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/Client.java new file mode 100644 index 00000000..c3aae57f --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/Client.java @@ -0,0 +1,351 @@ +package com.tigerbeetle; + +import java.util.Objects; +import java.util.StringJoiner; +import java.util.concurrent.CompletableFuture; + +public final class Client implements AutoCloseable { + + private final byte[] clusterID; + private final NativeClient nativeClient; + + /** + * Initializes an instance of TigerBeetle client. This class is thread-safe and for optimal + * performance, a single instance should be shared between multiple concurrent tasks. + *

+ * Multiple clients can be instantiated in case of connecting to more than one TigerBeetle + * cluster. + * + * @param clusterID + * @param replicaAddresses + * + * @throws InitializationException if an error occurred initializing this client. See + * {@link InitializationStatus} for more details. + * + * @throws NullPointerException if {@code clusterID} is null. + * @throws IllegalArgumentException if {@code clusterID} is not a UInt128. + * @throws IllegalArgumentException if {@code replicaAddresses} is empty or presented in + * incorrect format. + * @throws NullPointerException if {@code replicaAddresses} is null or any element in the array + * is null. + */ + public Client(final byte[] clusterID, final String[] replicaAddresses) { + Objects.requireNonNull(clusterID, "ClusterID cannot be null"); + if (clusterID.length != UInt128.SIZE) + throw new IllegalArgumentException("ClusterID must be 16 bytes long"); + + Objects.requireNonNull(replicaAddresses, "Replica addresses cannot be null"); + + if (replicaAddresses.length == 0) + throw new IllegalArgumentException("Empty replica addresses"); + + var joiner = new StringJoiner(","); + for (var address : replicaAddresses) { + Objects.requireNonNull(address, "Replica address cannot be null"); + joiner.add(address); + } + + this.clusterID = clusterID; + this.nativeClient = NativeClient.init(clusterID, joiner.toString()); + } + + /** + * Gets the cluster ID + * + * @return clusterID + */ + public byte[] getClusterID() { + return clusterID; + } + + /** + * Submits a batch of new accounts to be created. + * + * @param batch a {@link com.tigerbeetle.AccountBatch batch} containing all accounts to be + * created. + * @return a read-only {@link com.tigerbeetle.CreateAccountResultBatch batch} describing the + * result. + * @throws TooMuchDataException + * @throws ClientClosedException + * @throws ClientEvictedException + * @throws ClientReleaseException + * @throws IllegalArgumentException if {@code batch} is empty. + * @throws NullPointerException if {@code batch} is null. + * @throws InterruptedException if the current thread is interrupted. + */ + public CreateAccountResultBatch createAccounts(final AccountBatch batch) + throws InterruptedException { + final var request = BlockingRequest.createAccounts(this.nativeClient, batch); + request.beginRequest(); + return request.waitForResult(); + } + + /** + * Submits a batch of new accounts to be created asynchronously. + * + * @see Client#createAccounts(AccountBatch) + * @param batch a {@link com.tigerbeetle.AccountBatch batch} containing all accounts to be + * created. + * @return a {@link java.util.concurrent.CompletableFuture} to be completed. + * @throws IllegalArgumentException if {@code batch} is empty. + * @throws NullPointerException if {@code batch} is null. + * @throws IllegalStateException if this client is closed. + */ + public CompletableFuture createAccountsAsync( + final AccountBatch batch) { + final var request = AsyncRequest.createAccounts(this.nativeClient, batch); + request.beginRequest(); + return request.getFuture(); + } + + /** + * Looks up a batch of accounts. + * + * @param batch an {@link com.tigerbeetle.IdBatch batch} containing all account ids. + * @return a read-only {@link com.tigerbeetle.AccountBatch batch} containing all accounts found. + * @throws TooMuchDataException + * @throws ClientClosedException + * @throws ClientEvictedException + * @throws ClientReleaseException + * @throws IllegalArgumentException if {@code batch} is empty. + * @throws NullPointerException if {@code batch} is null. + * @throws InterruptedException if the current thread is interrupted. + */ + public AccountBatch lookupAccounts(final IdBatch batch) throws InterruptedException { + final var request = BlockingRequest.lookupAccounts(this.nativeClient, batch); + request.beginRequest(); + return request.waitForResult(); + } + + /** + * Looks up a batch of accounts asynchronously. + * + * @see Client#lookupAccounts + * @param batch a {@link com.tigerbeetle.IdBatch batch} containing all account ids. + * @return a {@link java.util.concurrent.CompletableFuture} to be completed. + * @throws IllegalArgumentException if {@code batch} is empty. + * @throws NullPointerException if {@code batch} is null. + * @throws IllegalStateException if this client is closed. + */ + public CompletableFuture lookupAccountsAsync(final IdBatch batch) { + final var request = AsyncRequest.lookupAccounts(this.nativeClient, batch); + request.beginRequest(); + return request.getFuture(); + } + + /** + * Submits a batch of new transfers to be created. + * + * @param batch a {@link com.tigerbeetle.TransferBatch batch} containing all transfers to be + * created. + * @return a read-only {@link com.tigerbeetle.CreateTransferResultBatch batch} describing the + * result. + * @throws TooMuchDataException + * @throws ClientClosedException + * @throws ClientEvictedException + * @throws ClientReleaseException + * @throws IllegalArgumentException if {@code batch} is empty. + * @throws NullPointerException if {@code batch} is null. + * @throws InterruptedException if the current thread is interrupted. + */ + public CreateTransferResultBatch createTransfers(final TransferBatch batch) + throws InterruptedException { + final var request = BlockingRequest.createTransfers(this.nativeClient, batch); + request.beginRequest(); + return request.waitForResult(); + } + + /** + * Submits a batch of new transfers to be created asynchronously. + * + * @param batch a {@link com.tigerbeetle.TransferBatch batch} containing all transfers to be + * created. + * @return a {@link java.util.concurrent.CompletableFuture} to be completed. + * @throws IllegalArgumentException if {@code batch} is empty. + * @throws NullPointerException if {@code batch} is null. + * @throws IllegalStateException if this client is closed. + */ + public CompletableFuture createTransfersAsync( + final TransferBatch batch) { + final var request = AsyncRequest.createTransfers(this.nativeClient, batch); + request.beginRequest(); + return request.getFuture(); + } + + /** + * Looks up a batch of transfers. + * + * @param batch a {@link com.tigerbeetle.IdBatch batch} containing all transfer ids. + * @return a read-only {@link com.tigerbeetle.TransferBatch batch} containing all transfers + * found. + * @throws TooMuchDataException + * @throws ClientClosedException + * @throws ClientEvictedException + * @throws ClientReleaseException + * @throws IllegalArgumentException if {@code batch} is empty. + * @throws NullPointerException if {@code batch} is null. + * @throws InterruptedException if the current thread is interrupted. + */ + public TransferBatch lookupTransfers(final IdBatch batch) throws InterruptedException { + final var request = BlockingRequest.lookupTransfers(this.nativeClient, batch); + request.beginRequest(); + return request.waitForResult(); + } + + /** + * Looks up a batch of transfers asynchronously. + * + * @see Client#lookupTransfers(IdBatch) + * @param batch a {@link com.tigerbeetle.IdBatch batch} containing all transfer ids. + * @return a {@link java.util.concurrent.CompletableFuture} to be completed. + * @throws IllegalArgumentException if {@code batch} is empty. + * @throws NullPointerException if {@code batch} is null. + * @throws IllegalStateException if this client is closed. + */ + public CompletableFuture lookupTransfersAsync(final IdBatch batch) { + final var request = AsyncRequest.lookupTransfers(this.nativeClient, batch); + request.beginRequest(); + return request.getFuture(); + } + + /** + * Fetch transfers from a given account. + * + * @see Client#getAccountTransfers(AccountFilter) + * @param filter a {@link com.tigerbeetle.AccountFilter} containing all query parameters. + * @return a read-only {@link com.tigerbeetle.TransferBatch batch} containing all transfers that + * match the query parameters. + * @throws NullPointerException if {@code filter} is null. + * @throws IllegalStateException if this client is closed. + * @throws InterruptedException if the current thread is interrupted. + */ + public TransferBatch getAccountTransfers(final AccountFilter filter) + throws InterruptedException { + final var request = BlockingRequest.getAccountTransfers(this.nativeClient, filter); + request.beginRequest(); + return request.waitForResult(); + } + + /** + * Fetch transfers from a given account asynchronously. + * + * @see Client#getAccountTransfers(AccountFilter) + * @param filter a {@link com.tigerbeetle.AccountFilter} containing all query parameters. + * @return a {@link java.util.concurrent.CompletableFuture} to be completed. + * @throws NullPointerException if {@code filter} is null. + * @throws IllegalStateException if this client is closed. + */ + public CompletableFuture getAccountTransfersAsync(final AccountFilter filter) { + final var request = AsyncRequest.getAccountTransfers(this.nativeClient, filter); + request.beginRequest(); + return request.getFuture(); + } + + /** + * Fetch the balance history from a given account. + * + * @see Client#getAccountBalances(AccountFilter) + * @param filter a {@link com.tigerbeetle.AccountFilter} containing all query parameters. + * @return a read-only {@link com.tigerbeetle.AccountBalanceBatch batch} containing all balances + * that match the query parameters. + * @throws NullPointerException if {@code filter} is null. + * @throws IllegalStateException if this client is closed. + * @throws InterruptedException if the current thread is interrupted. + */ + public AccountBalanceBatch getAccountBalances(final AccountFilter filter) + throws InterruptedException { + final var request = BlockingRequest.getAccountBalances(this.nativeClient, filter); + request.beginRequest(); + return request.waitForResult(); + } + + /** + * Fetch the balance history from a given account asynchronously. + * + * @see Client#getAccountBalances(AccountFilter) + * @param filter a {@link com.tigerbeetle.AccountFilter} containing all query parameters. + * @return a {@link java.util.concurrent.CompletableFuture} to be completed. + * @throws NullPointerException if {@code filter} is null. + * @throws IllegalStateException if this client is closed. + */ + public CompletableFuture getAccountBalancesAsync( + final AccountFilter filter) { + final var request = AsyncRequest.getAccountBalances(this.nativeClient, filter); + request.beginRequest(); + return request.getFuture(); + } + + /** + * Query accounts. + * + * @param filter a {@link com.tigerbeetle.QueryFilter} containing all query parameters. + * @return a read-only {@link com.tigerbeetle.AccountBatch batch} containing all accounts that + * match the query parameters. + * @throws NullPointerException if {@code filter} is null. + * @throws IllegalStateException if this client is closed. + * @throws InterruptedException if the current thread is interrupted. + */ + public AccountBatch queryAccounts(final QueryFilter filter) throws InterruptedException { + final var request = BlockingRequest.queryAccounts(this.nativeClient, filter); + request.beginRequest(); + return request.waitForResult(); + } + + /** + * Query accounts asynchronously. + * + * @see Client#queryAccounts(QueryFilter) + * @param filter a {@link com.tigerbeetle.QueryFilter} containing all query parameters. + * @return a {@link java.util.concurrent.CompletableFuture} to be completed. + * @throws NullPointerException if {@code filter} is null. + * @throws IllegalStateException if this client is closed. + */ + public CompletableFuture queryAccountsAsync(final QueryFilter filter) { + final var request = AsyncRequest.queryAccounts(this.nativeClient, filter); + request.beginRequest(); + return request.getFuture(); + } + + /** + * Query transfers. + * + * @param filter a {@link com.tigerbeetle.QueryFilter} containing all query parameters. + * @return a read-only {@link com.tigerbeetle.TransferBatch batch} containing all transfers that + * match the query parameters. + * @throws NullPointerException if {@code filter} is null. + * @throws IllegalStateException if this client is closed. + * @throws InterruptedException if the current thread is interrupted. + */ + public TransferBatch queryTransfers(final QueryFilter filter) throws InterruptedException { + final var request = BlockingRequest.queryTransfers(this.nativeClient, filter); + request.beginRequest(); + return request.waitForResult(); + } + + /** + * Query transfers asynchronously. + * + * @see Client#queryTransfers(QueryFilter) + * @param filter a {@link com.tigerbeetle.QueryFilter} containing all query parameters. + * @return a {@link java.util.concurrent.CompletableFuture} to be completed. + * @throws NullPointerException if {@code filter} is null. + * @throws IllegalStateException if this client is closed. + */ + public CompletableFuture queryTransfersAsync(final QueryFilter filter) { + final var request = AsyncRequest.queryTransfers(this.nativeClient, filter); + request.beginRequest(); + return request.getFuture(); + } + + /** + * Closes the client, freeing all resources. + *

+ * This method causes the current thread to wait for all ongoing requests to finish. + * + * @see java.lang.AutoCloseable#close() + */ + @Override + public void close() { + nativeClient.close(); + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/ClientClosedException.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/ClientClosedException.java new file mode 100644 index 00000000..53f7ca8f --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/ClientClosedException.java @@ -0,0 +1,20 @@ +package com.tigerbeetle; + +/** + * ClientClosedException is thrown when the client instance is closed and its resources have been + * freed. + **/ +public final class ClientClosedException extends RequestException { + + public ClientClosedException() {} + + @Override + public String getMessage() { + return toString(); + } + + @Override + public String toString() { + return "Client was closed."; + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/ClientEvictedException.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/ClientEvictedException.java new file mode 100644 index 00000000..5e895227 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/ClientEvictedException.java @@ -0,0 +1,22 @@ +package com.tigerbeetle; + +/** + * ClientEvictedException is thrown when the client is evicted from the TigerBeetle cluster. If this + * exception is thrown, then either there are too many clients connected or the client was idle for + * too long. + **/ +public final class ClientEvictedException extends RequestException { + + ClientEvictedException() {} + + @Override + public String getMessage() { + return toString(); + } + + @Override + public String toString() { + return "Client was evicted."; + } + +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/ClientReleaseException.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/ClientReleaseException.java new file mode 100644 index 00000000..de092c6e --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/ClientReleaseException.java @@ -0,0 +1,44 @@ +package com.tigerbeetle; + +/** + * ClientReleaseException is thrown when the TigerBeetle client release version is incompatible with + * the TigerBeetle cluster release. See the + * {@link com.tigerbeetle.ClientReleaseException#getReason()} property to check whether the client + * is too new or too old to connect to the cluster. + * + * @see upgrading + */ +public final class ClientReleaseException extends RequestException { + + public enum Reason { + ClientReleaseTooLow, + ClientReleaseTooHigh + } + + private final Reason reason; + + ClientReleaseException(Reason reason) { + this.reason = reason; + } + + public Reason getReason() { + return reason; + } + + @Override + public String getMessage() { + return toString(); + } + + @Override + public String toString() { + switch (reason) { + case ClientReleaseTooLow: + return "Client was evicted: release too old"; + case ClientReleaseTooHigh: + return "Client was evicted: release too new"; + default: + return reason.toString(); + } + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/ClientStatus.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/ClientStatus.java new file mode 100644 index 00000000..9ab72b85 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/ClientStatus.java @@ -0,0 +1,27 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +enum ClientStatus { + Ok((int) 0), + Invalid((int) 1); + + public final int value; + + ClientStatus(int value) { + this.value = value; + } + + public static ClientStatus fromValue(int value) { + switch (value) { + case 0: return Ok; + case 1: return Invalid; + default: throw new IllegalArgumentException( + String.format("Invalid ClientStatus value=%d", value)); + } + } +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/CreateAccountResultBatch.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/CreateAccountResultBatch.java new file mode 100644 index 00000000..fc4e4863 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/CreateAccountResultBatch.java @@ -0,0 +1,91 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +import java.nio.ByteBuffer; + + +public final class CreateAccountResultBatch extends Batch { + + + interface Struct { + int SIZE = 16; + + int Timestamp = 0; + int Status = 8; + int Reserved = 12; + } + + /** + * Creates an empty batch with the desired maximum capacity. + *

+ * Once created, an instance cannot be resized, however it may contain any number of elements + * between zero and its {@link #getCapacity capacity}. + * + * @param capacity the maximum capacity. + * @throws IllegalArgumentException if capacity is negative. + */ + public CreateAccountResultBatch(final int capacity) { + super(capacity, Struct.SIZE); + } + + CreateAccountResultBatch(final ByteBuffer buffer) { + super(buffer, Struct.SIZE); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public long getTimestamp() { + final var value = getUInt64(at(Struct.Timestamp)); + return value; + } + + /** + * @param timestamp + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + void setTimestamp(final long timestamp) { + putUInt64(at(Struct.Timestamp), timestamp); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public CreateAccountStatus getStatus() { + final var value = getUInt32(at(Struct.Status)); + return CreateAccountStatus.fromValue(value); + } + + /** + * @param status + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + void setStatus(final CreateAccountStatus status) { + putUInt32(at(Struct.Status), status.value); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + int getReserved() { + final var value = getUInt32(at(Struct.Reserved)); + return value; + } + + /** + * @param reserved + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + void setReserved(final int reserved) { + putUInt32(at(Struct.Reserved), reserved); + } + +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/CreateAccountStatus.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/CreateAccountStatus.java new file mode 100644 index 00000000..86b298e2 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/CreateAccountStatus.java @@ -0,0 +1,185 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +public enum CreateAccountStatus { + + /** + * @see created + */ + Created((int) 0xFFFFFFFF), + + /** + * @see linked_event_failed + */ + LinkedEventFailed((int) 1), + + /** + * @see linked_event_chain_open + */ + LinkedEventChainOpen((int) 2), + + /** + * @see imported_event_expected + */ + ImportedEventExpected((int) 22), + + /** + * @see imported_event_not_expected + */ + ImportedEventNotExpected((int) 23), + + /** + * @see timestamp_must_be_zero + */ + TimestampMustBeZero((int) 3), + + /** + * @see imported_event_timestamp_out_of_range + */ + ImportedEventTimestampOutOfRange((int) 24), + + /** + * @see imported_event_timestamp_must_not_advance + */ + ImportedEventTimestampMustNotAdvance((int) 25), + + /** + * @see reserved_field + */ + ReservedField((int) 4), + + /** + * @see reserved_flag + */ + ReservedFlag((int) 5), + + /** + * @see id_must_not_be_zero + */ + IdMustNotBeZero((int) 6), + + /** + * @see id_must_not_be_int_max + */ + IdMustNotBeIntMax((int) 7), + + /** + * @see exists_with_different_flags + */ + ExistsWithDifferentFlags((int) 15), + + /** + * @see exists_with_different_user_data_128 + */ + ExistsWithDifferentUserData128((int) 16), + + /** + * @see exists_with_different_user_data_64 + */ + ExistsWithDifferentUserData64((int) 17), + + /** + * @see exists_with_different_user_data_32 + */ + ExistsWithDifferentUserData32((int) 18), + + /** + * @see exists_with_different_ledger + */ + ExistsWithDifferentLedger((int) 19), + + /** + * @see exists_with_different_code + */ + ExistsWithDifferentCode((int) 20), + + /** + * @see exists + */ + Exists((int) 21), + + /** + * @see flags_are_mutually_exclusive + */ + FlagsAreMutuallyExclusive((int) 8), + + /** + * @see debits_pending_must_be_zero + */ + DebitsPendingMustBeZero((int) 9), + + /** + * @see debits_posted_must_be_zero + */ + DebitsPostedMustBeZero((int) 10), + + /** + * @see credits_pending_must_be_zero + */ + CreditsPendingMustBeZero((int) 11), + + /** + * @see credits_posted_must_be_zero + */ + CreditsPostedMustBeZero((int) 12), + + /** + * @see ledger_must_not_be_zero + */ + LedgerMustNotBeZero((int) 13), + + /** + * @see code_must_not_be_zero + */ + CodeMustNotBeZero((int) 14), + + /** + * @see imported_event_timestamp_must_not_regress + */ + ImportedEventTimestampMustNotRegress((int) 26); + + public final int value; + + CreateAccountStatus(int value) { + this.value = value; + } + + public static CreateAccountStatus fromValue(int value) { + switch (value) { + case 0xFFFFFFFF: return Created; + case 1: return LinkedEventFailed; + case 2: return LinkedEventChainOpen; + case 22: return ImportedEventExpected; + case 23: return ImportedEventNotExpected; + case 3: return TimestampMustBeZero; + case 24: return ImportedEventTimestampOutOfRange; + case 25: return ImportedEventTimestampMustNotAdvance; + case 4: return ReservedField; + case 5: return ReservedFlag; + case 6: return IdMustNotBeZero; + case 7: return IdMustNotBeIntMax; + case 15: return ExistsWithDifferentFlags; + case 16: return ExistsWithDifferentUserData128; + case 17: return ExistsWithDifferentUserData64; + case 18: return ExistsWithDifferentUserData32; + case 19: return ExistsWithDifferentLedger; + case 20: return ExistsWithDifferentCode; + case 21: return Exists; + case 8: return FlagsAreMutuallyExclusive; + case 9: return DebitsPendingMustBeZero; + case 10: return DebitsPostedMustBeZero; + case 11: return CreditsPendingMustBeZero; + case 12: return CreditsPostedMustBeZero; + case 13: return LedgerMustNotBeZero; + case 14: return CodeMustNotBeZero; + case 26: return ImportedEventTimestampMustNotRegress; + default: throw new IllegalArgumentException( + String.format("Invalid CreateAccountStatus value=%d", value)); + } + } +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/CreateTransferResultBatch.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/CreateTransferResultBatch.java new file mode 100644 index 00000000..0e161d3d --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/CreateTransferResultBatch.java @@ -0,0 +1,91 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +import java.nio.ByteBuffer; + + +public final class CreateTransferResultBatch extends Batch { + + + interface Struct { + int SIZE = 16; + + int Timestamp = 0; + int Status = 8; + int Reserved = 12; + } + + /** + * Creates an empty batch with the desired maximum capacity. + *

+ * Once created, an instance cannot be resized, however it may contain any number of elements + * between zero and its {@link #getCapacity capacity}. + * + * @param capacity the maximum capacity. + * @throws IllegalArgumentException if capacity is negative. + */ + public CreateTransferResultBatch(final int capacity) { + super(capacity, Struct.SIZE); + } + + CreateTransferResultBatch(final ByteBuffer buffer) { + super(buffer, Struct.SIZE); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public long getTimestamp() { + final var value = getUInt64(at(Struct.Timestamp)); + return value; + } + + /** + * @param timestamp + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + void setTimestamp(final long timestamp) { + putUInt64(at(Struct.Timestamp), timestamp); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public CreateTransferStatus getStatus() { + final var value = getUInt32(at(Struct.Status)); + return CreateTransferStatus.fromValue(value); + } + + /** + * @param status + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + void setStatus(final CreateTransferStatus status) { + putUInt32(at(Struct.Status), status.value); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + int getReserved() { + final var value = getUInt32(at(Struct.Reserved)); + return value; + } + + /** + * @param reserved + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + void setReserved(final int reserved) { + putUInt32(at(Struct.Reserved), reserved); + } + +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/CreateTransferStatus.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/CreateTransferStatus.java new file mode 100644 index 00000000..947ae3ed --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/CreateTransferStatus.java @@ -0,0 +1,431 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +public enum CreateTransferStatus { + + /** + * @see created + */ + Created((int) 0xFFFFFFFF), + + /** + * @see linked_event_failed + */ + LinkedEventFailed((int) 1), + + /** + * @see linked_event_chain_open + */ + LinkedEventChainOpen((int) 2), + + /** + * @see imported_event_expected + */ + ImportedEventExpected((int) 56), + + /** + * @see imported_event_not_expected + */ + ImportedEventNotExpected((int) 57), + + /** + * @see timestamp_must_be_zero + */ + TimestampMustBeZero((int) 3), + + /** + * @see imported_event_timestamp_out_of_range + */ + ImportedEventTimestampOutOfRange((int) 58), + + /** + * @see imported_event_timestamp_must_not_advance + */ + ImportedEventTimestampMustNotAdvance((int) 59), + + /** + * @see reserved_flag + */ + ReservedFlag((int) 4), + + /** + * @see id_must_not_be_zero + */ + IdMustNotBeZero((int) 5), + + /** + * @see id_must_not_be_int_max + */ + IdMustNotBeIntMax((int) 6), + + /** + * @see exists_with_different_flags + */ + ExistsWithDifferentFlags((int) 36), + + /** + * @see exists_with_different_pending_id + */ + ExistsWithDifferentPendingId((int) 40), + + /** + * @see exists_with_different_timeout + */ + ExistsWithDifferentTimeout((int) 44), + + /** + * @see exists_with_different_debit_account_id + */ + ExistsWithDifferentDebitAccountId((int) 37), + + /** + * @see exists_with_different_credit_account_id + */ + ExistsWithDifferentCreditAccountId((int) 38), + + /** + * @see exists_with_different_amount + */ + ExistsWithDifferentAmount((int) 39), + + /** + * @see exists_with_different_user_data_128 + */ + ExistsWithDifferentUserData128((int) 41), + + /** + * @see exists_with_different_user_data_64 + */ + ExistsWithDifferentUserData64((int) 42), + + /** + * @see exists_with_different_user_data_32 + */ + ExistsWithDifferentUserData32((int) 43), + + /** + * @see exists_with_different_ledger + */ + ExistsWithDifferentLedger((int) 67), + + /** + * @see exists_with_different_code + */ + ExistsWithDifferentCode((int) 45), + + /** + * @see exists + */ + Exists((int) 46), + + /** + * @see id_already_failed + */ + IdAlreadyFailed((int) 68), + + /** + * @see flags_are_mutually_exclusive + */ + FlagsAreMutuallyExclusive((int) 7), + + /** + * @see debit_account_id_must_not_be_zero + */ + DebitAccountIdMustNotBeZero((int) 8), + + /** + * @see debit_account_id_must_not_be_int_max + */ + DebitAccountIdMustNotBeIntMax((int) 9), + + /** + * @see credit_account_id_must_not_be_zero + */ + CreditAccountIdMustNotBeZero((int) 10), + + /** + * @see credit_account_id_must_not_be_int_max + */ + CreditAccountIdMustNotBeIntMax((int) 11), + + /** + * @see accounts_must_be_different + */ + AccountsMustBeDifferent((int) 12), + + /** + * @see pending_id_must_be_zero + */ + PendingIdMustBeZero((int) 13), + + /** + * @see pending_id_must_not_be_zero + */ + PendingIdMustNotBeZero((int) 14), + + /** + * @see pending_id_must_not_be_int_max + */ + PendingIdMustNotBeIntMax((int) 15), + + /** + * @see pending_id_must_be_different + */ + PendingIdMustBeDifferent((int) 16), + + /** + * @see timeout_reserved_for_pending_transfer + */ + TimeoutReservedForPendingTransfer((int) 17), + + /** + * @see closing_transfer_must_be_pending + */ + ClosingTransferMustBePending((int) 64), + + /** + * @see ledger_must_not_be_zero + */ + LedgerMustNotBeZero((int) 19), + + /** + * @see code_must_not_be_zero + */ + CodeMustNotBeZero((int) 20), + + /** + * @see debit_account_not_found + */ + DebitAccountNotFound((int) 21), + + /** + * @see credit_account_not_found + */ + CreditAccountNotFound((int) 22), + + /** + * @see accounts_must_have_the_same_ledger + */ + AccountsMustHaveTheSameLedger((int) 23), + + /** + * @see transfer_must_have_the_same_ledger_as_accounts + */ + TransferMustHaveTheSameLedgerAsAccounts((int) 24), + + /** + * @see pending_transfer_not_found + */ + PendingTransferNotFound((int) 25), + + /** + * @see pending_transfer_not_pending + */ + PendingTransferNotPending((int) 26), + + /** + * @see pending_transfer_has_different_debit_account_id + */ + PendingTransferHasDifferentDebitAccountId((int) 27), + + /** + * @see pending_transfer_has_different_credit_account_id + */ + PendingTransferHasDifferentCreditAccountId((int) 28), + + /** + * @see pending_transfer_has_different_ledger + */ + PendingTransferHasDifferentLedger((int) 29), + + /** + * @see pending_transfer_has_different_code + */ + PendingTransferHasDifferentCode((int) 30), + + /** + * @see exceeds_pending_transfer_amount + */ + ExceedsPendingTransferAmount((int) 31), + + /** + * @see pending_transfer_has_different_amount + */ + PendingTransferHasDifferentAmount((int) 32), + + /** + * @see pending_transfer_already_posted + */ + PendingTransferAlreadyPosted((int) 33), + + /** + * @see pending_transfer_already_voided + */ + PendingTransferAlreadyVoided((int) 34), + + /** + * @see pending_transfer_expired + */ + PendingTransferExpired((int) 35), + + /** + * @see imported_event_timestamp_must_not_regress + */ + ImportedEventTimestampMustNotRegress((int) 60), + + /** + * @see imported_event_timestamp_must_postdate_debit_account + */ + ImportedEventTimestampMustPostdateDebitAccount((int) 61), + + /** + * @see imported_event_timestamp_must_postdate_credit_account + */ + ImportedEventTimestampMustPostdateCreditAccount((int) 62), + + /** + * @see imported_event_timeout_must_be_zero + */ + ImportedEventTimeoutMustBeZero((int) 63), + + /** + * @see debit_account_already_closed + */ + DebitAccountAlreadyClosed((int) 65), + + /** + * @see credit_account_already_closed + */ + CreditAccountAlreadyClosed((int) 66), + + /** + * @see overflows_debits_pending + */ + OverflowsDebitsPending((int) 47), + + /** + * @see overflows_credits_pending + */ + OverflowsCreditsPending((int) 48), + + /** + * @see overflows_debits_posted + */ + OverflowsDebitsPosted((int) 49), + + /** + * @see overflows_credits_posted + */ + OverflowsCreditsPosted((int) 50), + + /** + * @see overflows_debits + */ + OverflowsDebits((int) 51), + + /** + * @see overflows_credits + */ + OverflowsCredits((int) 52), + + /** + * @see overflows_timeout + */ + OverflowsTimeout((int) 53), + + /** + * @see exceeds_credits + */ + ExceedsCredits((int) 54), + + /** + * @see exceeds_debits + */ + ExceedsDebits((int) 55); + + public final int value; + + CreateTransferStatus(int value) { + this.value = value; + } + + public static CreateTransferStatus fromValue(int value) { + switch (value) { + case 0xFFFFFFFF: return Created; + case 1: return LinkedEventFailed; + case 2: return LinkedEventChainOpen; + case 56: return ImportedEventExpected; + case 57: return ImportedEventNotExpected; + case 3: return TimestampMustBeZero; + case 58: return ImportedEventTimestampOutOfRange; + case 59: return ImportedEventTimestampMustNotAdvance; + case 4: return ReservedFlag; + case 5: return IdMustNotBeZero; + case 6: return IdMustNotBeIntMax; + case 36: return ExistsWithDifferentFlags; + case 40: return ExistsWithDifferentPendingId; + case 44: return ExistsWithDifferentTimeout; + case 37: return ExistsWithDifferentDebitAccountId; + case 38: return ExistsWithDifferentCreditAccountId; + case 39: return ExistsWithDifferentAmount; + case 41: return ExistsWithDifferentUserData128; + case 42: return ExistsWithDifferentUserData64; + case 43: return ExistsWithDifferentUserData32; + case 67: return ExistsWithDifferentLedger; + case 45: return ExistsWithDifferentCode; + case 46: return Exists; + case 68: return IdAlreadyFailed; + case 7: return FlagsAreMutuallyExclusive; + case 8: return DebitAccountIdMustNotBeZero; + case 9: return DebitAccountIdMustNotBeIntMax; + case 10: return CreditAccountIdMustNotBeZero; + case 11: return CreditAccountIdMustNotBeIntMax; + case 12: return AccountsMustBeDifferent; + case 13: return PendingIdMustBeZero; + case 14: return PendingIdMustNotBeZero; + case 15: return PendingIdMustNotBeIntMax; + case 16: return PendingIdMustBeDifferent; + case 17: return TimeoutReservedForPendingTransfer; + case 64: return ClosingTransferMustBePending; + case 19: return LedgerMustNotBeZero; + case 20: return CodeMustNotBeZero; + case 21: return DebitAccountNotFound; + case 22: return CreditAccountNotFound; + case 23: return AccountsMustHaveTheSameLedger; + case 24: return TransferMustHaveTheSameLedgerAsAccounts; + case 25: return PendingTransferNotFound; + case 26: return PendingTransferNotPending; + case 27: return PendingTransferHasDifferentDebitAccountId; + case 28: return PendingTransferHasDifferentCreditAccountId; + case 29: return PendingTransferHasDifferentLedger; + case 30: return PendingTransferHasDifferentCode; + case 31: return ExceedsPendingTransferAmount; + case 32: return PendingTransferHasDifferentAmount; + case 33: return PendingTransferAlreadyPosted; + case 34: return PendingTransferAlreadyVoided; + case 35: return PendingTransferExpired; + case 60: return ImportedEventTimestampMustNotRegress; + case 61: return ImportedEventTimestampMustPostdateDebitAccount; + case 62: return ImportedEventTimestampMustPostdateCreditAccount; + case 63: return ImportedEventTimeoutMustBeZero; + case 65: return DebitAccountAlreadyClosed; + case 66: return CreditAccountAlreadyClosed; + case 47: return OverflowsDebitsPending; + case 48: return OverflowsCreditsPending; + case 49: return OverflowsDebitsPosted; + case 50: return OverflowsCreditsPosted; + case 51: return OverflowsDebits; + case 52: return OverflowsCredits; + case 53: return OverflowsTimeout; + case 54: return ExceedsCredits; + case 55: return ExceedsDebits; + default: throw new IllegalArgumentException( + String.format("Invalid CreateTransferStatus value=%d", value)); + } + } +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/EchoClient.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/EchoClient.java new file mode 100644 index 00000000..37c5e2ef --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/EchoClient.java @@ -0,0 +1,40 @@ +package com.tigerbeetle; + +import java.util.concurrent.CompletableFuture; + +public final class EchoClient implements AutoCloseable { + + private final NativeClient nativeClient; + + public EchoClient(final byte[] clusterID, final String replicaAddresses) { + this.nativeClient = NativeClient.initEcho(clusterID, replicaAddresses); + } + + public AccountBatch echo(final AccountBatch batch) throws Exception { + final var request = BlockingRequest.echo(this.nativeClient, batch); + request.beginRequest(); + return request.waitForResult(); + } + + public TransferBatch echo(final TransferBatch batch) throws Exception { + final var request = BlockingRequest.echo(this.nativeClient, batch); + request.beginRequest(); + return request.waitForResult(); + } + + public CompletableFuture echoAsync(final AccountBatch batch) throws Exception { + final var request = AsyncRequest.echo(this.nativeClient, batch); + request.beginRequest(); + return request.getFuture(); + } + + public CompletableFuture echoAsync(final TransferBatch batch) throws Exception { + final var request = AsyncRequest.echo(this.nativeClient, batch); + request.beginRequest(); + return request.getFuture(); + } + + public void close() throws Exception { + nativeClient.close(); + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/IdBatch.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/IdBatch.java new file mode 100644 index 00000000..2e93badb --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/IdBatch.java @@ -0,0 +1,143 @@ +package com.tigerbeetle; + +import java.nio.ByteBuffer; +import java.util.Objects; + +/** + * A {@link Batch batch} of 128-bit unsigned integers. + */ +public final class IdBatch extends Batch { + + interface Struct { + int SIZE = 16; + } + + /** + * Constructs an empty batch of ids with the desired maximum capacity. + *

+ * Once created, an instance cannot be resized, however it may contain any number of ids between + * zero and its {@link #getCapacity capacity}. + * + * @param capacity the maximum capacity. + * + * @throws IllegalArgumentException if capacity is negative. + */ + public IdBatch(final int capacity) { + super(capacity, Struct.SIZE); + } + + IdBatch(final ByteBuffer buffer) { + super(buffer, Struct.SIZE); + } + + /** + * Constructs a batch of ids copying the elements from an array. + * + * @param ids the array of ids. + * + * @throws NullPointerException if {@code ids} is null. + */ + public IdBatch(final byte[]... ids) { + super(ids.length, Struct.SIZE); + + for (final var id : ids) { + add(id); + } + } + + /** + * Adds a new id at the end of this batch. + *

+ * If successful, moves the current {@link #setPosition position} to the newly created id. + * + * @param id an array of 16 bytes representing the 128-bit value. + * + * @throws IllegalStateException if this batch is read-only. + * @throws IndexOutOfBoundsException if exceeds the batch's capacity. + */ + public void add(final byte[] id) { + super.add(); + setId(id); + } + + /** + * Adds a new id at the end of this batch. + *

+ * If successful, moves the current {@link #setPosition position} to the newly created id. + * + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * + * @throws IllegalStateException if this batch is read-only. + * @throws IndexOutOfBoundsException if exceeds the batch's capacity. + */ + public void add(final long leastSignificant, final long mostSignificant) { + super.add(); + setId(leastSignificant, mostSignificant); + } + + /** + * Adds a new id at the end of this batch. + *

+ * If successful, moves the current {@link #setPosition position} to the newly created id. + * + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * + * @throws IllegalStateException if this batch is read-only. + * @throws IndexOutOfBoundsException if exceeds the batch's capacity. + */ + public void add(final long leastSignificant) { + super.add(); + setId(leastSignificant, 0); + } + + /** + * Gets the id. + * + * @return an array of 16 bytes representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public byte[] getId() { + return getUInt128(at(0)); + } + + /** + * Gets the id. + * + * @param part a {@link UInt128} enum indicating which part of the 128-bit value is to be + * retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public long getId(final UInt128 part) { + return getUInt128(at(0), part); + } + + /** + * Sets the id. + * + * @param id an array of 16 bytes representing the 128-bit value. + * @throws NullPointerException if {@code id} is null. + * @throws IllegalArgumentException if {@code id} is not 16 bytes long. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setId(final byte[] id) { + Objects.requireNonNull(id, "Id cannot be null"); + putUInt128(at(0), id); + } + + /** + * Sets the id. + * + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setId(final long leastSignificant, final long mostSignificant) { + putUInt128(at(0), leastSignificant, mostSignificant); + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/InitializationException.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/InitializationException.java new file mode 100644 index 00000000..0f54cabd --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/InitializationException.java @@ -0,0 +1,37 @@ +package com.tigerbeetle; + +public final class InitializationException extends RuntimeException { + + private final int status; + + public InitializationException(int status) { + this.status = status; + } + + public int getStatus() { + return status; + } + + @Override + public String getMessage() { + return toString(); + } + + @Override + public String toString() { + if (status == InitializationStatus.Unexpected.value) + return "Unexpected internal error"; + else if (status == InitializationStatus.OutOfMemory.value) + return "Internal client ran out of memory"; + else if (status == InitializationStatus.AddressInvalid.value) + return "Replica addresses format is invalid"; + else if (status == InitializationStatus.AddressLimitExceeded.value) + return "Replica addresses limit exceeded"; + else if (status == InitializationStatus.SystemResources.value) + return "Internal client ran out of system resources"; + else if (status == InitializationStatus.NetworkSubsystem.value) + return "Internal client had unexpected networking issues"; + else + return "Error status " + status; + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/InitializationStatus.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/InitializationStatus.java new file mode 100644 index 00000000..f76c598b --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/InitializationStatus.java @@ -0,0 +1,37 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +public enum InitializationStatus { + Success((int) 0), + Unexpected((int) 1), + OutOfMemory((int) 2), + AddressInvalid((int) 3), + AddressLimitExceeded((int) 4), + SystemResources((int) 5), + NetworkSubsystem((int) 6); + + public final int value; + + InitializationStatus(int value) { + this.value = value; + } + + public static InitializationStatus fromValue(int value) { + switch (value) { + case 0: return Success; + case 1: return Unexpected; + case 2: return OutOfMemory; + case 3: return AddressInvalid; + case 4: return AddressLimitExceeded; + case 5: return SystemResources; + case 6: return NetworkSubsystem; + default: throw new IllegalArgumentException( + String.format("Invalid InitializationStatus value=%d", value)); + } + } +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/JNILoader.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/JNILoader.java new file mode 100644 index 00000000..f027f422 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/JNILoader.java @@ -0,0 +1,149 @@ +package com.tigerbeetle; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +final class JNILoader { + + enum OS { + windows, + linux, + macos; + + public static OS getOS() { + String osName = System.getProperty("os.name").toLowerCase(); + if (osName.startsWith("win")) { + return OS.windows; + } else if (osName.startsWith("mac") || osName.startsWith("darwin")) { + return OS.macos; + } else if (osName.startsWith("linux")) { + return OS.linux; + } else { + throw new AssertionError(String.format("Unsupported OS %s", osName)); + } + } + } + + enum Arch { + x86_64, + aarch64; + + public static Arch getArch() { + String osArch = System.getProperty("os.arch").toLowerCase(); + + if (osArch.startsWith("x86_64") || osArch.startsWith("amd64") + || osArch.startsWith("x64")) { + return Arch.x86_64; + } else if (osArch.startsWith("aarch64")) { + return Arch.aarch64; + } else { + throw new AssertionError(String.format("Unsupported OS arch %s", osArch)); + } + } + } + + enum Abi { + none, + gnu, + musl; + + public static Abi getAbi(OS os) { + if (os != OS.linux) + return Abi.none; + + /** + * We need to detect during runtime which libc the JVM uses to load the correct JNI lib. + * + * Rationale: The /proc/self/map_files/ subdirectory contains entries corresponding to + * memory-mapped files loaded by the JVM. + * https://man7.org/linux/man-pages/man5/proc.5.html: We detect a musl-based distro by + * checking if any library contains the name "musl". + * + * Prior art: https://github.com/xerial/sqlite-jdbc/issues/623 + */ + + final var mapFiles = Paths.get("/proc/self/map_files"); + try (var stream = Files.newDirectoryStream(mapFiles)) { + for (final Path path : stream) { + try { + final var libName = path.toRealPath().toString().toLowerCase(); + if (libName.contains("musl")) { + return Abi.musl; + } + } catch (IOException exception) { + continue; + } + } + } catch (IOException exception) { + } + + return Abi.gnu; + } + } + + private JNILoader() {} + + public static final String libName = "tb_jniclient"; + + public static void loadFromJar() { + + Arch arch = Arch.getArch(); + OS os = OS.getOS(); + Abi abi = Abi.getAbi(os); + + final String jniResourcesPath = getResourcesPath(arch, os, abi); + final String fileName = Paths.get(jniResourcesPath).getFileName().toString(); + + File temp; + + try (InputStream stream = JNILoader.class.getResourceAsStream(jniResourcesPath)) { + + if (stream == null) { + // It's not expected when running from the jar package. + // If not found, we fallback to the standard JVM path and let the + // UnsatisfiedLinkError alert if it couldn't be found there. + System.loadLibrary(libName); + return; + } + + temp = Files.createTempFile(fileName, "").toFile(); + Files.copy(stream, temp.toPath(), StandardCopyOption.REPLACE_EXISTING); + + } catch (IOException ioException) { + throw new AssertionError(ioException, + "TigerBeetle jni %s could not be extracted from jar.", fileName); + } + + System.load(temp.getAbsolutePath()); + temp.deleteOnExit(); + } + + static String getResourcesPath(Arch arch, OS os, Abi abi) { + + final String jniResources = String.format("/lib/%s-%s", arch, os); + + switch (os) { + case linux: + + return String.format("%s-%s/lib%s.so", jniResources, abi, libName); + + case macos: + + return String.format("%s/lib%s.dylib", jniResources, libName); + + case windows: + + if (arch == Arch.x86_64) + return String.format("%s/%s.dll", jniResources, libName); + break; + + } + + throw new AssertionError("Unsupported OS-arch %s-%s", os, arch); + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/NativeClient.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/NativeClient.java new file mode 100644 index 00000000..67f39908 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/NativeClient.java @@ -0,0 +1,102 @@ +package com.tigerbeetle; + +import static com.tigerbeetle.AssertionError.assertTrue; + +import java.lang.ref.Cleaner; +import java.nio.ByteBuffer; + +final class NativeClient implements AutoCloseable { + private final static Cleaner cleaner; + + /* + * Holds the `tb_client` buffer in an object instance detached from `NativeClient` to provide + * state for the cleaner to dispose native memory when the `Client` instance is GCed. Also + * implements `Runnable` to be usable as the cleaner action. + * https://docs.oracle.com/javase%2F9%2Fdocs%2Fapi%2F%2F/java/lang/ref/Cleaner.html + */ + private static final class CleanableState implements Runnable { + private ByteBuffer tb_client; + + public CleanableState(ByteBuffer tb_client) { + assertTrue(tb_client.isDirect(), "Invalid client buffer"); + this.tb_client = tb_client; + } + + public void submit(final Request request) { + NativeClient.submit(tb_client, request); + } + + public void close() { + clientDeinit(tb_client); + } + + @Override + public void run() { + close(); + } + } + + static { + JNILoader.loadFromJar(); + cleaner = Cleaner.create(); + } + + private final CleanableState state; + private final Cleaner.Cleanable cleanable; + + public static NativeClient init(final byte[] clusterID, final String addresses) { + assertArgs(clusterID, addresses); + final var tb_client = ByteBuffer.allocateDirect(TBClient.SIZE + TBClient.ALIGNMENT); + clientInit(tb_client, clusterID, addresses); + return new NativeClient(tb_client); + } + + public static NativeClient initEcho(final byte[] clusterID, final String addresses) { + assertArgs(clusterID, addresses); + final var tb_client = ByteBuffer.allocateDirect(TBClient.SIZE + TBClient.ALIGNMENT); + clientInitEcho(tb_client, clusterID, addresses); + return new NativeClient(tb_client); + } + + private static void assertArgs(final byte[] clusterID, final String addresses) { + assertTrue(clusterID.length == 16, "ClusterID must be a UInt128"); + assertTrue(addresses != null, "Replica addresses cannot be null"); + } + + private NativeClient(final ByteBuffer tb_client) { + try { + this.state = new CleanableState(tb_client); + this.cleanable = cleaner.register(this, state); + } catch (Throwable forward) { + clientDeinit(tb_client); + throw forward; + } + } + + public void submit(final Request request) { + this.state.submit(request); + } + + @Override + public void close() { + // When the user calls `close()` or the client is used in a `try-resource` block, + // we call `NativeHandle.close` to force it to run synchronously in the same thread. + // Otherwise, if the user never disposes the client and `close` is never called, + // the cleaner calls `NativeHandle.close` in another thread when the client is GCed. + this.state.close(); + + // Unregistering the cleanable. + cleanable.clean(); + } + + private static native void submit(ByteBuffer tb_client, Request request) + throws ClientClosedException; + + private static native void clientInit(ByteBuffer tb_client, byte[] clusterID, String addresses) + throws InitializationException; + + private static native void clientInitEcho(ByteBuffer tb_client, byte[] clusterID, + String addresses) throws InitializationException; + + private static native void clientDeinit(ByteBuffer tb_client); +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/PacketAcquireStatus.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/PacketAcquireStatus.java new file mode 100644 index 00000000..bf65f8d8 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/PacketAcquireStatus.java @@ -0,0 +1,28 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +enum PacketAcquireStatus { + Ok((int) 0), + ConcurrencyMaxExceeded((int) 1), + Shutdown((int) 2); + + public final int value; + + PacketAcquireStatus(int value) { + this.value = value; + } + + public static PacketAcquireStatus fromValue(int value) { + var values = PacketAcquireStatus.values(); + if (value < 0 || value >= values.length) + throw new IllegalArgumentException( + String.format("Invalid PacketAcquireStatus value=%d", value)); + + return values[value]; + } +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/PacketStatus.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/PacketStatus.java new file mode 100644 index 00000000..575f4b36 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/PacketStatus.java @@ -0,0 +1,39 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +enum PacketStatus { + Ok((byte) 0), + TooMuchData((byte) 1), + ClientEvicted((byte) 2), + ClientReleaseTooLow((byte) 3), + ClientReleaseTooHigh((byte) 4), + ClientShutdown((byte) 5), + InvalidOperation((byte) 6), + InvalidDataSize((byte) 7); + + public final byte value; + + PacketStatus(byte value) { + this.value = value; + } + + public static PacketStatus fromValue(byte value) { + switch (value) { + case 0: return Ok; + case 1: return TooMuchData; + case 2: return ClientEvicted; + case 3: return ClientReleaseTooLow; + case 4: return ClientReleaseTooHigh; + case 5: return ClientShutdown; + case 6: return InvalidOperation; + case 7: return InvalidDataSize; + default: throw new IllegalArgumentException( + String.format("Invalid PacketStatus value=%d", value)); + } + } +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/QueryFilter.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/QueryFilter.java new file mode 100644 index 00000000..79c2e5f7 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/QueryFilter.java @@ -0,0 +1,220 @@ +package com.tigerbeetle; + +public final class QueryFilter { + + // @formatter:off + /* + * Summary: + * + * Wraps the `QueryFilterBatch` auto-generated binding in a single-item batch. + * Since `queryAccounts()` and `queryTransfers()` expects only one item, we avoid + * exposing the `Batch` class externally. + * + * This is an ad-hoc feature meant to be replaced by a proper querying API shortly, + * therefore, it is not worth the effort to modify the binding generator to emit single-item batchs. + * + */ + // @formatter:on + + QueryFilterBatch batch; + + public QueryFilter() { + this.batch = new QueryFilterBatch(1); + this.batch.add(); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @see user_data_128 + */ + public byte[] getUserData128() { + return this.batch.getUserData128(); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value is to be + * retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @see user_data_128 + */ + public long getUserData128(final UInt128 part) { + return this.batch.getUserData128(part); + } + + /** + * @param userData128 an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code userData128} is not 16 bytes long. + * @see user_data_128 + */ + public void setUserData128(final byte[] userData128) { + this.batch.setUserData128(userData128); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @see user_data_128 + */ + public void setUserData128(final long leastSignificant, final long mostSignificant) { + this.batch.setUserData128(leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @see user_data_128 + */ + public void setUserData128(final long leastSignificant) { + this.batch.setUserData128(leastSignificant); + } + + /** + * @see user_data_64 + */ + public long getUserData64() { + return this.batch.getUserData64(); + } + + /** + * @param userData64 + * @see user_data_64 + */ + public void setUserData64(final long userData64) { + this.batch.setUserData64(userData64); + } + + /** + * @see user_data_32 + */ + public int getUserData32() { + return this.batch.getUserData32(); + } + + /** + * @param userData32 + * @see user_data_32 + */ + public void setUserData32(final int userData32) { + this.batch.setUserData32(userData32); + } + + /** + * @see ledger + */ + public int getLedger() { + return this.batch.getLedger(); + } + + /** + * @param ledger + * @see ledger + */ + public void setLedger(final int ledger) { + this.batch.setLedger(ledger); + } + + /** + * @see code + */ + public int getCode() { + return this.batch.getCode(); + } + + /** + * @param code + * @see code + */ + public void setCode(final int code) { + this.batch.setCode(code); + } + + /** + * @see timestamp_min + */ + public long getTimestampMin() { + return batch.getTimestampMin(); + } + + /** + * @param timestamp + * @see timestamp_min + */ + public void setTimestampMin(final long timestamp) { + batch.setTimestampMin(timestamp); + } + + /** + * @see timestamp_max + */ + public long getTimestampMax() { + return batch.getTimestampMax(); + } + + /** + * @param timestamp + * @see timestamp_max + */ + public void setTimestampMax(final long timestamp) { + batch.setTimestampMax(timestamp); + } + + /** + * @see limit + */ + public int getLimit() { + return batch.getLimit(); + } + + /** + * @param limit + * @see limit + */ + public void setLimit(final int limit) { + batch.setLimit(limit); + } + + /** + * @see reversed + */ + public boolean getReversed() { + return getFlags(QueryFilterFlags.REVERSED); + } + + /** + * @param value + * @see reversed + */ + public void setReversed(boolean value) { + setFlags(QueryFilterFlags.REVERSED, value); + } + + boolean getFlags(final int flag) { + final var value = batch.getFlags(); + return (value & flag) != 0; + } + + void setFlags(final int flag, final boolean enabled) { + var value = batch.getFlags(); + if (enabled) + value |= flag; + else + value &= ~flag; + batch.setFlags(value); + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/QueryFilterBatch.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/QueryFilterBatch.java new file mode 100644 index 00000000..a4bc48c7 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/QueryFilterBatch.java @@ -0,0 +1,252 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +import java.nio.ByteBuffer; + + +final class QueryFilterBatch extends Batch { + + + interface Struct { + int SIZE = 64; + + int UserData128 = 0; + int UserData64 = 16; + int UserData32 = 24; + int Ledger = 28; + int Code = 32; + int Reserved = 34; + int TimestampMin = 40; + int TimestampMax = 48; + int Limit = 56; + int Flags = 60; + } + + /** + * Creates an empty batch with the desired maximum capacity. + *

+ * Once created, an instance cannot be resized, however it may contain any number of elements + * between zero and its {@link #getCapacity capacity}. + * + * @param capacity the maximum capacity. + * @throws IllegalArgumentException if capacity is negative. + */ + public QueryFilterBatch(final int capacity) { + super(capacity, Struct.SIZE); + } + + QueryFilterBatch(final ByteBuffer buffer) { + super(buffer, Struct.SIZE); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public byte[] getUserData128() { + return getUInt128(at(Struct.UserData128)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public long getUserData128(final UInt128 part) { + return getUInt128(at(Struct.UserData128), part); + } + + /** + * @param userData128 an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code userData128} is not 16 bytes long. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setUserData128(final byte[] userData128) { + putUInt128(at(Struct.UserData128), userData128); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setUserData128(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.UserData128), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setUserData128(final long leastSignificant) { + putUInt128(at(Struct.UserData128), leastSignificant, 0); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public long getUserData64() { + final var value = getUInt64(at(Struct.UserData64)); + return value; + } + + /** + * @param userData64 + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setUserData64(final long userData64) { + putUInt64(at(Struct.UserData64), userData64); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public int getUserData32() { + final var value = getUInt32(at(Struct.UserData32)); + return value; + } + + /** + * @param userData32 + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setUserData32(final int userData32) { + putUInt32(at(Struct.UserData32), userData32); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public int getLedger() { + final var value = getUInt32(at(Struct.Ledger)); + return value; + } + + /** + * @param ledger + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setLedger(final int ledger) { + putUInt32(at(Struct.Ledger), ledger); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public int getCode() { + final var value = getUInt16(at(Struct.Code)); + return value; + } + + /** + * @param code + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setCode(final int code) { + putUInt16(at(Struct.Code), code); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + byte[] getReserved() { + return getArray(at(Struct.Reserved), 6); + } + + /** + * @param reserved + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + void setReserved(byte[] reserved) { + if (reserved == null) + reserved = new byte[6]; + if (reserved.length != 6) + throw new IllegalArgumentException("Reserved must be 6 bytes long"); + putArray(at(Struct.Reserved), reserved); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public long getTimestampMin() { + final var value = getUInt64(at(Struct.TimestampMin)); + return value; + } + + /** + * @param timestampMin + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setTimestampMin(final long timestampMin) { + putUInt64(at(Struct.TimestampMin), timestampMin); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public long getTimestampMax() { + final var value = getUInt64(at(Struct.TimestampMax)); + return value; + } + + /** + * @param timestampMax + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setTimestampMax(final long timestampMax) { + putUInt64(at(Struct.TimestampMax), timestampMax); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public int getLimit() { + final var value = getUInt32(at(Struct.Limit)); + return value; + } + + /** + * @param limit + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setLimit(final int limit) { + putUInt32(at(Struct.Limit), limit); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + */ + public int getFlags() { + final var value = getUInt32(at(Struct.Flags)); + return value; + } + + /** + * @param flags + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + */ + public void setFlags(final int flags) { + putUInt32(at(Struct.Flags), flags); + } + +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/QueryFilterFlags.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/QueryFilterFlags.java new file mode 100644 index 00000000..e31cda3e --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/QueryFilterFlags.java @@ -0,0 +1,16 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +interface QueryFilterFlags { + int NONE = (int) 0; + int REVERSED = (int) (1 << 0); + + static boolean hasReversed(final int flags) { + return (flags & REVERSED) == REVERSED; + } + +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/Request.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/Request.java new file mode 100644 index 00000000..5c4cc09a --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/Request.java @@ -0,0 +1,246 @@ +package com.tigerbeetle; + +import java.lang.annotation.Native; +import java.nio.ByteBuffer; +import java.util.Objects; + +import com.tigerbeetle.ClientReleaseException.Reason; + +abstract class Request { + + // @formatter:off + /* + * Overview: + * + * Implements a context that will be used to submit the request and to signal the completion. + * A reference to this class is stored by the JNI side in the "user_data" field when calling "tb_client_submit", + * meaning that no GC will occur before the callback completion + * + * Memory: + * + * - Holds the request body until the completion to be accessible by the C client. + * - Copies the response body to be exposed to the application. + * + * Completion: + * + * - See AsyncRequest.java and BlockingRequest.java + * + */ + // @formatter:on + + enum Operations { + // TODO Auto-generate these. + PULSE(128), + CREATE_ACCOUNTS(146), + CREATE_TRANSFERS(147), + LOOKUP_ACCOUNTS(140), + LOOKUP_TRANSFERS(141), + GET_ACCOUNT_TRANSFERS(142), + GET_ACCOUNT_BALANCES(143), + QUERY_ACCOUNTS(144), + QUERY_TRANSFERS(145), + + ECHO_ACCOUNTS(146), + ECHO_TRANSFERS(147); + + byte value; + + Operations(int value) { + this.value = (byte) value; + } + } + + static final ByteBuffer REPLY_EMPTY = ByteBuffer.allocate(0).asReadOnlyBuffer(); + + // Used only by the JNI side + @Native + private final ByteBuffer sendBuffer; + + @Native + private final long sendBufferLen; + + @Native + private byte[] replyBuffer; + + private final NativeClient nativeClient; + private final Operations operation; + private final int requestLen; + + protected Request(final NativeClient nativeClient, final Operations operation, + final Batch batch) { + Objects.requireNonNull(nativeClient, "Client cannot be null"); + Objects.requireNonNull(batch, "Batch cannot be null"); + + this.nativeClient = nativeClient; + this.operation = operation; + this.requestLen = batch.getLength(); + this.sendBuffer = batch.getBuffer(); + this.sendBufferLen = batch.getBufferLen(); + this.replyBuffer = null; + } + + public void beginRequest() { + nativeClient.submit(this); + } + + // Unchecked: Since we just support a limited set of operations, it is safe to cast the + // result to T[] + @SuppressWarnings("unchecked") + void endRequest(final byte receivedOperation, final byte status, final long timestamp) { + + // This method is called from the JNI side, on the tb_client thread + // We CAN'T throw any exception here, any event must be stored and + // handled from the user's thread on the completion. + + Batch result = null; + Throwable exception = null; + + try { + if (receivedOperation != operation.value) { + throw new AssertionError("Unexpected callback operation: expected=%d, actual=%d", + operation.value, receivedOperation); + + } + + switch (PacketStatus.fromValue(status)) { + case Ok: + switch (operation) { + case CREATE_ACCOUNTS: { + result = new CreateAccountResultBatch(replyBuffer == null ? REPLY_EMPTY + : ByteBuffer.wrap(replyBuffer)); + exception = checkResultLength(result); + break; + } + + case CREATE_TRANSFERS: { + result = new CreateTransferResultBatch(replyBuffer == null ? REPLY_EMPTY + : ByteBuffer.wrap(replyBuffer)); + exception = checkResultLength(result); + break; + } + + case ECHO_ACCOUNTS: + case LOOKUP_ACCOUNTS: { + result = new AccountBatch(replyBuffer == null ? REPLY_EMPTY + : ByteBuffer.wrap(replyBuffer)); + exception = checkResultLength(result); + break; + } + + case ECHO_TRANSFERS: + case LOOKUP_TRANSFERS: { + result = new TransferBatch(replyBuffer == null ? REPLY_EMPTY + : ByteBuffer.wrap(replyBuffer)); + exception = checkResultLength(result); + break; + } + + case GET_ACCOUNT_TRANSFERS: { + result = new TransferBatch(replyBuffer == null ? REPLY_EMPTY + : ByteBuffer.wrap(replyBuffer)); + break; + } + + case GET_ACCOUNT_BALANCES: { + result = new AccountBalanceBatch(replyBuffer == null ? REPLY_EMPTY + : ByteBuffer.wrap(replyBuffer)); + break; + } + + case QUERY_ACCOUNTS: { + result = new AccountBatch(replyBuffer == null ? REPLY_EMPTY + : ByteBuffer.wrap(replyBuffer)); + break; + } + + case QUERY_TRANSFERS: { + result = new TransferBatch(replyBuffer == null ? REPLY_EMPTY + : ByteBuffer.wrap(replyBuffer)); + break; + } + + default: { + exception = new AssertionError("Unknown operation %d", operation); + break; + } + } + break; + + case TooMuchData: { + exception = new TooMuchDataException(); + break; + } + + case ClientEvicted: { + exception = new ClientEvictedException(); + break; + } + + case ClientReleaseTooHigh: { + exception = new ClientReleaseException(Reason.ClientReleaseTooHigh); + break; + } + + case ClientReleaseTooLow: { + exception = new ClientReleaseException(Reason.ClientReleaseTooLow); + break; + } + + case ClientShutdown: { + exception = new ClientClosedException(); + break; + } + + case InvalidDataSize: + case InvalidOperation: + default: { + exception = new AssertionError("Unexpected PacketStatus %d", status); + break; + } + } + } catch (Throwable any) { + exception = any; + } + + try { + if (exception == null) { + result.setHeader(new Batch.Header(timestamp)); + setResult((TResponse) result); + } else { + setException(exception); + } + } catch (Throwable any) { + System.err.println("Completion of request failed!\n" + + "This is a bug in TigerBeetle. Please report it at https://github.com/tigerbeetle/tigerbeetle.\n" + + "Cause: " + any.toString()); + any.printStackTrace(); + Runtime.getRuntime().halt(1); + } + } + + private AssertionError checkResultLength(Batch result) { + if (result.getLength() > requestLen) { + return new AssertionError( + "Amount of results is greater than the amount of requests: resultLen=%d, requestLen=%d", + result.getLength(), requestLen); + } else { + return null; + } + } + + // Unused: Used by unit tests. + @SuppressWarnings("unused") + void setReplyBuffer(byte[] buffer) { + this.replyBuffer = buffer; + } + + // Unused: Used by the JNI side. + @SuppressWarnings("unused") + byte getOperation() { + return this.operation.value; + } + + protected abstract void setResult(final TResponse result); + + protected abstract void setException(final Throwable exception); +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/RequestException.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/RequestException.java new file mode 100644 index 00000000..61c421ed --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/RequestException.java @@ -0,0 +1,9 @@ +package com.tigerbeetle; + +/** + * Abstract unchecked exception that may occur during a request. See the derived exceptions for the + * specific failures. + **/ +public abstract class RequestException extends RuntimeException { + RequestException() {} +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/TBClient.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/TBClient.java new file mode 100644 index 00000000..ccce83e8 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/TBClient.java @@ -0,0 +1,6 @@ +package com.tigerbeetle; + +interface TBClient { + int SIZE = 32; + int ALIGNMENT = 8; +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/TooMuchDataException.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/TooMuchDataException.java new file mode 100644 index 00000000..5451d79e --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/TooMuchDataException.java @@ -0,0 +1,22 @@ +package com.tigerbeetle; + +/** + * TooMuchDataException is thrown when the number of events or expected results exceeds the maximum + * message size. If this exception is thrown, then either there are too many elements in a batch, or + * the limit of a query is too large to be fulfilled in a single request. + **/ +public final class TooMuchDataException extends RequestException { + + TooMuchDataException() {} + + @Override + public String getMessage() { + return toString(); + } + + @Override + public String toString() { + return "Too much data was sent or requested in this batch."; + } + +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/TransferBatch.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/TransferBatch.java new file mode 100644 index 00000000..807e4e18 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/TransferBatch.java @@ -0,0 +1,510 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +import java.nio.ByteBuffer; +import java.math.BigInteger; + +public final class TransferBatch extends Batch { + + public static final BigInteger AMOUNT_MAX = UInt128.INT_MAX; + + interface Struct { + int SIZE = 128; + + int Id = 0; + int DebitAccountId = 16; + int CreditAccountId = 32; + int Amount = 48; + int PendingId = 64; + int UserData128 = 80; + int UserData64 = 96; + int UserData32 = 104; + int Timeout = 108; + int Ledger = 112; + int Code = 116; + int Flags = 118; + int Timestamp = 120; + } + + /** + * Creates an empty batch with the desired maximum capacity. + *

+ * Once created, an instance cannot be resized, however it may contain any number of elements + * between zero and its {@link #getCapacity capacity}. + * + * @param capacity the maximum capacity. + * @throws IllegalArgumentException if capacity is negative. + */ + public TransferBatch(final int capacity) { + super(capacity, Struct.SIZE); + } + + TransferBatch(final ByteBuffer buffer) { + super(buffer, Struct.SIZE); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see id + */ + public byte[] getId() { + return getUInt128(at(Struct.Id)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see id + */ + public long getId(final UInt128 part) { + return getUInt128(at(Struct.Id), part); + } + + /** + * @param id an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code id} is not 16 bytes long. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see id + */ + public void setId(final byte[] id) { + putUInt128(at(Struct.Id), id); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see id + */ + public void setId(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.Id), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see id + */ + public void setId(final long leastSignificant) { + putUInt128(at(Struct.Id), leastSignificant, 0); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see debit_account_id + */ + public byte[] getDebitAccountId() { + return getUInt128(at(Struct.DebitAccountId)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see debit_account_id + */ + public long getDebitAccountId(final UInt128 part) { + return getUInt128(at(Struct.DebitAccountId), part); + } + + /** + * @param debitAccountId an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code debitAccountId} is not 16 bytes long. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debit_account_id + */ + public void setDebitAccountId(final byte[] debitAccountId) { + putUInt128(at(Struct.DebitAccountId), debitAccountId); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debit_account_id + */ + public void setDebitAccountId(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.DebitAccountId), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see debit_account_id + */ + public void setDebitAccountId(final long leastSignificant) { + putUInt128(at(Struct.DebitAccountId), leastSignificant, 0); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see credit_account_id + */ + public byte[] getCreditAccountId() { + return getUInt128(at(Struct.CreditAccountId)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see credit_account_id + */ + public long getCreditAccountId(final UInt128 part) { + return getUInt128(at(Struct.CreditAccountId), part); + } + + /** + * @param creditAccountId an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code creditAccountId} is not 16 bytes long. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credit_account_id + */ + public void setCreditAccountId(final byte[] creditAccountId) { + putUInt128(at(Struct.CreditAccountId), creditAccountId); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credit_account_id + */ + public void setCreditAccountId(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.CreditAccountId), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see credit_account_id + */ + public void setCreditAccountId(final long leastSignificant) { + putUInt128(at(Struct.CreditAccountId), leastSignificant, 0); + } + + /** + * @return a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see amount + */ + public BigInteger getAmount() { + final var index = at(Struct.Amount); + return UInt128.asBigInteger( + getUInt128(index, UInt128.LeastSignificant), + getUInt128(index, UInt128.MostSignificant)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see amount + */ + public long getAmount(final UInt128 part) { + return getUInt128(at(Struct.Amount), part); + } + + /** + * @param amount a {@link java.math.BigInteger} representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see amount + */ + public void setAmount(final BigInteger amount) { + putUInt128(at(Struct.Amount), UInt128.asBytes(amount)); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see amount + */ + public void setAmount(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.Amount), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see amount + */ + public void setAmount(final long leastSignificant) { + putUInt128(at(Struct.Amount), leastSignificant, 0); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see pending_id + */ + public byte[] getPendingId() { + return getUInt128(at(Struct.PendingId)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see pending_id + */ + public long getPendingId(final UInt128 part) { + return getUInt128(at(Struct.PendingId), part); + } + + /** + * @param pendingId an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code pendingId} is not 16 bytes long. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see pending_id + */ + public void setPendingId(final byte[] pendingId) { + putUInt128(at(Struct.PendingId), pendingId); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see pending_id + */ + public void setPendingId(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.PendingId), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see pending_id + */ + public void setPendingId(final long leastSignificant) { + putUInt128(at(Struct.PendingId), leastSignificant, 0); + } + + /** + * @return an array of 16 bytes representing the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see user_data_128 + */ + public byte[] getUserData128() { + return getUInt128(at(Struct.UserData128)); + } + + /** + * @param part a {@link UInt128} enum indicating which part of the 128-bit value + is to be retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see user_data_128 + */ + public long getUserData128(final UInt128 part) { + return getUInt128(at(Struct.UserData128), part); + } + + /** + * @param userData128 an array of 16 bytes representing the 128-bit value. + * @throws IllegalArgumentException if {@code userData128} is not 16 bytes long. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see user_data_128 + */ + public void setUserData128(final byte[] userData128) { + putUInt128(at(Struct.UserData128), userData128); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see user_data_128 + */ + public void setUserData128(final long leastSignificant, final long mostSignificant) { + putUInt128(at(Struct.UserData128), leastSignificant, mostSignificant); + } + + /** + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see user_data_128 + */ + public void setUserData128(final long leastSignificant) { + putUInt128(at(Struct.UserData128), leastSignificant, 0); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see user_data_64 + */ + public long getUserData64() { + final var value = getUInt64(at(Struct.UserData64)); + return value; + } + + /** + * @param userData64 + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see user_data_64 + */ + public void setUserData64(final long userData64) { + putUInt64(at(Struct.UserData64), userData64); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see user_data_32 + */ + public int getUserData32() { + final var value = getUInt32(at(Struct.UserData32)); + return value; + } + + /** + * @param userData32 + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see user_data_32 + */ + public void setUserData32(final int userData32) { + putUInt32(at(Struct.UserData32), userData32); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see timeout + */ + public int getTimeout() { + final var value = getUInt32(at(Struct.Timeout)); + return value; + } + + /** + * @param timeout + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see timeout + */ + public void setTimeout(final int timeout) { + putUInt32(at(Struct.Timeout), timeout); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see ledger + */ + public int getLedger() { + final var value = getUInt32(at(Struct.Ledger)); + return value; + } + + /** + * @param ledger + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see ledger + */ + public void setLedger(final int ledger) { + putUInt32(at(Struct.Ledger), ledger); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see code + */ + public int getCode() { + final var value = getUInt16(at(Struct.Code)); + return value; + } + + /** + * @param code + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see code + */ + public void setCode(final int code) { + putUInt16(at(Struct.Code), code); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see flags + */ + public int getFlags() { + final var value = getUInt16(at(Struct.Flags)); + return value; + } + + /** + * @param flags + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see flags + */ + public void setFlags(final int flags) { + putUInt16(at(Struct.Flags), flags); + } + + /** + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @see timestamp + */ + public long getTimestamp() { + final var value = getUInt64(at(Struct.Timestamp)); + return value; + } + + /** + * @param timestamp + * @throws IllegalStateException if not at a {@link #isValidPosition valid position}. + * @throws IllegalStateException if a {@link #isReadOnly() read-only} batch. + * @see timestamp + */ + public void setTimestamp(final long timestamp) { + putUInt64(at(Struct.Timestamp), timestamp); + } + +} + diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/TransferFlags.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/TransferFlags.java new file mode 100644 index 00000000..ef8ecc95 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/TransferFlags.java @@ -0,0 +1,92 @@ +////////////////////////////////////////////////////////// +// This file was auto-generated by java_bindings.zig +// Do not manually modify. +////////////////////////////////////////////////////////// + +package com.tigerbeetle; + +public interface TransferFlags { + int NONE = (int) 0; + + /** + * @see linked + */ + int LINKED = (int) (1 << 0); + + /** + * @see pending + */ + int PENDING = (int) (1 << 1); + + /** + * @see post_pending_transfer + */ + int POST_PENDING_TRANSFER = (int) (1 << 2); + + /** + * @see void_pending_transfer + */ + int VOID_PENDING_TRANSFER = (int) (1 << 3); + + /** + * @see balancing_debit + */ + int BALANCING_DEBIT = (int) (1 << 4); + + /** + * @see balancing_credit + */ + int BALANCING_CREDIT = (int) (1 << 5); + + /** + * @see closing_debit + */ + int CLOSING_DEBIT = (int) (1 << 6); + + /** + * @see closing_credit + */ + int CLOSING_CREDIT = (int) (1 << 7); + + /** + * @see imported + */ + int IMPORTED = (int) (1 << 8); + + static boolean hasLinked(final int flags) { + return (flags & LINKED) == LINKED; + } + + static boolean hasPending(final int flags) { + return (flags & PENDING) == PENDING; + } + + static boolean hasPostPendingTransfer(final int flags) { + return (flags & POST_PENDING_TRANSFER) == POST_PENDING_TRANSFER; + } + + static boolean hasVoidPendingTransfer(final int flags) { + return (flags & VOID_PENDING_TRANSFER) == VOID_PENDING_TRANSFER; + } + + static boolean hasBalancingDebit(final int flags) { + return (flags & BALANCING_DEBIT) == BALANCING_DEBIT; + } + + static boolean hasBalancingCredit(final int flags) { + return (flags & BALANCING_CREDIT) == BALANCING_CREDIT; + } + + static boolean hasClosingDebit(final int flags) { + return (flags & CLOSING_DEBIT) == CLOSING_DEBIT; + } + + static boolean hasClosingCredit(final int flags) { + return (flags & CLOSING_CREDIT) == CLOSING_CREDIT; + } + + static boolean hasImported(final int flags) { + return (flags & IMPORTED) == IMPORTED; + } + +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/UInt128.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/UInt128.java new file mode 100644 index 00000000..79bec69e --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/UInt128.java @@ -0,0 +1,245 @@ +package com.tigerbeetle; + +import java.security.SecureRandom; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Objects; +import java.util.UUID; + +public enum UInt128 { + + LeastSignificant, + MostSignificant; + + public static final int SIZE = 16; + + private static final BigInteger MOST_SIGNIFICANT_MASK = BigInteger.ONE.shiftLeft(64); + private static final BigInteger LEAST_SIGNIFICANT_MASK = BigInteger.valueOf(Long.MAX_VALUE); + + // Maximum unsigned 128-bit integer: 2^128 - 1. + static final BigInteger INT_MAX = BigInteger.ONE.shiftLeft(128).add(BigInteger.ONE.negate()); + + /** + * Gets the partial 64-bit representation of a 128-bit unsigned integer. + * + * @param bytes an array of 16 bytes representing the 128-bit value. + * @param part a {@link UInt128} enum indicating which part of the 128-bit value is to be + * retrieved. + * @return a {@code long} representing the first 8 bytes of the 128-bit value if + * {@link UInt128#LeastSignificant} is informed, or the last 8 bytes if + * {@link UInt128#MostSignificant}. + * + * @throws NullPointerException if {@code bytes} is null. + * @throws IllegalArgumentException if {@code bytes} is not 16 bytes long. + */ + public static long asLong(final byte[] bytes, final UInt128 part) { + Objects.requireNonNull(bytes, "Bytes cannot be null"); + + if (bytes.length != UInt128.SIZE) + throw new IllegalArgumentException("Bytes must be 16 bytes long"); + + var buffer = ByteBuffer.wrap(bytes).order(Batch.BYTE_ORDER).position(0); + if (part == UInt128.MostSignificant) + buffer.position(Long.BYTES); + return buffer.getLong(); + } + + /** + * Gets an array of 16 bytes representing the 128-bit value. + * + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @return an array of 16 bytes representing the 128-bit value. + */ + public static byte[] asBytes(final long leastSignificant, final long mostSignificant) { + byte[] bytes = new byte[UInt128.SIZE]; + + if (leastSignificant != 0 || mostSignificant != 0) { + var buffer = ByteBuffer.wrap(bytes).order(Batch.BYTE_ORDER); + buffer.putLong(leastSignificant); + buffer.putLong(mostSignificant); + } + + return bytes; + } + + /** + * Gets an array of 16 bytes representing the 128-bit value. + * + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @return an array of 16 bytes representing the 128-bit value. + */ + public static byte[] asBytes(final long leastSignificant) { + return asBytes(leastSignificant, 0); + } + + /** + * Gets an array of 16 bytes representing the UUID. + * + * @param uuid a {@link java.util.UUID} + * @return an array of 16 bytes representing the 128-bit value. + * + * @throws NullPointerException if {@code uuid} is null. + */ + public static byte[] asBytes(final UUID uuid) { + Objects.requireNonNull(uuid, "Uuid cannot be null"); + return asBytes(uuid.getLeastSignificantBits(), uuid.getMostSignificantBits()); + } + + /** + * Gets a {@link java.util.UUID} representing a 128-bit value. + * + * @param bytes an array of 16 bytes representing the 128-bit value. + * @return a {@link java.util.UUID}. + * + * @throws NullPointerException if {@code bytes} is null. + * @throws IllegalArgumentException if {@code bytes} is not 16 bytes long. + */ + public static UUID asUUID(final byte[] bytes) { + final long leastSignificant = asLong(bytes, UInt128.LeastSignificant); + final long mostSignificant = asLong(bytes, UInt128.MostSignificant); + return new UUID(mostSignificant, leastSignificant); + } + + /** + * Gets a {@link java.math.BigInteger} representing a 128-bit unsigned integer. + * + * @param leastSignificant a {@code long} representing the first 8 bytes of the 128-bit value. + * @param mostSignificant a {@code long} representing the last 8 bytes of the 128-bit value. + * @return a {@link java.math.BigInteger}. + */ + public static BigInteger asBigInteger(final long leastSignificant, final long mostSignificant) { + if (leastSignificant == 0 && mostSignificant == 0) { + return BigInteger.ZERO; + } + + var bigintMsb = BigInteger.valueOf(mostSignificant); + var bigintLsb = BigInteger.valueOf(leastSignificant); + + if (bigintMsb.signum() < 0) { + bigintMsb = bigintMsb.add(MOST_SIGNIFICANT_MASK); + } + if (bigintLsb.signum() < 0) { + bigintLsb = bigintLsb.add(MOST_SIGNIFICANT_MASK); + } + + return bigintLsb.add(bigintMsb.multiply(MOST_SIGNIFICANT_MASK)); + } + + /** + * Gets a {@link java.math.BigInteger} representing a 128-bit unsigned integer. + * + * @param bytes an array of 16 bytes representing the 128-bit value. + * @return a {@code java.math.BigInteger}. + * + * @throws NullPointerException if {@code bytes} is null. + * @throws IllegalArgumentException if {@code bytes} is not 16 bytes long. + */ + public static BigInteger asBigInteger(final byte[] bytes) { + Objects.requireNonNull(bytes, "Bytes cannot be null"); + + if (bytes.length != UInt128.SIZE) + throw new IllegalArgumentException("Bytes must be 16 bytes long"); + + final var buffer = ByteBuffer.wrap(bytes).order(Batch.BYTE_ORDER).position(0); + return asBigInteger(buffer.getLong(), buffer.getLong()); + } + + /** + * Gets an array of 16 bytes representing the 128-bit unsigned integer. + * + * @param value a {@link java.math.BigDecimal} + * @return an array of 16 bytes representing the 128-bit unsigned value. + * + * @throws NullPointerException if {@code value} is null. + * @throws IllegalArgumentException if {@code value} is negative. + */ + public static byte[] asBytes(final BigInteger value) { + Objects.requireNonNull(value, "Value cannot be null"); + if (value.signum() < 0) + throw new IllegalArgumentException("Value cannot be negative"); + + if (value.compareTo(INT_MAX) > 0) + throw new IllegalArgumentException("Value larger than a 128-bit integer"); + + if (BigInteger.ZERO.equals(value)) + return new byte[SIZE]; + + final var parts = value.divideAndRemainder(MOST_SIGNIFICANT_MASK); + BigInteger bigintMsb = parts[0]; + BigInteger bigintLsb = parts[1]; + + if (LEAST_SIGNIFICANT_MASK.compareTo(bigintMsb) < 0) { + bigintMsb = bigintMsb.subtract(MOST_SIGNIFICANT_MASK); + } + + if (LEAST_SIGNIFICANT_MASK.compareTo(bigintLsb) < 0) { + bigintLsb = bigintLsb.subtract(MOST_SIGNIFICANT_MASK); + } + + return asBytes(bigintLsb.longValueExact(), bigintMsb.longValueExact()); + } + + private static long idLastTimestamp = 0L; + private static final byte[] idLastRandom = new byte[10]; + private static final SecureRandom idSecureRandom = new SecureRandom(); + + /** + * Generates a Universally Unique Binary Sortable Identifier as 16 bytes of a 128-bit value. + * + * The ID() function is thread-safe, the bytes returned are stored in little endian, and the + * unsigned 128-bit value increases monotonically. The algorithm is based on + * ULID but is adjusted for u128-LE interpretation. + * + * @throws ArithmeticException if the random monotonic bits in the same millisecond overflows. + * @return An array of 16 bytes representing an unsigned 128-bit value in little endian. + */ + public static byte[] id() { + long randomLo; + short randomHi; + long timestamp = System.currentTimeMillis(); + + // Only modify the static variables in the synchronized block. + synchronized (idSecureRandom) { + // Ensure timestamp is monotonic. If it advances forward, also generate a new random. + if (timestamp <= idLastTimestamp) { + timestamp = idLastTimestamp; + } else { + idLastTimestamp = timestamp; + idSecureRandom.nextBytes(idLastRandom); + } + + var random = ByteBuffer.wrap(idLastRandom).order(ByteOrder.nativeOrder()); + randomLo = random.getLong(); + randomHi = random.getShort(); + + // Increment the u80 stored in idLastRandom using a u64 increment then u16 increment. + // If both overflow, increment timestamp too. + // In Java, all arithmetic wraps around on overflow by default so check for zero. + randomLo += 1; + if (randomLo == 0) { + randomHi += 1; + if (randomHi == 0) { + timestamp += 1; + idLastTimestamp = timestamp; + if (timestamp == 1 << 48) { + throw new ArithmeticException("Timestamp overflow on monotonic increment"); + } + } + } + + // Write back the incremented random. + random.flip(); + random.putLong(randomLo); + random.putShort(randomHi); + } + + var buffer = ByteBuffer.allocate(UInt128.SIZE).order(Batch.BYTE_ORDER); + buffer.putLong(randomLo); + buffer.putShort(randomHi); + buffer.putShort((short) timestamp); // timestamp lo + buffer.putInt((int) (timestamp >> 16)); // timestamp hi + return buffer.array(); + } +} diff --git a/ocam/src/clients/java/src/main/java/com/tigerbeetle/package-info.java b/ocam/src/clients/java/src/main/java/com/tigerbeetle/package-info.java new file mode 100644 index 00000000..54eb5b27 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/com/tigerbeetle/package-info.java @@ -0,0 +1,7 @@ +/** + * TigerBeetle client for Java. + * + * @see TigerBeetle Docs + * @see Source code + */ +package com.tigerbeetle; diff --git a/ocam/src/clients/java/src/main/java/module-info.java b/ocam/src/clients/java/src/main/java/module-info.java new file mode 100644 index 00000000..f5ae2021 --- /dev/null +++ b/ocam/src/clients/java/src/main/java/module-info.java @@ -0,0 +1,3 @@ +module com.tigerbeetle { + exports com.tigerbeetle; +} \ No newline at end of file diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/AccountBalanceTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/AccountBalanceTest.java new file mode 100644 index 00000000..fa7c415b --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/AccountBalanceTest.java @@ -0,0 +1,144 @@ +package com.tigerbeetle; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import java.math.BigInteger; + +import org.junit.Test; + +public class AccountBalanceTest { + + @Test + public void testDefaultValues() { + final var balances = new AccountBalanceBatch(1); + balances.add(); + + assertEquals(0L, balances.getTimestamp()); + assertEquals(BigInteger.ZERO, balances.getDebitsPending()); + assertEquals(0L, balances.getDebitsPending(UInt128.LeastSignificant)); + assertEquals(0L, balances.getDebitsPending(UInt128.MostSignificant)); + assertEquals(BigInteger.ZERO, balances.getDebitsPosted()); + assertEquals(0L, balances.getDebitsPosted(UInt128.LeastSignificant)); + assertEquals(0L, balances.getDebitsPosted(UInt128.MostSignificant)); + assertEquals(BigInteger.ZERO, balances.getCreditsPending()); + assertEquals(0L, balances.getCreditsPending(UInt128.LeastSignificant)); + assertEquals(0L, balances.getCreditsPending(UInt128.MostSignificant)); + assertEquals(BigInteger.ZERO, balances.getCreditsPosted()); + assertEquals(0L, balances.getCreditsPosted(UInt128.LeastSignificant)); + assertEquals(0L, balances.getCreditsPosted(UInt128.MostSignificant)); + assertArrayEquals(new byte[56], balances.getReserved()); + } + + @Test + public void testCreditsPending() { + final var balances = new AccountBalanceBatch(1); + balances.add(); + + final var value = new BigInteger("123456789012345678901234567890"); + balances.setCreditsPending(value); + assertEquals(value, balances.getCreditsPending()); + } + + + @Test + public void testCreditsPendingLong() { + final var balances = new AccountBalanceBatch(1); + balances.add(); + + balances.setCreditsPending(999); + assertEquals(BigInteger.valueOf(999), balances.getCreditsPending()); + + balances.setCreditsPending(999, 1); + assertEquals(UInt128.asBigInteger(999, 1), balances.getCreditsPending()); + assertEquals(999L, balances.getCreditsPending(UInt128.LeastSignificant)); + assertEquals(1L, balances.getCreditsPending(UInt128.MostSignificant)); + } + + @Test + public void testCreditsPosted() { + final var balances = new AccountBalanceBatch(1); + balances.add(); + + final var value = new BigInteger("123456789012345678901234567890"); + balances.setCreditsPosted(value); + assertEquals(value, balances.getCreditsPosted()); + } + + @Test + public void testCreditsPostedLong() { + final var balances = new AccountBalanceBatch(1); + balances.add(); + + balances.setCreditsPosted(999); + assertEquals(BigInteger.valueOf(999), balances.getCreditsPosted()); + + balances.setCreditsPosted(999, 1); + assertEquals(UInt128.asBigInteger(999, 1), balances.getCreditsPosted()); + assertEquals(999L, balances.getCreditsPosted(UInt128.LeastSignificant)); + assertEquals(1L, balances.getCreditsPosted(UInt128.MostSignificant)); + } + + @Test + public void testDebitsPosted() { + final var balances = new AccountBalanceBatch(1); + balances.add(); + + final var value = new BigInteger("123456789012345678901234567890"); + balances.setDebitsPosted(value); + assertEquals(value, balances.getDebitsPosted()); + } + + @Test + public void testDebitsPostedLong() { + final var balances = new AccountBalanceBatch(1); + balances.add(); + + balances.setDebitsPosted(999); + assertEquals(BigInteger.valueOf(999), balances.getDebitsPosted()); + + balances.setDebitsPosted(999, 1); + assertEquals(UInt128.asBigInteger(999, 1), balances.getDebitsPosted()); + assertEquals(999L, balances.getDebitsPosted(UInt128.LeastSignificant)); + assertEquals(1L, balances.getDebitsPosted(UInt128.MostSignificant)); + } + + @Test + public void testDebitsPending() { + final var balances = new AccountBalanceBatch(1); + balances.add(); + + final var value = new BigInteger("123456789012345678901234567890"); + balances.setDebitsPending(value); + assertEquals(value, balances.getDebitsPending()); + } + + @Test + public void testDebitsPendingLong() { + var balances = new AccountBalanceBatch(1); + balances.add(); + + balances.setDebitsPending(999); + assertEquals(BigInteger.valueOf(999), balances.getDebitsPending()); + + balances.setDebitsPending(999, 1); + assertEquals(UInt128.asBigInteger(999, 1), balances.getDebitsPending()); + assertEquals(999L, balances.getDebitsPending(UInt128.LeastSignificant)); + assertEquals(1L, balances.getDebitsPending(UInt128.MostSignificant)); + } + + @Test + public void testReserved() { + var balances = new AccountBalanceBatch(1); + balances.add(); + + final var bytes = new byte[56]; + for (byte i = 0; i < 56; i++) { + bytes[i] = i; + } + + balances.setReserved(bytes); + assertArrayEquals(bytes, balances.getReserved()); + } + +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/AccountFilterTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/AccountFilterTest.java new file mode 100644 index 00000000..99b9f7fb --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/AccountFilterTest.java @@ -0,0 +1,221 @@ +package com.tigerbeetle; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import org.junit.Test; + +public class AccountFilterTest { + + @Test + public void testDefaultValues() { + final var accountFilter = new AccountFilter(); + assertEquals(0L, accountFilter.getAccountId(UInt128.LeastSignificant)); + assertEquals(0L, accountFilter.getAccountId(UInt128.MostSignificant)); + assertEquals(0L, accountFilter.getTimestampMin()); + assertEquals(0L, accountFilter.getTimestampMax()); + assertEquals(0, accountFilter.getLimit()); + assertEquals(false, accountFilter.getDebits()); + assertEquals(false, accountFilter.getCredits()); + assertEquals(false, accountFilter.getReversed()); + } + + @Test + public void testAccountId() { + final var accountFilter = new AccountFilter(); + + accountFilter.setAccountId(100, 200); + assertEquals(100L, accountFilter.getAccountId(UInt128.LeastSignificant)); + assertEquals(200L, accountFilter.getAccountId(UInt128.MostSignificant)); + } + + @Test + public void testAccountIdLong() { + final var accountFilter = new AccountFilter(); + + accountFilter.setAccountId(100); + assertEquals(100L, accountFilter.getAccountId(UInt128.LeastSignificant)); + assertEquals(0L, accountFilter.getAccountId(UInt128.MostSignificant)); + } + + @Test + public void testAccountIdIdAsBytes() { + final var accountFilter = new AccountFilter(); + + final var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}; + accountFilter.setAccountId(id); + assertArrayEquals(id, accountFilter.getAccountId()); + } + + @Test + public void testAccountIdNull() { + final var accountFilter = new AccountFilter(); + + final byte[] id = null; + accountFilter.setAccountId(id); + + assertArrayEquals(new byte[16], accountFilter.getAccountId()); + } + + @Test(expected = IllegalArgumentException.class) + public void testAccountIdInvalid() { + final var accountFilter = new AccountFilter(); + + final var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + accountFilter.setAccountId(id); + fail(); + } + + @Test + public void testUserData128() { + final var accountFilter = new AccountFilter(); + + accountFilter.setUserData128(100, 200); + assertEquals(100L, accountFilter.getUserData128(UInt128.LeastSignificant)); + assertEquals(200L, accountFilter.getUserData128(UInt128.MostSignificant)); + } + + @Test + public void testUserData128Long() { + final var accountFilter = new AccountFilter(); + + accountFilter.setUserData128(100); + assertEquals(100L, accountFilter.getUserData128(UInt128.LeastSignificant)); + assertEquals(0L, accountFilter.getUserData128(UInt128.MostSignificant)); + } + + @Test + public void testUserData128AsBytes() { + final var accountFilter = new AccountFilter(); + + final var data = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}; + accountFilter.setUserData128(data); + assertArrayEquals(data, accountFilter.getUserData128()); + } + + @Test + public void testUserData128Null() { + final var accountFilter = new AccountFilter(); + + final byte[] data = null; + accountFilter.setUserData128(data); + + assertArrayEquals(new byte[16], accountFilter.getUserData128()); + } + + @Test(expected = IllegalArgumentException.class) + public void testUserData128Invalid() { + final var accountFilter = new AccountFilter(); + + final var data = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + accountFilter.setUserData128(data); + fail(); + } + + @Test + public void testUserData64() { + final var accountFilter = new AccountFilter(); + + accountFilter.setUserData64(100L); + assertEquals(100L, accountFilter.getUserData64()); + } + + @Test + public void testUserData32() { + final var accountFilter = new AccountFilter(); + + accountFilter.setUserData32(10); + assertEquals(10, accountFilter.getUserData32()); + } + + @Test + public void testCode() { + final var accountFilter = new AccountFilter(); + + accountFilter.setCode(1); + assertEquals(1, accountFilter.getCode()); + } + + @Test + public void testReserved() { + final var accountFilter = new AccountFilterBatch(1); + accountFilter.add(); + + // Empty array: + final var bytes = new byte[58]; + assertArrayEquals(new byte[58], accountFilter.getReserved()); + + // Null == empty array: + assertArrayEquals(new byte[58], accountFilter.getReserved()); + accountFilter.setReserved(null); + + for (byte i = 0; i < 58; i++) { + bytes[i] = i; + } + accountFilter.setReserved(bytes); + assertArrayEquals(bytes, accountFilter.getReserved()); + } + + @Test(expected = IllegalArgumentException.class) + public void testReservedInvalid() { + final var accountFilter = new AccountFilterBatch(1); + accountFilter.add(); + accountFilter.setReserved(new byte[59]); + fail(); + } + + @Test + public void testTimestampMin() { + final var accountFilter = new AccountFilter(); + + accountFilter.setTimestampMin(100L); + assertEquals(100, accountFilter.getTimestampMin()); + } + + @Test + public void testTimestampMax() { + final var accountFilter = new AccountFilter(); + + accountFilter.setTimestampMax(100L); + assertEquals(100, accountFilter.getTimestampMax()); + } + + @Test + public void testLimit() { + final var accountFilter = new AccountFilter(); + + accountFilter.setLimit(30); + assertEquals(30, accountFilter.getLimit()); + } + + @Test + public void testFlags() { + // Debits + { + final var accountFilter = new AccountFilter(); + accountFilter.setDebits(true); + assertEquals(true, accountFilter.getDebits()); + assertEquals(false, accountFilter.getCredits()); + assertEquals(false, accountFilter.getReversed()); + } + + // Credits + { + final var accountFilter = new AccountFilter(); + accountFilter.setCredits(true); + assertEquals(false, accountFilter.getDebits()); + assertEquals(true, accountFilter.getCredits()); + assertEquals(false, accountFilter.getReversed()); + } + + // Direction + { + final var accountFilter = new AccountFilter(); + accountFilter.setReversed(true); + assertEquals(false, accountFilter.getDebits()); + assertEquals(false, accountFilter.getCredits()); + assertEquals(true, accountFilter.getReversed()); + } + } +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/AccountFlagsTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/AccountFlagsTest.java new file mode 100644 index 00000000..199d4a28 --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/AccountFlagsTest.java @@ -0,0 +1,51 @@ +package com.tigerbeetle; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +public class AccountFlagsTest { + + @Test + public void testFlags() { + + assertTrue(AccountFlags.hasLinked(AccountFlags.LINKED)); + assertTrue(AccountFlags + .hasLinked(AccountFlags.LINKED | AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS)); + assertTrue(AccountFlags + .hasLinked(AccountFlags.LINKED | AccountFlags.CREDITS_MUST_NOT_EXCEED_DEBITS)); + + assertTrue(AccountFlags + .hasDebitsMustNotExceedCredits(AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS)); + assertTrue(AccountFlags.hasDebitsMustNotExceedCredits( + AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS | AccountFlags.LINKED)); + assertTrue(AccountFlags + .hasDebitsMustNotExceedCredits(AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS + | AccountFlags.CREDITS_MUST_NOT_EXCEED_DEBITS)); + + assertTrue(AccountFlags + .hasCreditsMustNotExceedDebits(AccountFlags.CREDITS_MUST_NOT_EXCEED_DEBITS)); + assertTrue(AccountFlags.hasCreditsMustNotExceedDebits( + AccountFlags.CREDITS_MUST_NOT_EXCEED_DEBITS | AccountFlags.LINKED)); + assertTrue(AccountFlags + .hasCreditsMustNotExceedDebits(AccountFlags.CREDITS_MUST_NOT_EXCEED_DEBITS + | AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS)); + + assertFalse(AccountFlags.hasLinked(AccountFlags.NONE)); + assertFalse(AccountFlags.hasLinked(AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS)); + assertFalse(AccountFlags.hasLinked(AccountFlags.CREDITS_MUST_NOT_EXCEED_DEBITS + | AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS)); + + assertFalse(AccountFlags.hasDebitsMustNotExceedCredits(AccountFlags.NONE)); + assertFalse(AccountFlags.hasDebitsMustNotExceedCredits(AccountFlags.LINKED)); + assertFalse(AccountFlags.hasDebitsMustNotExceedCredits( + AccountFlags.LINKED | AccountFlags.CREDITS_MUST_NOT_EXCEED_DEBITS)); + + assertFalse(AccountFlags.hasCreditsMustNotExceedDebits(AccountFlags.NONE)); + assertFalse(AccountFlags.hasCreditsMustNotExceedDebits(AccountFlags.LINKED)); + assertFalse(AccountFlags.hasCreditsMustNotExceedDebits( + AccountFlags.LINKED | AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS)); + + } + +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/AccountTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/AccountTest.java new file mode 100644 index 00000000..64bdba2e --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/AccountTest.java @@ -0,0 +1,344 @@ +package com.tigerbeetle; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.math.BigInteger; + +import org.junit.Test; + +public class AccountTest { + + @Test + public void testDefaultValues() { + var accounts = new AccountBatch(1); + accounts.add(); + + assertEquals(0L, accounts.getId(UInt128.LeastSignificant)); + assertEquals(0L, accounts.getId(UInt128.MostSignificant)); + assertEquals(BigInteger.ZERO, accounts.getDebitsPosted()); + assertEquals(BigInteger.ZERO, accounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, accounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, accounts.getCreditsPending()); + assertEquals(0L, accounts.getUserData128(UInt128.LeastSignificant)); + assertEquals(0L, accounts.getUserData128(UInt128.MostSignificant)); + assertEquals(0L, accounts.getUserData64()); + assertEquals(0, accounts.getUserData32()); + assertEquals(0, accounts.getLedger()); + assertEquals(AccountFlags.NONE, accounts.getFlags()); + assertEquals(0L, accounts.getTimestamp()); + } + + @Test + public void testId() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setId(100, 200); + assertEquals(100L, accounts.getId(UInt128.LeastSignificant)); + assertEquals(200L, accounts.getId(UInt128.MostSignificant)); + } + + @Test + public void testIdLong() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setId(100); + assertEquals(100L, accounts.getId(UInt128.LeastSignificant)); + assertEquals(0L, accounts.getId(UInt128.MostSignificant)); + } + + @Test + public void testIdAsBytes() { + var accounts = new AccountBatch(1); + accounts.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}; + accounts.setId(id); + assertArrayEquals(id, accounts.getId()); + } + + public void testIdNull() { + byte[] id = null; + var accounts = new AccountBatch(1); + + accounts.add(); + accounts.setId(id); + + assertArrayEquals(new byte[16], accounts.getId()); + } + + @Test(expected = IllegalArgumentException.class) + public void testIdInvalid() { + var accounts = new AccountBatch(1); + accounts.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + accounts.setId(id); + fail(); + } + + @Test + public void testCreditsPending() { + var accounts = new AccountBatch(1); + accounts.add(); + + final var value = new BigInteger("123456789012345678901234567890"); + accounts.setCreditsPending(value); + assertEquals(value, accounts.getCreditsPending()); + } + + + @Test + public void testCreditsPendingLong() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setCreditsPending(999); + assertEquals(BigInteger.valueOf(999), accounts.getCreditsPending()); + + accounts.setCreditsPending(999, 1); + assertEquals(UInt128.asBigInteger(999, 1), accounts.getCreditsPending()); + assertEquals(999L, accounts.getCreditsPending(UInt128.LeastSignificant)); + assertEquals(1L, accounts.getCreditsPending(UInt128.MostSignificant)); + } + + @Test + public void testCreditsPosted() { + var accounts = new AccountBatch(1); + accounts.add(); + + final var value = new BigInteger("123456789012345678901234567890"); + accounts.setCreditsPosted(value); + assertEquals(value, accounts.getCreditsPosted()); + } + + @Test + public void testCreditsPostedLong() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setCreditsPosted(999); + assertEquals(BigInteger.valueOf(999), accounts.getCreditsPosted()); + + accounts.setCreditsPosted(999, 1); + assertEquals(UInt128.asBigInteger(999, 1), accounts.getCreditsPosted()); + assertEquals(999L, accounts.getCreditsPosted(UInt128.LeastSignificant)); + assertEquals(1L, accounts.getCreditsPosted(UInt128.MostSignificant)); + } + + @Test + public void testDebitsPosted() { + var accounts = new AccountBatch(1); + accounts.add(); + + final var value = new BigInteger("123456789012345678901234567890"); + accounts.setDebitsPosted(value); + assertEquals(value, accounts.getDebitsPosted()); + } + + @Test + public void testDebitsPostedLong() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setDebitsPosted(999); + assertEquals(BigInteger.valueOf(999), accounts.getDebitsPosted()); + + accounts.setDebitsPosted(999, 1); + assertEquals(UInt128.asBigInteger(999, 1), accounts.getDebitsPosted()); + assertEquals(999L, accounts.getDebitsPosted(UInt128.LeastSignificant)); + assertEquals(1L, accounts.getDebitsPosted(UInt128.MostSignificant)); + } + + @Test + public void testDebitsPending() { + var accounts = new AccountBatch(1); + accounts.add(); + + final var value = new BigInteger("123456789012345678901234567890"); + accounts.setDebitsPending(value); + assertEquals(value, accounts.getDebitsPending()); + } + + @Test + public void testDebitsPendingLong() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setDebitsPending(999); + assertEquals(BigInteger.valueOf(999), accounts.getDebitsPending()); + + accounts.setDebitsPending(999, 1); + assertEquals(UInt128.asBigInteger(999, 1), accounts.getDebitsPending()); + assertEquals(999L, accounts.getDebitsPending(UInt128.LeastSignificant)); + assertEquals(1L, accounts.getDebitsPending(UInt128.MostSignificant)); + } + + @Test + public void testUserData128Long() { + var accounts = new AccountBatch(2); + accounts.add(); + + accounts.setUserData128(100); + assertEquals(100L, accounts.getUserData128(UInt128.LeastSignificant)); + assertEquals(0L, accounts.getUserData128(UInt128.MostSignificant)); + } + + @Test + public void testUserData128() { + var accounts = new AccountBatch(2); + accounts.add(); + + accounts.setUserData128(100, 200); + assertEquals(100L, accounts.getUserData128(UInt128.LeastSignificant)); + assertEquals(200L, accounts.getUserData128(UInt128.MostSignificant)); + } + + @Test + public void testUserData128AsBytes() { + var accounts = new AccountBatch(1); + accounts.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}; + accounts.setUserData128(id); + assertArrayEquals(id, accounts.getUserData128()); + } + + @Test + public void testUserData128Null() { + var accounts = new AccountBatch(1); + accounts.add(); + + byte[] userData = null; + accounts.setUserData128(userData); + assertEquals(0L, accounts.getUserData128(UInt128.LeastSignificant)); + assertEquals(0L, accounts.getUserData128(UInt128.MostSignificant)); + } + + @Test(expected = IllegalArgumentException.class) + public void testUserData128Invalid() { + var accounts = new AccountBatch(1); + accounts.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + accounts.setUserData128(id); + fail(); + } + + @Test + public void testUserData64() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setUserData64(1000L); + assertEquals(1000L, accounts.getUserData64()); + } + + @Test + public void testUserData32() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setUserData32(100); + assertEquals(100, accounts.getUserData32()); + } + + @Test + public void testLedger() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setLedger(200); + assertEquals(200, accounts.getLedger()); + } + + @Test + public void testCode() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setCode(30); + assertEquals(30, accounts.getCode()); + } + + @Test + public void testCodeUnsignedValue() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setCode(60000); + assertEquals(60000, accounts.getCode()); + } + + @Test(expected = IllegalArgumentException.class) + public void testCodeNegative() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setCode(-1); + } + + @Test(expected = IllegalArgumentException.class) + public void testCodeOverflow() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setCode(Integer.MAX_VALUE); + } + + @Test + public void testReserved() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setReserved(0); + assertEquals(0, accounts.getReserved()); + } + + @Test + public void testFlags() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setFlags(AccountFlags.CREDITS_MUST_NOT_EXCEED_DEBITS | AccountFlags.LINKED); + assertEquals((int) (AccountFlags.CREDITS_MUST_NOT_EXCEED_DEBITS | AccountFlags.LINKED), + accounts.getFlags()); + } + + @Test + public void testFlagsUnsignedValue() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setFlags(60000); + assertEquals(60000, accounts.getFlags()); + } + + @Test(expected = IllegalArgumentException.class) + public void testFlagsNegative() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setFlags(-1); + } + + @Test(expected = IllegalArgumentException.class) + public void testFlagsOverflow() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setFlags(Integer.MAX_VALUE); + } + + @Test + public void testTimestamp() { + var accounts = new AccountBatch(1); + accounts.add(); + + accounts.setTimestamp(1234567890); + assertEquals((long) 1234567890, accounts.getTimestamp()); + } +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/AsyncRequestTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/AsyncRequestTest.java new file mode 100644 index 00000000..b2441e56 --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/AsyncRequestTest.java @@ -0,0 +1,587 @@ + +package com.tigerbeetle; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.Test; +import com.tigerbeetle.Request.Operations; + +public class AsyncRequestTest { + + @Test + public void testCreateAccountsRequestConstructor() { + var client = getDummyClient(); + var batch = new AccountBatch(1); + batch.add(); + + var request = AsyncRequest.createAccounts(client, batch); + assertNotNull(request); + } + + @Test + public void testCreateTransfersRequestConstructor() { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + var request = AsyncRequest.createTransfers(client, batch); + assertNotNull(request); + } + + @Test + public void testLookupAccountsRequestConstructor() { + var client = getDummyClient(); + var batch = new IdBatch(1); + batch.add(); + + var request = AsyncRequest.lookupAccounts(client, batch); + assertNotNull(request); + } + + @Test + public void testLookupTransfersRequestConstructor() { + var client = getDummyClient(); + var batch = new IdBatch(1); + batch.add(); + + var request = AsyncRequest.lookupTransfers(client, batch); + assertNotNull(request); + } + + @Test(expected = NullPointerException.class) + public void testConstructorWithClientNull() { + var batch = new AccountBatch(1); + batch.add(); + + AsyncRequest.createAccounts(null, batch); + fail(); + } + + @Test(expected = NullPointerException.class) + public void testConstructorWithBatchNull() { + var client = getDummyClient(); + AsyncRequest.createAccounts(client, null); + fail(); + } + + @Test + public void testConstructorWithZeroCapacityBatch() { + var client = getDummyClient(); + var batch = new AccountBatch(0); + AsyncRequest.createAccounts(client, batch); + } + + @Test + public void testConstructorWithZeroItemsBatch() { + var client = getDummyClient(); + var batch = new AccountBatch(1); + AsyncRequest.createAccounts(client, batch); + } + + @Test(expected = AssertionError.class) + public void testEndRequestWithInvalidOperation() throws Throwable { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + + var dummyBuffer = ByteBuffer.allocate(CreateTransferResultBatch.Struct.SIZE); + var callback = new CallbackSimulator( + AsyncRequest.createTransfers(client, batch), + Request.Operations.LOOKUP_ACCOUNTS.value, dummyBuffer, PacketStatus.Ok.value, 250); + + CompletableFuture future = callback.request.getFuture(); + callback.start(); + assertFalse(future.isDone()); + + try { + future.get(); + fail(); + } catch (ExecutionException e) { + assertNotNull(e.getCause()); + throw e.getCause(); + } + } + + @Test(expected = AssertionError.class) + public void testEndRequestWithUnknownOperation() throws Throwable { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + final byte UNKNOWN = 99; + + var dummyBuffer = ByteBuffer.allocate(CreateTransferResultBatch.Struct.SIZE); + var callback = + new CallbackSimulator( + new AsyncRequest(client, + Operations.CREATE_TRANSFERS, batch), + UNKNOWN, dummyBuffer, PacketStatus.Ok.value, 250); + + CompletableFuture future = callback.request.getFuture(); + callback.start(); + assertFalse(future.isDone()); + + try { + future.get(); + fail(); + } catch (ExecutionException e) { + assertNotNull(e.getCause()); + throw e.getCause(); + } + } + + @Test + public void testEndRequestWithNullBuffer() throws Throwable { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + var callback = new CallbackSimulator( + AsyncRequest.createTransfers(client, batch), + Request.Operations.CREATE_TRANSFERS.value, null, PacketStatus.Ok.value, 250); + + CompletableFuture future = callback.request.getFuture(); + callback.start(); + assertFalse(future.isDone()); + + var result = future.get(); + assertEquals(0, result.getLength()); + assertNotNull(result.getHeader()); + assertTrue(result.getHeader().getTimestamp() != 0L); + } + + @Test(expected = AssertionError.class) + public void testEndRequestWithInvalidBufferSize() throws Throwable { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + + var invalidBuffer = ByteBuffer.allocate((CreateTransferResultBatch.Struct.SIZE * 2) - 1); + var callback = new CallbackSimulator( + AsyncRequest.createTransfers(client, batch), + Request.Operations.CREATE_TRANSFERS.value, invalidBuffer, PacketStatus.Ok.value, + 250); + + CompletableFuture future = callback.request.getFuture(); + callback.start(); + assertFalse(future.isDone()); + + try { + future.get(); + fail(); + } catch (ExecutionException e) { + assertNotNull(e.getCause()); + throw e.getCause(); + } + } + + @Test + public void testEndRequestWithRequestException() { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + var dummyBuffer = ByteBuffer.allocate(CreateTransferResultBatch.Struct.SIZE); + var callback = new CallbackSimulator( + AsyncRequest.createTransfers(client, batch), + Request.Operations.CREATE_TRANSFERS.value, dummyBuffer, + PacketStatus.TooMuchData.value, 250); + + CompletableFuture future = callback.request.getFuture(); + callback.start(); + assertFalse(future.isDone()); + + try { + future.join(); + fail(); + } catch (CompletionException e) { + assertTrue(e.getCause() instanceof TooMuchDataException); + } + } + + @Test(expected = AssertionError.class) + public void testEndRequestWithAmountOfResultsGreaterThanAmountOfRequests() throws Throwable { + var client = getDummyClient(); + + // A batch with only 1 item + var batch = new AccountBatch(1); + batch.add(); + + // A reply with 2 items, while the batch had only 1 item + var incorrectReply = ByteBuffer.allocate(CreateAccountResultBatch.Struct.SIZE * 2) + .order(ByteOrder.LITTLE_ENDIAN); + + var callback = new CallbackSimulator( + AsyncRequest.createAccounts(client, batch), + Request.Operations.CREATE_ACCOUNTS.value, incorrectReply, PacketStatus.Ok.value, + 250); + + CompletableFuture future = callback.request.getFuture(); + callback.start(); + assertFalse(future.isDone()); + + try { + future.get(); + fail(); + } catch (ExecutionException e) { + assertNotNull(e.getCause()); + throw e.getCause(); + } + } + + @Test + public void testCreateAccountEndRequest() throws ExecutionException, InterruptedException { + var client = getDummyClient(); + var batch = new AccountBatch(2); + batch.add(); + batch.add(); + + // A dummy ByteBuffer simulating some simple reply + var dummyReplyBuffer = ByteBuffer.allocate(CreateAccountResultBatch.Struct.SIZE * 2) + .order(ByteOrder.LITTLE_ENDIAN); + dummyReplyBuffer.putLong(100); + dummyReplyBuffer.putInt(CreateAccountStatus.IdMustNotBeZero.value); + dummyReplyBuffer.putInt(0); + dummyReplyBuffer.putLong(101); + dummyReplyBuffer.putInt(CreateAccountStatus.Exists.value); + dummyReplyBuffer.putInt(0); + + var callback = new CallbackSimulator( + AsyncRequest.createAccounts(client, batch), + Request.Operations.CREATE_ACCOUNTS.value, dummyReplyBuffer, PacketStatus.Ok.value, + 250); + + CompletableFuture future = callback.request.getFuture(); + callback.start(); + assertFalse(future.isDone()); + + var result = future.get(); + assertEquals(2, result.getLength()); + assertNotNull(result.getHeader()); + assertTrue(result.getHeader().getTimestamp() != 0L); + + assertTrue(result.next()); + assertEquals(100, result.getTimestamp()); + assertEquals(CreateAccountStatus.IdMustNotBeZero, result.getStatus()); + + assertTrue(result.next()); + assertEquals(101, result.getTimestamp()); + assertEquals(CreateAccountStatus.Exists, result.getStatus()); + } + + @Test + public void testCreateTransferEndRequest() throws InterruptedException, ExecutionException { + var client = getDummyClient(); + var batch = new TransferBatch(2); + batch.add(); + batch.add(); + + // A dummy ByteBuffer simulating some simple reply + var dummyReplyBuffer = ByteBuffer.allocate(CreateTransferResultBatch.Struct.SIZE * 2) + .order(ByteOrder.LITTLE_ENDIAN); + dummyReplyBuffer.putLong(100); + dummyReplyBuffer.putInt(CreateTransferStatus.IdMustNotBeZero.value); + dummyReplyBuffer.putInt(0); + dummyReplyBuffer.putLong(101); + dummyReplyBuffer.putInt(CreateTransferStatus.Exists.value); + dummyReplyBuffer.putInt(0); + + var callback = new CallbackSimulator( + AsyncRequest.createTransfers(client, batch), + Request.Operations.CREATE_TRANSFERS.value, dummyReplyBuffer, PacketStatus.Ok.value, + 250); + + CompletableFuture future = callback.request.getFuture(); + callback.start(); + assertFalse(future.isDone()); + + var result = future.get(); + assertEquals(2, result.getLength()); + assertNotNull(result.getHeader()); + assertTrue(result.getHeader().getTimestamp() != 0L); + + assertTrue(result.next()); + assertEquals(100, result.getTimestamp()); + assertEquals(CreateTransferStatus.IdMustNotBeZero, result.getStatus()); + + assertTrue(result.next()); + assertEquals(101, result.getTimestamp()); + assertEquals(CreateTransferStatus.Exists, result.getStatus()); + } + + @Test + public void testLookupAccountEndRequest() throws InterruptedException, ExecutionException { + var client = getDummyClient(); + var batch = new IdBatch(2); + batch.add(); + batch.add(); + + // A dummy ByteBuffer simulating some simple reply + var dummyReplyBuffer = + ByteBuffer.allocate(AccountBatch.Struct.SIZE * 2).order(ByteOrder.LITTLE_ENDIAN); + dummyReplyBuffer.putLong(100).putLong(1000); + dummyReplyBuffer.position(AccountBatch.Struct.SIZE).putLong(200).putLong(2000); + + var callback = + new CallbackSimulator(AsyncRequest.lookupAccounts(client, batch), + Request.Operations.LOOKUP_ACCOUNTS.value, dummyReplyBuffer, + PacketStatus.Ok.value, 250); + + CompletableFuture future = callback.request.getFuture(); + callback.start(); + assertFalse(future.isDone()); + + var result = future.get(); + assertEquals(2, result.getLength()); + assertNotNull(result.getHeader()); + assertTrue(result.getHeader().getTimestamp() != 0L); + + assertTrue(result.next()); + assertEquals(100L, result.getId(UInt128.LeastSignificant)); + assertEquals(1000L, result.getId(UInt128.MostSignificant)); + + assertTrue(result.next()); + assertEquals(200L, result.getId(UInt128.LeastSignificant)); + assertEquals(2000L, result.getId(UInt128.MostSignificant)); + } + + @Test + public void testLookupTransferEndRequest() throws InterruptedException, ExecutionException { + var client = getDummyClient(); + var batch = new IdBatch(2); + batch.add(); + batch.add(); + + // A dummy ByteBuffer simulating some simple reply + var dummyReplyBuffer = + ByteBuffer.allocate(TransferBatch.Struct.SIZE * 2).order(ByteOrder.LITTLE_ENDIAN); + dummyReplyBuffer.putLong(100).putLong(1000); + dummyReplyBuffer.position(TransferBatch.Struct.SIZE).putLong(200).putLong(2000); + + var callback = + new CallbackSimulator(AsyncRequest.lookupTransfers(client, batch), + Request.Operations.LOOKUP_TRANSFERS.value, dummyReplyBuffer, + PacketStatus.Ok.value, 250); + + CompletableFuture future = callback.request.getFuture(); + callback.start(); + assertFalse(future.isDone()); + + var result = future.get(); + assertEquals(2, result.getLength()); + assertNotNull(result.getHeader()); + assertTrue(result.getHeader().getTimestamp() != 0L); + + assertTrue(result.next()); + assertEquals(100L, result.getId(UInt128.LeastSignificant)); + assertEquals(1000L, result.getId(UInt128.MostSignificant)); + + assertTrue(result.next()); + assertEquals(200L, result.getId(UInt128.LeastSignificant)); + assertEquals(2000L, result.getId(UInt128.MostSignificant)); + } + + @Test + public void testSuccessFuture() throws InterruptedException, ExecutionException { + var client = getDummyClient(); + var batch = new IdBatch(2); + batch.add(); + batch.add(); + + // A dummy ByteBuffer simulating some simple reply + var dummyReplyBuffer = + ByteBuffer.allocate(TransferBatch.Struct.SIZE * 2).order(ByteOrder.LITTLE_ENDIAN); + dummyReplyBuffer.putLong(100).putLong(1000); + dummyReplyBuffer.position(TransferBatch.Struct.SIZE).putLong(200).putLong(2000); + + var callback = + new CallbackSimulator(AsyncRequest.lookupTransfers(client, batch), + Request.Operations.LOOKUP_TRANSFERS.value, dummyReplyBuffer, + PacketStatus.Ok.value, 5000); + + Future future = callback.request.getFuture(); + callback.start(); + + try { + // Our goal is just to test the future timeout. + // The timeout is much smaller than the delay, + // to avoid flaky results due to thread scheduling. + future.get(5, TimeUnit.MILLISECONDS); + fail(); + + } catch (TimeoutException timeout) { + assertTrue(true); + } + + // Wait for completion + var result = future.get(); + assertEquals(2, result.getLength()); + assertNotNull(result.getHeader()); + assertTrue(result.getHeader().getTimestamp() != 0L); + + assertTrue(result.next()); + assertEquals(100L, result.getId(UInt128.LeastSignificant)); + assertEquals(1000L, result.getId(UInt128.MostSignificant)); + + assertTrue(result.next()); + assertEquals(200L, result.getId(UInt128.LeastSignificant)); + assertEquals(2000L, result.getId(UInt128.MostSignificant)); + } + + @Test + public void testSuccessFutureWithTimeout() throws InterruptedException, ExecutionException { + var client = getDummyClient(); + var batch = new IdBatch(2); + batch.add(); + batch.add(); + + // A dummy ByteBuffer simulating some simple reply + var dummyReplyBuffer = + ByteBuffer.allocate(TransferBatch.Struct.SIZE * 2).order(ByteOrder.LITTLE_ENDIAN); + dummyReplyBuffer.putLong(100).putLong(1000); + dummyReplyBuffer.position(TransferBatch.Struct.SIZE).putLong(200).putLong(2000); + + var callback = + new CallbackSimulator(AsyncRequest.lookupTransfers(client, batch), + Request.Operations.LOOKUP_TRANSFERS.value, dummyReplyBuffer, + PacketStatus.Ok.value, 5); + + Future future = callback.request.getFuture(); + callback.start(); + + try { + + // Our goal is just to test the future completion. + // The timeout is much bigger than the delay, + // to avoid flaky results due to thread scheduling. + var result = future.get(5000, TimeUnit.MILLISECONDS); + assertEquals(2, result.getLength()); + assertNotNull(result.getHeader()); + assertTrue(result.getHeader().getTimestamp() != 0L); + + assertTrue(result.next()); + assertEquals(100L, result.getId(UInt128.LeastSignificant)); + assertEquals(1000L, result.getId(UInt128.MostSignificant)); + + assertTrue(result.next()); + assertEquals(200L, result.getId(UInt128.LeastSignificant)); + assertEquals(2000L, result.getId(UInt128.MostSignificant)); + + } catch (TimeoutException timeout) { + fail(); + } + } + + @Test + public void testFailedFuture() throws InterruptedException { + var client = getDummyClient(); + var batch = new IdBatch(1); + batch.add(); + + var callback = + new CallbackSimulator(AsyncRequest.lookupTransfers(client, batch), + Request.Operations.LOOKUP_TRANSFERS.value, null, + PacketStatus.TooMuchData.value, 250); + + Future future = callback.request.getFuture(); + callback.start(); + + try { + future.get(); + fail(); + } catch (ExecutionException exception) { + assertTrue(exception.getCause() instanceof TooMuchDataException); + } + } + + @Test + public void testFailedFutureWithTimeout() throws InterruptedException, TimeoutException { + var client = getDummyClient(); + var batch = new IdBatch(1); + batch.add(); + + var callback = + new CallbackSimulator(AsyncRequest.lookupAccounts(client, batch), + Request.Operations.LOOKUP_ACCOUNTS.value, null, + PacketStatus.ClientEvicted.value, 100); + + Future future = callback.request.getFuture(); + callback.start(); + + try { + future.get(1000, TimeUnit.MILLISECONDS); + fail(); + } catch (ExecutionException exception) { + assertTrue(exception.getCause() instanceof ClientEvictedException); + } + } + + @Test(expected = IllegalStateException.class) + public void testFailedFutureCompletedTwice() { + var client = getDummyClient(); + var batch = new IdBatch(1); + batch.add(); + + var request = AsyncRequest.lookupTransfers(client, batch); + try { + // First completion is OK, registering the exception in the CompletableFuture. + request.setException(new Exception()); + } catch (Throwable any) { + // No exception is expected in the first call. + fail(); + } + // Second time throws an exception, because it can only be completed once. + request.setException(new Exception()); + fail(); + } + + private static NativeClient getDummyClient() { + return NativeClient.initEcho(UInt128.asBytes(0), "3000"); + } + + private class CallbackSimulator extends Thread { + + public final AsyncRequest request; + private final byte receivedOperation; + private final ByteBuffer buffer; + private final byte status; + private final int delay; + + + private CallbackSimulator(AsyncRequest request, byte receivedOperation, + ByteBuffer buffer, byte status, int delay) { + this.request = request; + this.receivedOperation = receivedOperation; + this.buffer = buffer; + this.status = status; + this.delay = delay; + } + + @Override + public synchronized void run() { + try { + Thread.sleep(delay); + if (buffer != null) { + request.setReplyBuffer(buffer.array()); + } + request.endRequest(receivedOperation, status, System.nanoTime()); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/BatchTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/BatchTest.java new file mode 100644 index 00000000..ecac26ff --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/BatchTest.java @@ -0,0 +1,1017 @@ +package com.tigerbeetle; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.math.BigInteger; + +import org.junit.Test; + +/** + * Asserts the memory interpretation from/to a binary stream. + */ +public class BatchTest { + + private static final DummyAccountDto account1; + private static final DummyAccountDto account2; + private static final ByteBuffer dummyAccountsStream; + + private static final DummyTransferDto transfer1; + private static final DummyTransferDto transfer2; + private static final ByteBuffer dummyTransfersStream; + + private static final long createAccountTimestamp1; + private static final long createAccountTimestamp2; + private static final CreateAccountStatus createAccountResult1; + private static final CreateAccountStatus createAccountResult2; + private static final ByteBuffer dummyCreateAccountStatusStream; + + private static final long createTransferTimestamp1; + private static final long createTransferTimestamp2; + private static final CreateTransferStatus createTransferResult1; + private static final CreateTransferStatus createTransferResult2; + private static final ByteBuffer dummyCreateTransferResultsStream; + + + private static final byte[] id1; + private static final long id1LeastSignificant; + private static final long id1MostSignificant; + private static final byte[] id2; + private static final long id2LeastSignificant; + private static final long id2MostSignificant; + private static final ByteBuffer dummyIdsStream; + + @Test(expected = IllegalArgumentException.class) + public void testConstructorWithNegativeCapacity() { + new AccountBatch(-1); + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructorOverflows() { + new AccountBatch(Integer.MAX_VALUE); + } + + @Test(expected = NullPointerException.class) + public void testConstructorWithNullBuffer() { + ByteBuffer buffer = null; + new TransferBatch(buffer); + } + + @Test(expected = IndexOutOfBoundsException.class) + public void testPositionIndexOutOfBounds() { + + var batch = new AccountBatch(1); + batch.setPosition(1); + fail(); // Should be unreachable + } + + @Test(expected = IndexOutOfBoundsException.class) + public void testPositionIndexNegative() { + + var batch = new CreateTransferResultBatch(1); + batch.setPosition(-1); + fail(); // Should be unreachable + } + + @Test + public void testNextFromCapacity() { + var batch = new AccountBatch(2); + + // Creating from capacity + // Expected position = -1 and length = 0 + assertEquals(-1, batch.getPosition()); + assertEquals(0, batch.getLength()); + assertEquals(2, batch.getCapacity()); + assertEquals(0 * AccountBatch.Struct.SIZE, batch.getBufferLen()); + + assertFalse(batch.isValidPosition()); + + // Zero elements, next must return false + assertFalse(batch.next()); + assertFalse(batch.isValidPosition()); + + // Calling next multiple times on an EMPTY batch is allowed since the cursor does not move. + // This allows reusing a single instance of an empty batch, + // avoiding allocations for the common case (empty batch == success). + batch.next(); + batch.next(); + assertFalse(batch.isValidPosition()); + + // Adding 2 elements + + batch.add(); + assertTrue(batch.isValidPosition()); + assertEquals(0, batch.getPosition()); + assertEquals(1, batch.getLength()); + assertEquals(1 * AccountBatch.Struct.SIZE, batch.getBufferLen()); + + batch.add(); + assertTrue(batch.isValidPosition()); + assertEquals(1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2 * AccountBatch.Struct.SIZE, batch.getBufferLen()); + + // reset to the beginning, + // Expected position -1 + batch.beforeFirst(); + assertFalse(batch.isValidPosition()); + assertEquals(-1, batch.getPosition()); + assertEquals(2, batch.getLength()); + + // Moving + assertTrue(batch.next()); + assertTrue(batch.isValidPosition()); + assertEquals(0, batch.getPosition()); + + assertTrue(batch.next()); + assertEquals(1, batch.getPosition()); + + // End of the batch + assertFalse(batch.next()); + assertFalse(batch.isValidPosition()); + + // Calling next multiple times must throw an exception, preventing the user to assume that + // an iterated batch is an empty one. + try { + batch.next(); + fail(); + } catch (IndexOutOfBoundsException exception) { + assertTrue(true); + } + assertFalse(batch.isValidPosition()); + } + + @Test + public void testNextFromBuffer() { + var batch = new AccountBatch(dummyAccountsStream.position(0)); + + // Creating from a existing buffer + // Expected position = -1 and length = 2 + assertEquals(-1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + assertEquals(dummyAccountsStream.capacity(), batch.getBufferLen()); + assertFalse(batch.isValidPosition()); + + // Moving + assertTrue(batch.next()); + assertTrue(batch.isValidPosition()); + assertEquals(0, batch.getPosition()); + + assertTrue(batch.next()); + assertTrue(batch.isValidPosition()); + assertEquals(1, batch.getPosition()); + + // End of the batch + assertFalse(batch.next()); + assertFalse(batch.isValidPosition()); + + // Calling next multiple times must throw an exception, preventing the user to assume that + // an iterated batch is an empty one. + try { + batch.next(); + fail(); + } catch (IndexOutOfBoundsException exception) { + assertTrue(true); + } + assertFalse(batch.isValidPosition()); + } + + @Test + public void testNextEmptyBatch() { + var batch = new TransferBatch(Request.REPLY_EMPTY); + + // Empty batch + // Expected position = -1 and length = 0 + assertEquals(-1, batch.getPosition()); + assertEquals(0, batch.getLength()); + assertEquals(0, batch.getCapacity()); + assertEquals(0, batch.getBufferLen()); + assertFalse(batch.isValidPosition()); + + // Before the first element + assertFalse(batch.next()); + assertFalse(batch.isValidPosition()); + + // Resting an empty batch + batch.beforeFirst(); + + // Still, before the first element + assertFalse(batch.next()); + assertFalse(batch.isValidPosition()); + + // Calling next multiple times on an EMPTY batch is allowed since the cursor does not move. + // This allows reusing a single instance of an empty batch, + // avoiding allocations for the common case (empty batch == success). + batch.next(); + batch.next(); + assertFalse(batch.isValidPosition()); + } + + @Test + public void testAdd() { + var batch = new AccountBatch(2); + assertEquals(-1, batch.getPosition()); + assertEquals(0, batch.getLength()); + assertEquals(2, batch.getCapacity()); + assertEquals(0 * AccountBatch.Struct.SIZE, batch.getBufferLen()); + + batch.add(); + + assertEquals(0, batch.getPosition()); + assertEquals(1, batch.getLength()); + assertEquals(2, batch.getCapacity()); + assertEquals(1 * AccountBatch.Struct.SIZE, batch.getBufferLen()); + + batch.add(); + + assertEquals(1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + assertEquals(2 * AccountBatch.Struct.SIZE, batch.getBufferLen()); + } + + @Test(expected = IllegalStateException.class) + public void testAddReadOnly() { + var batch = new AccountBatch(dummyAccountsStream.asReadOnlyBuffer().position(0)); + assertEquals(-1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + assertEquals(2 * AccountBatch.Struct.SIZE, batch.getBufferLen()); + + batch.add(); + } + + @Test(expected = IllegalStateException.class) + public void testReadInvalidPosition() { + + AccountBatch batch = new AccountBatch(dummyAccountsStream.position(0)); + assertEquals(-1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + assertFalse(batch.isValidPosition()); + batch.getLedger(); + } + + @Test(expected = IllegalStateException.class) + public void testWriteInvalidPosition() { + + AccountBatch batch = new AccountBatch(2); + assertEquals(-1, batch.getPosition()); + assertEquals(0, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + assertFalse(batch.isValidPosition()); + batch.setCode(100); + } + + @Test(expected = IndexOutOfBoundsException.class) + public void testAddExceedCapacity() { + var batch = new AccountBatch(1); + assertEquals(-1, batch.getPosition()); + assertEquals(0, batch.getLength()); + assertEquals(1, batch.getCapacity()); + assertEquals(0 * AccountBatch.Struct.SIZE, batch.getBufferLen()); + + batch.add(); + + assertEquals(0, batch.getPosition()); + assertEquals(1, batch.getLength()); + assertEquals(1 * AccountBatch.Struct.SIZE, batch.getBufferLen()); + + batch.add(); + } + + @Test + public void testReadAccounts() { + + AccountBatch batch = new AccountBatch(dummyAccountsStream.position(0)); + assertEquals(-1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + assertTrue(batch.next()); + assertAccounts(account1, batch); + + assertTrue(batch.next()); + assertAccounts(account2, batch); + + assertFalse(batch.next()); + } + + @Test + public void testWriteAccounts() { + + AccountBatch batch = new AccountBatch(2); + assertEquals(-1, batch.getPosition()); + assertEquals(0, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + batch.add(); + assertEquals(0, batch.getPosition()); + assertEquals(1, batch.getLength()); + setAccount(batch, account1); + + batch.add(); + assertEquals(1, batch.getPosition()); + assertEquals(2, batch.getLength()); + setAccount(batch, account2); + + batch.beforeFirst(); + + assertTrue(batch.next()); + assertAccounts(account1, batch); + + assertTrue(batch.next()); + assertAccounts(account2, batch); + + assertFalse(batch.next()); + + assertBuffer(dummyAccountsStream, batch.getBuffer()); + } + + @Test + public void testMoveAndSetAccounts() { + + AccountBatch batch = new AccountBatch(2); + assertEquals(-1, batch.getPosition()); + assertEquals(0, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + // Set index 0 + batch.add(); + assertEquals(0, batch.getPosition()); + assertEquals(1, batch.getLength()); + assertEquals(2, batch.getCapacity()); + setAccount(batch, account1); + assertAccounts(account1, batch); + + // Set index 1 with account1 again + batch.add(); + assertEquals(1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + setAccount(batch, account1); + assertAccounts(account1, batch); + + // Replace index 0 with account 2 + batch.setPosition(0); + assertEquals(0, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + setAccount(batch, account2); + + batch.beforeFirst(); + + assertTrue(batch.next()); + assertAccounts(account2, batch); + + assertTrue(batch.next()); + assertAccounts(account1, batch); + + assertFalse(batch.next()); + } + + @Test(expected = AssertionError.class) + public void testInvalidAccountBuffer() { + + // Invalid size + var invalidBuffer = ByteBuffer.allocate((AccountBatch.Struct.SIZE * 2) - 1) + .order(ByteOrder.LITTLE_ENDIAN); + + @SuppressWarnings("unused") + var batch = new AccountBatch(invalidBuffer); + fail(); + } + + @Test + public void testReadTransfers() { + + var batch = new TransferBatch(dummyTransfersStream.position(0)); + assertEquals(-1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + assertTrue(batch.next()); + assertTransfers(transfer1, batch); + + assertTrue(batch.next()); + assertTransfers(transfer2, batch); + + assertFalse(batch.next()); + } + + @Test + public void testWriteTransfers() { + + var batch = new TransferBatch(2); + assertEquals(-1, batch.getPosition()); + assertEquals(0, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + batch.add(); + assertEquals(0, batch.getPosition()); + assertEquals(1, batch.getLength()); + setTransfer(batch, transfer1); + + batch.add(); + assertEquals(1, batch.getPosition()); + assertEquals(2, batch.getLength()); + setTransfer(batch, transfer2); + + batch.beforeFirst(); + + assertTrue(batch.next()); + assertTransfers(transfer1, batch); + + assertTrue(batch.next()); + assertTransfers(transfer2, batch); + + assertFalse(batch.next()); + + assertBuffer(dummyTransfersStream, batch.getBuffer()); + } + + @Test + public void testMoveAndSetTransfers() { + + var batch = new TransferBatch(2); + assertEquals(-1, batch.getPosition()); + assertEquals(0, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + // Set index 0 + batch.add(); + assertEquals(0, batch.getPosition()); + assertEquals(1, batch.getLength()); + assertEquals(2, batch.getCapacity()); + setTransfer(batch, transfer1); + assertTransfers(transfer1, batch); + + // Set index 1 with transfer1 again + batch.add(); + assertEquals(1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + setTransfer(batch, transfer1); + assertTransfers(transfer1, batch); + + // Replace index 0 with account 2 + batch.setPosition(0); + assertEquals(0, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + setTransfer(batch, transfer2); + + batch.beforeFirst(); + + assertTrue(batch.next()); + assertTransfers(transfer2, batch); + + assertTrue(batch.next()); + assertTransfers(transfer1, batch); + + assertFalse(batch.next()); + } + + @Test(expected = AssertionError.class) + public void testInvalidTransfersBuffer() { + + // Invalid size + var invalidBuffer = ByteBuffer.allocate((TransferBatch.Struct.SIZE * 2) - 1) + .order(ByteOrder.LITTLE_ENDIAN); + + @SuppressWarnings("unused") + var batch = new TransferBatch(invalidBuffer); + fail(); + } + + @Test + public void testReadCreateAccountStatus() { + + var batch = new CreateAccountResultBatch(dummyCreateAccountStatusStream.position(0)); + assertEquals(-1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + assertTrue(batch.next()); + assertEquals(createAccountTimestamp1, batch.getTimestamp()); + assertEquals(createAccountResult1, batch.getStatus()); + assertEquals(0L, batch.getReserved()); + + assertTrue(batch.next()); + assertEquals(createAccountTimestamp2, batch.getTimestamp()); + assertEquals(createAccountResult2, batch.getStatus()); + assertEquals(0L, batch.getReserved()); + + assertFalse(batch.next()); + } + + @Test + public void testWriteCreateAccountStatus() { + var batch = new CreateAccountResultBatch(1); + batch.add(); + + batch.setTimestamp(createAccountTimestamp1); + assertEquals(createAccountTimestamp1, batch.getTimestamp()); + + batch.setStatus(createAccountResult1); + assertEquals(createAccountResult1, batch.getStatus()); + + batch.setReserved(100); + assertEquals(100, batch.getReserved()); + } + + @Test(expected = AssertionError.class) + public void testInvalidCreateAccountStatusBuffer() { + + // Invalid size + var invalidBuffer = ByteBuffer.allocate((CreateAccountResultBatch.Struct.SIZE * 2) - 1) + .order(ByteOrder.LITTLE_ENDIAN); + + @SuppressWarnings("unused") + var batch = new CreateAccountResultBatch(invalidBuffer); + fail(); + } + + @Test + public void testReadCreateTransferStatus() { + + var batch = new CreateTransferResultBatch(dummyCreateTransferResultsStream.position(0)); + assertEquals(-1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + assertTrue(batch.next()); + assertEquals(createTransferTimestamp1, batch.getTimestamp()); + assertEquals(createTransferResult1, batch.getStatus()); + assertEquals(0L, batch.getReserved()); + + assertTrue(batch.next()); + assertEquals(createTransferTimestamp2, batch.getTimestamp()); + assertEquals(createTransferResult2, batch.getStatus()); + assertEquals(0L, batch.getReserved()); + + assertFalse(batch.next()); + } + + @Test + public void testWriteCreateTransferStatus() { + var batch = new CreateTransferResultBatch(1); + batch.add(); + + batch.setTimestamp(createTransferTimestamp1); + assertEquals(createTransferTimestamp1, batch.getTimestamp()); + + batch.setStatus(createTransferResult1); + assertEquals(createTransferResult1, batch.getStatus()); + + batch.setReserved(100); + assertEquals(100, batch.getReserved()); + } + + @Test(expected = AssertionError.class) + public void testInvalidTransferAccountResultsBuffer() { + + // Invalid size + var invalidBuffer = ByteBuffer.allocate((CreateTransferResultBatch.Struct.SIZE * 2) - 1) + .order(ByteOrder.LITTLE_ENDIAN); + + @SuppressWarnings("unused") + var batch = new CreateTransferResultBatch(invalidBuffer); + fail(); + } + + @Test + public void testReadIds() { + + var batch = new IdBatch(dummyIdsStream.position(0)); + assertEquals(-1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + assertTrue(batch.next()); + assertEquals(id1LeastSignificant, batch.getId(UInt128.LeastSignificant)); + assertEquals(id1MostSignificant, batch.getId(UInt128.MostSignificant)); + assertArrayEquals(id1, batch.getId()); + + assertTrue(batch.next()); + assertEquals(id2LeastSignificant, batch.getId(UInt128.LeastSignificant)); + assertEquals(id2MostSignificant, batch.getId(UInt128.MostSignificant)); + assertArrayEquals(id2, batch.getId()); + + assertFalse(batch.next()); + } + + @Test + public void testWriteIds() { + + var batch = new IdBatch(2); + assertEquals(-1, batch.getPosition()); + assertEquals(0, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + batch.add(id1LeastSignificant, id1MostSignificant); + assertEquals(0, batch.getPosition()); + assertEquals(1, batch.getLength()); + + batch.add(id2LeastSignificant, id2MostSignificant); + assertEquals(1, batch.getPosition()); + assertEquals(2, batch.getLength()); + + batch.beforeFirst(); + + assertTrue(batch.next()); + assertEquals(id1LeastSignificant, batch.getId(UInt128.LeastSignificant)); + assertEquals(id1MostSignificant, batch.getId(UInt128.MostSignificant)); + assertArrayEquals(id1, batch.getId()); + + assertTrue(batch.next()); + assertEquals(id2LeastSignificant, batch.getId(UInt128.LeastSignificant)); + assertEquals(id2MostSignificant, batch.getId(UInt128.MostSignificant)); + assertArrayEquals(id2, batch.getId()); + + assertFalse(batch.next()); + + assertBuffer(dummyIdsStream, batch.getBuffer()); + } + + @Test + public void testMoveAndSetIds() { + + var batch = new IdBatch(2); + assertEquals(-1, batch.getPosition()); + assertEquals(0, batch.getLength()); + assertEquals(2, batch.getCapacity()); + + // Set index 0 + batch.add(id1); + assertEquals(0, batch.getPosition()); + assertEquals(1, batch.getLength()); + assertEquals(2, batch.getCapacity()); + assertEquals(id1LeastSignificant, batch.getId(UInt128.LeastSignificant)); + assertEquals(id1MostSignificant, batch.getId(UInt128.MostSignificant)); + assertArrayEquals(id1, batch.getId()); + + // Set index 1 with id1 again + batch.add(id1); + assertEquals(1, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + assertEquals(id1LeastSignificant, batch.getId(UInt128.LeastSignificant)); + assertEquals(id1MostSignificant, batch.getId(UInt128.MostSignificant)); + assertArrayEquals(id1, batch.getId()); + + // Replace index 0 with id2 + batch.setPosition(0); + assertEquals(0, batch.getPosition()); + assertEquals(2, batch.getLength()); + assertEquals(2, batch.getCapacity()); + batch.setId(id2); + + batch.beforeFirst(); + + assertTrue(batch.next()); + assertArrayEquals(id2, batch.getId()); + + assertTrue(batch.next()); + assertArrayEquals(id1, batch.getId()); + + assertFalse(batch.next()); + } + + @Test(expected = AssertionError.class) + public void testInvalidIdsBuffer() { + + // Invalid size + var invalidBuffer = + ByteBuffer.allocate((UInt128.SIZE * 2) - 1).order(ByteOrder.LITTLE_ENDIAN); + + @SuppressWarnings("unused") + var batch = new IdBatch(invalidBuffer); + fail(); + } + + @Test(expected = NullPointerException.class) + public void testNullIds() { + + var batch = new IdBatch(1); + batch.add(); + batch.setId(null); + fail(); + } + + @Test + public void testLongIds() { + var batch = new IdBatch(1); + batch.add(100L); + assertEquals(100L, batch.getId(UInt128.LeastSignificant)); + assertEquals(0L, batch.getId(UInt128.MostSignificant)); + } + + private static void setAccount(AccountBatch batch, DummyAccountDto account) { + batch.setId(account.idLeastSignificant, account.idMostSignificant); + batch.setDebitsPending(account.debitsPending); + batch.setDebitsPosted(account.debitsPosted); + batch.setCreditsPending(account.creditsPending); + batch.setCreditsPosted(account.creditsPosted); + batch.setUserData128(account.userData128LeastSignificant, + account.userData128MostSignificant); + batch.setUserData64(account.userData64); + batch.setUserData32(account.userData32); + batch.setLedger(account.ledger); + batch.setCode(account.code); + batch.setFlags(account.flags); + batch.setTimestamp(account.timestamp); + } + + private static void assertAccounts(DummyAccountDto account, AccountBatch batch) { + assertEquals(account.idLeastSignificant, batch.getId(UInt128.LeastSignificant)); + assertEquals(account.idMostSignificant, batch.getId(UInt128.MostSignificant)); + assertEquals(account.debitsPending, batch.getDebitsPending()); + assertEquals(account.debitsPosted, batch.getDebitsPosted()); + assertEquals(account.creditsPending, batch.getCreditsPending()); + assertEquals(account.creditsPosted, batch.getCreditsPosted()); + assertEquals(account.userData128LeastSignificant, + batch.getUserData128(UInt128.LeastSignificant)); + assertEquals(account.userData128MostSignificant, + batch.getUserData128(UInt128.MostSignificant)); + assertEquals(account.userData64, batch.getUserData64()); + assertEquals(account.userData32, batch.getUserData32()); + assertEquals(account.ledger, batch.getLedger()); + assertEquals(account.code, (short) batch.getCode()); + assertEquals(account.flags, (short) batch.getFlags()); + assertEquals(account.timestamp, batch.getTimestamp()); + } + + private static void assertTransfers(DummyTransferDto transfer, TransferBatch batch) { + assertEquals(transfer.idLeastSignificant, batch.getId(UInt128.LeastSignificant)); + assertEquals(transfer.idMostSignificant, batch.getId(UInt128.MostSignificant)); + assertEquals(transfer.creditAccountIdLeastSignificant, + batch.getCreditAccountId(UInt128.LeastSignificant)); + assertEquals(transfer.creditAccountIdMostSignificant, + batch.getCreditAccountId(UInt128.MostSignificant)); + assertEquals(transfer.debitAccountIdLeastSignificant, + batch.getDebitAccountId(UInt128.LeastSignificant)); + assertEquals(transfer.debitAccountIdMostSignificant, + batch.getDebitAccountId(UInt128.MostSignificant)); + assertEquals(transfer.amount, batch.getAmount()); + assertEquals(transfer.pendingIdLeastSignificant, + batch.getPendingId(UInt128.LeastSignificant)); + assertEquals(transfer.pendingIdMostSignificant, + batch.getPendingId(UInt128.MostSignificant)); + assertEquals(transfer.userData128LeastSignificant, + batch.getUserData128(UInt128.LeastSignificant)); + assertEquals(transfer.userData128MostSignificant, + batch.getUserData128(UInt128.MostSignificant)); + assertEquals(transfer.userData64, batch.getUserData64()); + assertEquals(transfer.userData32, batch.getUserData32()); + assertEquals(transfer.ledger, batch.getLedger()); + assertEquals(transfer.code, (short) batch.getCode()); + assertEquals(transfer.flags, (short) batch.getFlags()); + assertEquals(transfer.timeout, batch.getTimeout()); + assertEquals(transfer.timestamp, batch.getTimestamp()); + } + + private static void setTransfer(TransferBatch batch, DummyTransferDto transfer) { + batch.setId(transfer.idLeastSignificant, transfer.idMostSignificant); + batch.setDebitAccountId(transfer.debitAccountIdLeastSignificant, + transfer.debitAccountIdMostSignificant); + batch.setCreditAccountId(transfer.creditAccountIdLeastSignificant, + transfer.creditAccountIdMostSignificant); + batch.setAmount(transfer.amount); + batch.setPendingId(transfer.pendingIdLeastSignificant, transfer.pendingIdMostSignificant); + batch.setUserData128(transfer.userData128LeastSignificant, + transfer.userData128MostSignificant); + batch.setUserData64(transfer.userData64); + batch.setUserData32(transfer.userData32); + batch.setLedger(transfer.ledger); + batch.setCode(transfer.code); + batch.setFlags(transfer.flags); + batch.setTimeout(transfer.timeout); + batch.setTimestamp(transfer.timestamp); + } + + private void assertBuffer(ByteBuffer expected, ByteBuffer actual) { + assertEquals(expected.capacity(), actual.capacity()); + for (int i = 0; i < expected.capacity(); i++) { + assertEquals(expected.get(i), actual.get(i)); + } + } + + private static final class DummyAccountDto { + public long idLeastSignificant; + public long idMostSignificant; + public BigInteger creditsPosted; + public BigInteger creditsPending; + public BigInteger debitsPosted; + public BigInteger debitsPending; + public long userData128LeastSignificant; + public long userData128MostSignificant; + public long userData64; + public int userData32; + public int ledger; + public short code; + public short flags; + public long timestamp; + } + + private static final class DummyTransferDto { + private long idLeastSignificant; + private long idMostSignificant; + private long debitAccountIdLeastSignificant; + private long debitAccountIdMostSignificant; + private long creditAccountIdLeastSignificant; + private long creditAccountIdMostSignificant; + private BigInteger amount; + private long pendingIdLeastSignificant; + private long pendingIdMostSignificant; + private long userData128LeastSignificant; + private long userData128MostSignificant; + private long userData64; + private int userData32; + private int timeout; + private int ledger; + private short code; + private short flags; + private long timestamp; + } + + static { + + account1 = new DummyAccountDto(); + account1.idLeastSignificant = 10; + account1.idMostSignificant = 100; + account1.debitsPending = BigInteger.valueOf(100); + account1.debitsPosted = BigInteger.valueOf(200); + account1.creditsPending = BigInteger.valueOf(300); + account1.creditsPosted = BigInteger.valueOf(400); + account1.userData128LeastSignificant = 1000; + account1.userData128MostSignificant = 1100; + account1.userData64 = 2000; + account1.userData32 = 3000; + account1.ledger = 720; + account1.code = 1; + account1.flags = AccountFlags.LINKED; + account1.timestamp = 999; + + account2 = new DummyAccountDto(); + account2.idLeastSignificant = 20; + account2.idMostSignificant = 200; + account2.debitsPending = BigInteger.valueOf(10); + account2.debitsPosted = BigInteger.valueOf(20); + account2.creditsPending = BigInteger.valueOf(30); + account2.creditsPosted = BigInteger.valueOf(40); + account2.userData128LeastSignificant = 2000; + account2.userData128MostSignificant = 2200; + account2.userData64 = 4000; + account2.userData32 = 5000; + account2.ledger = 730; + account2.code = 2; + account2.flags = AccountFlags.LINKED | AccountFlags.CREDITS_MUST_NOT_EXCEED_DEBITS; + account2.timestamp = 99; + + // Mimic the binary response + dummyAccountsStream = ByteBuffer.allocate(256).order(ByteOrder.LITTLE_ENDIAN); + + // Item 1 + dummyAccountsStream.putLong(10).putLong(100); // Id + dummyAccountsStream.putLong(100).putLong(0); // DebitsPending + dummyAccountsStream.putLong(200).putLong(0); // DebitsPosted + dummyAccountsStream.putLong(300).putLong(0); // CreditPending + dummyAccountsStream.putLong(400).putLong(0); // CreditsPosted + dummyAccountsStream.putLong(1000).putLong(1100); // UserData128 + dummyAccountsStream.putLong(2000); // UserData64 + dummyAccountsStream.putInt(3000); // UserData32 + dummyAccountsStream.putInt(0); // Reserved + dummyAccountsStream.putInt(720); // Ledger + dummyAccountsStream.putShort((short) 1); // Code + dummyAccountsStream.putShort((short) 1); // Flags + dummyAccountsStream.putLong(999); // Timestamp + + // Item 2 + dummyAccountsStream.putLong(20).putLong(200); // Id + dummyAccountsStream.putLong(10).putLong(0); // DebitsPending + dummyAccountsStream.putLong(20).putLong(0);; // DebitsPosted + dummyAccountsStream.putLong(30).putLong(0);; // CreditPending + dummyAccountsStream.putLong(40).putLong(0);; // CreditsPosted + dummyAccountsStream.putLong(2000).putLong(2200); // UserData128 + dummyAccountsStream.putLong(4000); // UserData64 + dummyAccountsStream.putInt(5000); // UserData32 + dummyAccountsStream.putInt(0); // Reserved + dummyAccountsStream.putInt(730); // Ledger + dummyAccountsStream.putShort((short) 2); // Code + dummyAccountsStream.putShort((short) 5); // Flags + dummyAccountsStream.putLong(99); // Timestamp + + transfer1 = new DummyTransferDto(); + transfer1.idLeastSignificant = 5000; + transfer1.idMostSignificant = 500; + transfer1.debitAccountIdLeastSignificant = 1000; + transfer1.debitAccountIdMostSignificant = 100; + transfer1.creditAccountIdLeastSignificant = 2000; + transfer1.creditAccountIdMostSignificant = 200; + transfer1.amount = BigInteger.valueOf(1000); + transfer1.userData128LeastSignificant = 3000; + transfer1.userData128MostSignificant = 300; + transfer1.userData64 = 6000; + transfer1.userData32 = 7000; + transfer1.code = 10; + transfer1.ledger = 720; + + transfer2 = new DummyTransferDto(); + transfer2.idLeastSignificant = 5001; + transfer2.idMostSignificant = 501; + transfer2.debitAccountIdLeastSignificant = 1001; + transfer2.debitAccountIdMostSignificant = 101; + transfer2.creditAccountIdLeastSignificant = 2001; + transfer2.creditAccountIdMostSignificant = 201; + transfer2.amount = BigInteger.valueOf(200); + transfer2.pendingIdLeastSignificant = transfer1.idLeastSignificant; + transfer2.pendingIdMostSignificant = transfer1.idMostSignificant; + transfer2.userData128LeastSignificant = 3001; + transfer2.userData128MostSignificant = 301; + transfer2.userData64 = 8000; + transfer2.userData32 = 9000; + transfer2.timeout = 2500; + transfer2.code = 20; + transfer2.ledger = 100; + transfer2.flags = TransferFlags.PENDING | TransferFlags.LINKED; + transfer2.timestamp = 900; + + // Mimic the binary response + dummyTransfersStream = ByteBuffer.allocate(256).order(ByteOrder.LITTLE_ENDIAN); + + // Item 1 + dummyTransfersStream.putLong(5000).putLong(500); // Id + dummyTransfersStream.putLong(1000).putLong(100); // CreditAccountId + dummyTransfersStream.putLong(2000).putLong(200); // DebitAccountId + dummyTransfersStream.putLong(1000).putLong(0); // Amount + dummyTransfersStream.putLong(0).putLong(0); // PendingId + dummyTransfersStream.putLong(3000).putLong(300); // UserData128 + dummyTransfersStream.putLong(6000); // UserData64 + dummyTransfersStream.putInt(7000); // UserData32 + dummyTransfersStream.putInt(0); // Timeout + dummyTransfersStream.putInt(720); // Ledger + dummyTransfersStream.putShort((short) 10); // Code + dummyTransfersStream.putShort((short) 0); // Flags + dummyTransfersStream.putLong(0); // Timestamp + + // Item 2 + dummyTransfersStream.putLong(5001).putLong(501); // Id + dummyTransfersStream.putLong(1001).putLong(101); // CreditAccountId + dummyTransfersStream.putLong(2001).putLong(201); // DebitAccountId + dummyTransfersStream.putLong(200).putLong(0); // Amount + dummyTransfersStream.putLong(5000).putLong(500); // PendingId + dummyTransfersStream.putLong(3001).putLong(301); // UserData128 + dummyTransfersStream.putLong(8000); // UserData64 + dummyTransfersStream.putInt(9000); // UserData32 + dummyTransfersStream.putInt(2500); // Timeout + dummyTransfersStream.putInt(100); // Ledger + dummyTransfersStream.putShort((short) 20); // Code + dummyTransfersStream.putShort((short) 3); // Flags + dummyTransfersStream.putLong(900); // Timestamp + + createAccountTimestamp1 = 999_998; + createAccountTimestamp2 = 999_999; + createAccountResult1 = CreateAccountStatus.Created; + createAccountResult2 = CreateAccountStatus.Exists; + + // Mimic the binary response + dummyCreateAccountStatusStream = ByteBuffer.allocate(32).order(ByteOrder.LITTLE_ENDIAN); + dummyCreateAccountStatusStream.putLong(createAccountTimestamp1) + .putInt(createAccountResult1.value).putInt(0); + dummyCreateAccountStatusStream.putLong(createAccountTimestamp2) + .putInt(createAccountResult2.value).putInt(0); + + createTransferTimestamp1 = 999_998; + createTransferTimestamp2 = 999_999; + createTransferResult1 = CreateTransferStatus.Created; + createTransferResult2 = CreateTransferStatus.ExceedsDebits; + + // Mimic the binary response + dummyCreateTransferResultsStream = ByteBuffer.allocate(32).order(ByteOrder.LITTLE_ENDIAN); + dummyCreateTransferResultsStream.putLong(createTransferTimestamp1) + .putInt(createTransferResult1.value).putInt(0); + dummyCreateTransferResultsStream.putLong(createTransferTimestamp2) + .putInt(createTransferResult2.value).putInt(0); + + id1 = new byte[] {10, 0, 0, 0, 0, 0, 0, 0, 100, 0, 0, 0, 0, 0, 0, 0}; + id1LeastSignificant = 10; + id1MostSignificant = 100; + id2 = new byte[] {2, 0, 0, 0, 0, 0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0}; + id2LeastSignificant = 2; + id2MostSignificant = 20; + + // Mimic the binary response + dummyIdsStream = ByteBuffer.allocate(32).order(ByteOrder.LITTLE_ENDIAN); + dummyIdsStream.putLong(10).putLong(100); // Item (10,100) + dummyIdsStream.putLong(2).putLong(20); // Item (2,20) + } +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/BlockingRequestTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/BlockingRequestTest.java new file mode 100644 index 00000000..fdf541a5 --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/BlockingRequestTest.java @@ -0,0 +1,493 @@ +package com.tigerbeetle; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.junit.Test; +import com.tigerbeetle.Request.Operations; + +public class BlockingRequestTest { + + @Test + public void testCreateAccountsRequestConstructor() { + var client = getDummyClient(); + var batch = new AccountBatch(1); + batch.add(); + + var request = BlockingRequest.createAccounts(client, batch); + assertNotNull(request); + } + + @Test + public void testCreateTransfersRequestConstructor() { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + var request = BlockingRequest.createTransfers(client, batch); + assertNotNull(request); + } + + @Test + public void testLookupAccountsRequestConstructor() { + var client = getDummyClient(); + var batch = new IdBatch(1); + batch.add(); + + var request = BlockingRequest.lookupAccounts(client, batch); + assertNotNull(request); + } + + @Test + public void testLookupTransfersRequestConstructor() { + var client = getDummyClient(); + var batch = new IdBatch(1); + batch.add(); + + var request = BlockingRequest.lookupTransfers(client, batch); + assertNotNull(request); + } + + @Test(expected = NullPointerException.class) + public void testConstructorWithClientNull() { + var batch = new AccountBatch(1); + batch.add(); + + BlockingRequest.createAccounts(null, batch); + fail(); + } + + @Test(expected = NullPointerException.class) + public void testConstructorWithBatchNull() { + var client = getDummyClient(); + BlockingRequest.createAccounts(client, null); + fail(); + } + + @Test + public void testConstructorWithZeroCapacityBatch() { + var client = getDummyClient(); + var batch = new AccountBatch(0); + + BlockingRequest.createAccounts(client, batch); + } + + @Test + public void testConstructorWithZeroItemsBatch() { + var client = getDummyClient(); + var batch = new AccountBatch(1); + + BlockingRequest.createAccounts(client, batch); + } + + @Test(expected = AssertionError.class) + public void testEndRequestWithInvalidOperation() throws InterruptedException { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + var request = BlockingRequest.createTransfers(client, batch); + + assertFalse(request.isDone()); + + // Invalid operation, should be CREATE_TRANSFERS + request.endRequest(Request.Operations.LOOKUP_ACCOUNTS.value, PacketStatus.Ok.value, + System.nanoTime()); + + assertTrue(request.isDone()); + + request.waitForResult(); + fail(); + } + + @Test(expected = AssertionError.class) + public void testEndRequestWithUnknownOperation() throws InterruptedException { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + final byte UNKNOWN = 99; + var request = new BlockingRequest(client, + Operations.CREATE_TRANSFERS, batch); + + assertFalse(request.isDone()); + + // Unknown operation + request.endRequest(UNKNOWN, PacketStatus.Ok.value, System.nanoTime()); + + assertTrue(request.isDone()); + + request.waitForResult(); + fail(); + } + + @Test + public void testEndRequestWithNullBuffer() throws InterruptedException { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + var request = BlockingRequest.createTransfers(client, batch); + + assertFalse(request.isDone()); + + request.endRequest(Request.Operations.CREATE_TRANSFERS.value, PacketStatus.Ok.value, + System.nanoTime()); + + assertTrue(request.isDone()); + + var result = request.waitForResult(); + assertEquals(0, result.getLength()); + } + + @Test(expected = AssertionError.class) + public void testEndRequestWithInvalidBufferSize() throws InterruptedException { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + var request = BlockingRequest.createTransfers(client, batch); + var invalidBuffer = ByteBuffer.allocate((CreateTransferResultBatch.Struct.SIZE * 2) - 1); + + assertFalse(request.isDone()); + + request.setReplyBuffer(invalidBuffer.array()); + request.endRequest(Request.Operations.CREATE_TRANSFERS.value, PacketStatus.Ok.value, + System.nanoTime()); + + assertTrue(request.isDone()); + + request.waitForResult(); + fail(); + } + + @Test(expected = AssertionError.class) + public void testGetResultBeforeEndRequest() { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + var request = BlockingRequest.createTransfers(client, batch); + + assertFalse(request.isDone()); + + request.getResult(); + fail(); + } + + @Test + public void testEndRequestWithRequestException() throws InterruptedException { + var client = getDummyClient(); + var batch = new TransferBatch(1); + batch.add(); + + var request = BlockingRequest.createTransfers(client, batch); + + assertFalse(request.isDone()); + + var dummyBuffer = ByteBuffer.allocate(CreateTransferResultBatch.Struct.SIZE); + request.setReplyBuffer(dummyBuffer.array()); + request.endRequest(Request.Operations.CREATE_TRANSFERS.value, + PacketStatus.ClientReleaseTooLow.value, 0L); + + assertTrue(request.isDone()); + + try { + request.waitForResult(); + fail(); + } catch (ClientReleaseException exception) { + assertEquals(ClientReleaseException.Reason.ClientReleaseTooLow, exception.getReason()); + } + } + + @Test(expected = AssertionError.class) + public void testEndRequestWithAmountOfResultsGreaterThanAmountOfRequests() + throws InterruptedException { + var client = getDummyClient(); + + // A batch with only 1 item + var batch = new AccountBatch(1); + batch.add(); + + // A reply with 2 items, while the batch had only 1 item + var dummyReplyBuffer = ByteBuffer.allocate(CreateAccountResultBatch.Struct.SIZE * 2) + .order(ByteOrder.LITTLE_ENDIAN); + + var request = BlockingRequest.createAccounts(client, batch); + request.setReplyBuffer(dummyReplyBuffer.position(0).array()); + request.endRequest(Request.Operations.CREATE_ACCOUNTS.value, PacketStatus.Ok.value, + System.nanoTime()); + + assertTrue(request.isDone()); + request.waitForResult(); + } + + @Test(expected = IllegalStateException.class) + public void testEndRequestTwice() throws InterruptedException { + var client = getDummyClient(); + + // A batch with only 1 item + var batch = new AccountBatch(1); + batch.add(); + + // A reply with 2 items, while the batch had only 1 item + var dummyReplyBuffer = ByteBuffer.allocate(CreateAccountResultBatch.Struct.SIZE) + .order(ByteOrder.LITTLE_ENDIAN); + + var request = BlockingRequest.createAccounts(client, batch); + assertFalse(request.isDone()); + + request.setReplyBuffer(dummyReplyBuffer.position(0).array()); + + // First completion is OK, registering the exception. + try { + request.endRequest(Request.Operations.CREATE_ACCOUNTS.value, PacketStatus.Ok.value, + System.nanoTime()); + } catch (Throwable any) { + // No exception is expected in the first call. + fail(); + } + + assertTrue(request.isDone()); + var result = request.waitForResult(); + assertEquals(1, result.getLength()); + + // Can't end the request twice. + // NOTE: Normally this is caught by `Request.endRequest`, halting the VM immediately. + // But we can't test that in a good way, so we use `setException` here and catch the + // IllegalStateException. + request.setException(new Exception()); + } + + @Test + public void testCreateAccountEndRequest() throws InterruptedException { + var client = getDummyClient(); + var batch = new AccountBatch(2); + batch.add(); + batch.add(); + + var request = BlockingRequest.createAccounts(client, batch); + + // A dummy ByteBuffer simulating some simple reply + var dummyReplyBuffer = ByteBuffer.allocate(CreateAccountResultBatch.Struct.SIZE * 2) + .order(ByteOrder.LITTLE_ENDIAN); + dummyReplyBuffer.putLong(100); + dummyReplyBuffer.putInt(CreateAccountStatus.IdMustNotBeZero.value); + dummyReplyBuffer.putInt(0); + dummyReplyBuffer.putLong(101); + dummyReplyBuffer.putInt(CreateAccountStatus.Exists.value); + dummyReplyBuffer.putInt(0); + + request.setReplyBuffer(dummyReplyBuffer.position(0).array()); + request.endRequest(Request.Operations.CREATE_ACCOUNTS.value, PacketStatus.Ok.value, + System.nanoTime()); + + assertTrue(request.isDone()); + var result = request.waitForResult(); + assertEquals(2, result.getLength()); + + assertTrue(result.next()); + assertEquals(100, result.getTimestamp()); + assertEquals(CreateAccountStatus.IdMustNotBeZero, result.getStatus()); + + assertTrue(result.next()); + assertEquals(101, result.getTimestamp()); + assertEquals(CreateAccountStatus.Exists, result.getStatus()); + } + + @Test + public void testCreateTransferEndRequest() throws InterruptedException { + var client = getDummyClient(); + var batch = new TransferBatch(2); + batch.add(); + batch.add(); + + var request = BlockingRequest.createTransfers(client, batch); + + // A dummy ByteBuffer simulating some simple reply + var dummyReplyBuffer = ByteBuffer.allocate(CreateTransferResultBatch.Struct.SIZE * 2) + .order(ByteOrder.LITTLE_ENDIAN); + dummyReplyBuffer.putLong(100); + dummyReplyBuffer.putInt(CreateTransferStatus.IdMustNotBeZero.value); + dummyReplyBuffer.putInt(0); + dummyReplyBuffer.putLong(101); + dummyReplyBuffer.putInt(CreateTransferStatus.Exists.value); + dummyReplyBuffer.putInt(0); + + request.setReplyBuffer(dummyReplyBuffer.position(0).array()); + request.endRequest(Request.Operations.CREATE_TRANSFERS.value, PacketStatus.Ok.value, + System.nanoTime()); + + assertTrue(request.isDone()); + var result = request.waitForResult(); + assertEquals(2, result.getLength()); + + assertTrue(result.next()); + assertEquals(100, result.getTimestamp()); + assertEquals(CreateTransferStatus.IdMustNotBeZero, result.getStatus()); + + assertTrue(result.next()); + assertEquals(101, result.getTimestamp()); + assertEquals(CreateTransferStatus.Exists, result.getStatus()); + } + + @Test + public void testLookupAccountEndRequest() throws InterruptedException { + var client = getDummyClient(); + var batch = new IdBatch(2); + batch.add(); + batch.add(); + + var request = BlockingRequest.lookupAccounts(client, batch); + + // A dummy ByteBuffer simulating some simple reply + var dummyReplyBuffer = + ByteBuffer.allocate(AccountBatch.Struct.SIZE * 2).order(ByteOrder.LITTLE_ENDIAN); + dummyReplyBuffer.putLong(100).putLong(1000); + dummyReplyBuffer.position(AccountBatch.Struct.SIZE).putLong(200).putLong(2000); + + request.setReplyBuffer(dummyReplyBuffer.position(0).array()); + request.endRequest(Request.Operations.LOOKUP_ACCOUNTS.value, PacketStatus.Ok.value, + System.nanoTime()); + + assertTrue(request.isDone()); + var result = request.waitForResult(); + assertEquals(2, result.getLength()); + + assertTrue(result.next()); + assertEquals(100L, result.getId(UInt128.LeastSignificant)); + assertEquals(1000L, result.getId(UInt128.MostSignificant)); + + assertTrue(result.next()); + assertEquals(200L, result.getId(UInt128.LeastSignificant)); + assertEquals(2000L, result.getId(UInt128.MostSignificant)); + } + + @Test + public void testLookupTransferEndRequest() throws InterruptedException { + var client = getDummyClient(); + var batch = new IdBatch(2); + batch.add(); + batch.add(); + + var request = BlockingRequest.lookupTransfers(client, batch); + + // A dummy ByteBuffer simulating some simple reply + var dummyReplyBuffer = + ByteBuffer.allocate(TransferBatch.Struct.SIZE * 2).order(ByteOrder.LITTLE_ENDIAN); + dummyReplyBuffer.putLong(100).putLong(1000); + dummyReplyBuffer.position(TransferBatch.Struct.SIZE).putLong(200).putLong(2000); + + request.setReplyBuffer(dummyReplyBuffer.position(0).array()); + request.endRequest(Request.Operations.LOOKUP_TRANSFERS.value, PacketStatus.Ok.value, + System.nanoTime()); + + assertTrue(request.isDone()); + var result = request.waitForResult(); + assertEquals(2, result.getLength()); + + assertTrue(result.next()); + assertEquals(100L, result.getId(UInt128.LeastSignificant)); + assertEquals(1000L, result.getId(UInt128.MostSignificant)); + + assertTrue(result.next()); + assertEquals(200L, result.getId(UInt128.LeastSignificant)); + assertEquals(2000L, result.getId(UInt128.MostSignificant)); + } + + @Test + public void testSuccessCompletion() throws InterruptedException { + var client = getDummyClient(); + var batch = new IdBatch(2); + batch.add(); + batch.add(); + + // A dummy ByteBuffer simulating some simple reply + var dummyReplyBuffer = + ByteBuffer.allocate(TransferBatch.Struct.SIZE * 2).order(ByteOrder.LITTLE_ENDIAN); + + dummyReplyBuffer.putLong(100).putLong(1000); + dummyReplyBuffer.position(TransferBatch.Struct.SIZE).putLong(200).putLong(2000); + + var callback = + new CallbackSimulator(BlockingRequest.lookupTransfers(client, batch), + Request.Operations.LOOKUP_TRANSFERS.value, dummyReplyBuffer, + PacketStatus.Ok.value, 500); + + callback.start(); + + // Wait for completion + var result = callback.request.waitForResult(); + assertEquals(2, result.getLength()); + + assertTrue(result.next()); + assertEquals(100L, result.getId(UInt128.LeastSignificant)); + assertEquals(1000L, result.getId(UInt128.MostSignificant)); + + assertTrue(result.next()); + assertEquals(200L, result.getId(UInt128.LeastSignificant)); + assertEquals(2000L, result.getId(UInt128.MostSignificant)); + } + + @Test + public void testFailedCompletion() throws InterruptedException { + var client = getDummyClient(); + var batch = new IdBatch(1); + batch.add(); + + var callback = + new CallbackSimulator(BlockingRequest.lookupTransfers(client, batch), + Request.Operations.LOOKUP_TRANSFERS.value, null, + PacketStatus.ClientReleaseTooHigh.value, 250); + + callback.start(); + + try { + callback.request.waitForResult(); + fail(); + } catch (ClientReleaseException exception) { + assertEquals(ClientReleaseException.Reason.ClientReleaseTooHigh, exception.getReason()); + } + } + + private static NativeClient getDummyClient() { + return NativeClient.initEcho(UInt128.asBytes(0), "3000"); + } + + private class CallbackSimulator extends Thread { + + public final BlockingRequest request; + private final byte receivedOperation; + private final ByteBuffer buffer; + private final byte status; + private final int delay; + + + private CallbackSimulator(BlockingRequest request, byte receivedOperation, + ByteBuffer buffer, byte status, int delay) { + this.request = request; + this.receivedOperation = receivedOperation; + this.buffer = buffer; + this.status = status; + this.delay = delay; + } + + @Override + public synchronized void run() { + try { + Thread.sleep(delay); + if (buffer != null) { + request.setReplyBuffer(buffer.array()); + } + request.endRequest(receivedOperation, status, System.nanoTime()); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/CreateAccountStatusTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/CreateAccountStatusTest.java new file mode 100644 index 00000000..011fcc13 --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/CreateAccountStatusTest.java @@ -0,0 +1,39 @@ +package com.tigerbeetle; + +import org.junit.Assert; +import org.junit.Test; + +public class CreateAccountStatusTest { + + @Test + public void testFromValue() { + final var value = CreateAccountStatus.Exists.value; + Assert.assertEquals(CreateAccountStatus.Exists, CreateAccountStatus.fromValue(value)); + } + + @Test + public void testOrdinal() { + for (final var expected : CreateAccountStatus.values()) { + final var actual = CreateAccountStatus.fromValue(expected.value); + Assert.assertEquals(expected, actual); + } + } + + @Test + public void testMaxValue() { + var value = 0xFFFFFFFF; + Assert.assertEquals(CreateAccountStatus.Created, CreateAccountStatus.fromValue(value)); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidValue() { + var value = 999; + CreateAccountStatus.fromValue(value); + } + + @Test(expected = IllegalArgumentException.class) + public void testNegativeValue() { + var value = -100; + CreateAccountStatus.fromValue(value); + } +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/CreateTransferStatusTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/CreateTransferStatusTest.java new file mode 100644 index 00000000..9da08a7a --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/CreateTransferStatusTest.java @@ -0,0 +1,40 @@ +package com.tigerbeetle; + +import org.junit.Assert; +import org.junit.Test; + +public class CreateTransferStatusTest { + + @Test + public void testFromValue() { + var value = CreateTransferStatus.DebitAccountIdMustNotBeIntMax.value; + Assert.assertEquals(CreateTransferStatus.DebitAccountIdMustNotBeIntMax, + CreateTransferStatus.fromValue(value)); + } + + @Test + public void testOrdinal() { + for (final var expected : CreateTransferStatus.values()) { + final var actual = CreateTransferStatus.fromValue(expected.value); + Assert.assertEquals(expected, actual); + } + } + + @Test + public void testMaxValue() { + var value = 0xFFFFFFFF; + Assert.assertEquals(CreateTransferStatus.Created, CreateTransferStatus.fromValue(value)); + } + + @Test(expected = IllegalArgumentException.class) + public void testInvalidValue() { + var value = 999; + CreateTransferStatus.fromValue(value); + } + + @Test(expected = IllegalArgumentException.class) + public void testNegativeValue() { + var value = -100; + CreateTransferStatus.fromValue(value); + } +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/EchoTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/EchoTest.java new file mode 100644 index 00000000..a1035963 --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/EchoTest.java @@ -0,0 +1,185 @@ +package com.tigerbeetle; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +public class EchoTest { + + static final int HEADER_SIZE = 256; // @sizeOf(vsr.Header) + static final int TRANSFER_SIZE = 128; // @sizeOf(Transfer) + static final int MESSAGE_SIZE_MAX = 1024 * 1024; // config.message_size_max + static final int ITEMS_PER_BATCH = (MESSAGE_SIZE_MAX - HEADER_SIZE) / TRANSFER_SIZE; + + // The number of times the same test is repeated, to stress the + // cycle of packet exhaustion followed by completions. + static final int repetitionsMax = 16; + + // The number of concurrency requests on each cycle. + static final int concurrencyMax = 64; + + @Test(expected = AssertionError.class) + public void testConstructorNullReplicaAddresses() throws Throwable { + + try (var client = new EchoClient(UInt128.asBytes(0), null)) { + + } catch (Throwable any) { + throw any; + } + } + + @Test(expected = AssertionError.class) + public void testConstructorInvalidCluster() throws Throwable { + var clusterInvalid = new byte[] {1, 2, 3}; + try (var client = new EchoClient(clusterInvalid, "3000")) { + + } catch (Throwable any) { + throw any; + } + } + + @Test + public void testEchoAccounts() throws Throwable { + final Random rnd = new Random(1); + + try (var client = new EchoClient(UInt128.asBytes(0), "3000")) { + final var batch = new AccountBatch(getRandomData(rnd, AccountBatch.Struct.SIZE)); + final var reply = client.echo(batch); + assertBatchesEqual(batch, reply); + } + } + + @Test + public void testEchoTransfers() throws Throwable { + final Random rnd = new Random(2); + + try (var client = new EchoClient(UInt128.asBytes(0), "3000")) { + final var batch = new TransferBatch(getRandomData(rnd, TransferBatch.Struct.SIZE)); + final var future = client.echoAsync(batch); + final var reply = future.join(); + assertBatchesEqual(batch, reply); + } + } + + @Test + public void testEchoAccountsAsync() throws Throwable { + + final class AsyncContext { + public AccountBatch batch; + public CompletableFuture future; + }; + + final Random rnd = new Random(3); + try (var client = new EchoClient(UInt128.asBytes(0), "3000")) { + for (int repetition = 0; repetition < repetitionsMax; repetition++) { + + final var list = new ArrayList(); + for (int i = 0; i < concurrencyMax; i++) { + + // Submitting some random data to be echoed back: + final var batch = + new AccountBatch(getRandomData(rnd, AccountBatch.Struct.SIZE)); + + var context = new AsyncContext(); + context.batch = batch; + context.future = client.echoAsync(batch); + + list.add(context); + } + + for (var context : list) { + final var batch = context.batch; + final var reply = context.future.get(); + assertBatchesEqual(batch, reply); + } + } + } + } + + @Test + public void testEchoTransfersConcurrent() throws Throwable { + + final class ThreadContext extends Thread { + + public final TransferBatch batch; + private final EchoClient client; + private TransferBatch reply; + private Throwable exception; + + public ThreadContext(EchoClient client, TransferBatch batch) { + this.client = client; + this.batch = batch; + this.reply = null; + this.exception = null; + } + + public TransferBatch getReply() { + if (exception != null) + throw new RuntimeException(exception); + return reply; + } + + @Override + public synchronized void run() { + try { + reply = client.echo(batch); + } catch (Throwable e) { + exception = e; + } + } + } + + final Random rnd = new Random(4); + try (var client = new EchoClient(UInt128.asBytes(0), "3000")) { + for (int repetition = 0; repetition < repetitionsMax; repetition++) { + + final var list = new ArrayList(); + for (int i = 0; i < concurrencyMax; i++) { + + // Submitting some random data to be echoed back: + final var batch = + new TransferBatch(getRandomData(rnd, TransferBatch.Struct.SIZE)); + + var context = new ThreadContext(client, batch); + context.start(); + + list.add(context); + } + + for (var context : list) { + context.join(); + final var batch = context.batch; + final var reply = context.getReply(); + assertBatchesEqual(batch, reply); + } + } + } + } + + private ByteBuffer getRandomData(final Random rnd, final int SIZE) { + final var length = rnd.nextInt(ITEMS_PER_BATCH - 1) + 1; + var buffer = ByteBuffer.allocateDirect(length * SIZE); + for (int i = 0; i < length; i++) { + var item = new byte[SIZE]; + rnd.nextBytes(item); + buffer.put(item); + } + return buffer.position(0); + } + + private void assertBatchesEqual(Batch batch, Batch reply) { + final var capacity = batch.getCapacity(); + assertEquals(capacity, reply.getCapacity()); + + final var length = batch.getLength(); + assertEquals(length, reply.getLength()); + + var buffer = batch.getBuffer(); + var replyBuffer = reply.getBuffer(); + + assertEquals(buffer, replyBuffer); + } +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/IntegrationTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/IntegrationTest.java new file mode 100644 index 00000000..27f13b9f --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/IntegrationTest.java @@ -0,0 +1,2680 @@ +package com.tigerbeetle; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.lang.ProcessBuilder.Redirect; +import java.math.BigInteger; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Integration tests using a TigerBeetle instance. + */ +public class IntegrationTest { + private static final byte[] clusterId = new byte[16]; + + private static Server server; + private static Client client; + + private static AccountBatch generateAccounts(final byte[]... ids) { + final var accounts = new AccountBatch(ids.length); + + for (var id : ids) { + accounts.add(); + accounts.setId(id); + accounts.setUserData128(100, 0); + accounts.setUserData64(101); + accounts.setUserData32(102); + accounts.setLedger(720); + accounts.setCode(1); + accounts.setFlags(AccountFlags.NONE); + } + + accounts.beforeFirst(); + return accounts; + } + + @BeforeClass + public static void initialize() throws Exception { + server = new Server("tests"); + client = new Client(clusterId, new String[] {server.address}); + } + + @AfterClass + public static void cleanup() throws Exception { + client.close(); + server.close(); + } + + @Test(expected = NullPointerException.class) + public void testConstructorNullReplicaAddresses() throws Throwable { + try (final var client = new Client(clusterId, null)) { + fail(); + } + } + + @Test(expected = NullPointerException.class) + public void testConstructorNullElementReplicaAddresses() throws Throwable { + final var replicaAddresses = new String[] {"3001", null}; + try (final var client = new Client(clusterId, replicaAddresses)) { + fail(); + } + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructorEmptyReplicaAddresses() throws Throwable { + final var replicaAddresses = new String[0]; + try (final var client = new Client(clusterId, replicaAddresses)) { + fail(); + } + } + + @Test + public void testConstructorReplicaAddressesLimitExceeded() throws Throwable { + final var replicaAddresses = new String[100]; + for (int i = 0; i < replicaAddresses.length; i++) { + replicaAddresses[i] = "3000"; + } + + try (final var client = new Client(clusterId, replicaAddresses)) { + fail(); + } catch (InitializationException initializationException) { + assertEquals(InitializationStatus.AddressLimitExceeded.value, + initializationException.getStatus()); + } + } + + @Test + public void testConstructorEmptyStringReplicaAddresses() throws Throwable { + final var replicaAddresses = new String[] {"", "", ""}; + try (final var client = new Client(clusterId, replicaAddresses)) { + fail(); + } catch (InitializationException initializationException) { + assertEquals(InitializationStatus.AddressInvalid.value, + initializationException.getStatus()); + } + } + + @Test + public void testConstructorInvalidReplicaAddresses() throws Throwable { + final var replicaAddresses = new String[] {"127.0.0.1:99999"}; + try (final var client = new Client(clusterId, replicaAddresses)) { + fail(); + } catch (InitializationException initializationException) { + assertEquals(InitializationStatus.AddressInvalid.value, + initializationException.getStatus()); + } + } + + public void testConstructorCluster() throws Throwable { + final var clusterId = UInt128.id(); + final var replicaAddresses = new String[] {"3001"}; + try (final var client = new Client(clusterId, replicaAddresses)) { + assertArrayEquals(clusterId, client.getClusterID()); + } + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructorInvalidCluster() throws Throwable { + final var clusterIdInvalid = new byte[] {0, 0, 0}; + final var replicaAddresses = new String[] {"3001"}; + try (final var client = new Client(clusterIdInvalid, replicaAddresses)) { + fail(); + } + } + + @Test + public void testCreateAccounts() throws Throwable { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var accounts = generateAccounts(account1Id, account2Id); + + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + final var lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertEquals(2, lookupAccounts.getLength()); + + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + } + + @Test + public void testCreateInvalidAccount() throws Throwable { + final var zeroedAccounts = new AccountBatch(1); + zeroedAccounts.add(); + + final var accountResults = client.createAccounts(zeroedAccounts); + assertHeader(zeroedAccounts, accountResults); + assertTrue(accountResults.getLength() == 1); + assertTrue(accountResults.next()); + assertEquals(CreateAccountStatus.IdMustNotBeZero, accountResults.getStatus()); + } + + @Test + public void testCreateAccountsAsync() throws Throwable { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var accounts = generateAccounts(account1Id, account2Id); + + CompletableFuture accountResultsFuture = + client.createAccountsAsync(accounts); + + final var accountResults = accountResultsFuture.get(); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + while (accountResults.next()) { + assertTrue(accountResults.getTimestamp() > 0); + assertEquals(CreateAccountStatus.Created, accountResults.getStatus()); + } + + CompletableFuture lookupFuture = + client.lookupAccountsAsync(new IdBatch(account1Id, account2Id)); + + final var lookupAccounts = lookupFuture.get(); + assertEquals(2, lookupAccounts.getLength()); + + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + } + + @Test + public void testCreateTransfers() throws Throwable { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var transfer1Id = UInt128.id(); + final var transfer2Id = UInt128.id(); + + final var accounts = generateAccounts(account1Id, account2Id); + + // Creating the accounts. + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + // Creating a transfer. + final var transfers = new TransferBatch(2); + + transfers.add(); + transfers.setId(transfer1Id); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode(1); + transfers.setFlags(TransferFlags.NONE); + transfers.setAmount(100); + + final var transferResults = client.createTransfers(transfers); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + + // Looking up the accounts. + final var lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertTrue(lookupAccounts.getLength() == 2); + + accounts.beforeFirst(); + + // Asserting the first account for the credit. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.valueOf(100), lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + + // Asserting the second account for the debit. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.valueOf(100), lookupAccounts.getDebitsPosted()); + + // Looking up and asserting the transfer. + final var lookupTransfers = client.lookupTransfers(new IdBatch(transfer1Id, transfer2Id)); + assertEquals(1, lookupTransfers.getLength()); + + transfers.beforeFirst(); + + assertTrue(transfers.next()); + assertTrue(lookupTransfers.next()); + assertTransfers(transfers, lookupTransfers); + assertNotEquals(0L, lookupTransfers.getTimestamp()); + } + + @Test + public void testCreateTransfersAsync() throws Throwable { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var transfer1Id = UInt128.id(); + final var transfer2Id = UInt128.id(); + + final var accounts = generateAccounts(account1Id, account2Id); + + // Creating the accounts. + CompletableFuture accountResultsFuture = + client.createAccountsAsync(accounts); + + final var accountResults = accountResultsFuture.get(); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + while (accountResults.next()) { + assertTrue(accountResults.getTimestamp() > 0); + assertEquals(CreateAccountStatus.Created, accountResults.getStatus()); + } + + // Creating a transfer. + final var transfers = new TransferBatch(2); + + transfers.add(); + transfers.setId(transfer1Id); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode(1); + transfers.setAmount(100); + + CompletableFuture transferResultsFuture = + client.createTransfersAsync(transfers); + final var transferResults = transferResultsFuture.get(); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + while (transferResults.next()) { + assertTrue(transferResults.getTimestamp() > 0); + assertEquals(CreateTransferStatus.Created, transferResults.getStatus()); + } + + + // Looking up the accounts. + CompletableFuture lookupAccountsFuture = + client.lookupAccountsAsync(new IdBatch(account1Id, account2Id)); + final var lookupAccounts = lookupAccountsFuture.get(); + assertTrue(lookupAccounts.getLength() == 2); + + accounts.beforeFirst(); + + // Asserting the first account for the credit. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.valueOf(100), lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + + // Asserting the second account for the debit. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.valueOf(100), lookupAccounts.getDebitsPosted()); + + // Looking up and asserting the transfer. + CompletableFuture lookupTransfersFuture = + client.lookupTransfersAsync(new IdBatch(transfer1Id, transfer2Id)); + final var lookupTransfers = lookupTransfersFuture.get(); + assertEquals(1, lookupTransfers.getLength()); + + transfers.beforeFirst(); + + assertTrue(transfers.next()); + assertTrue(lookupTransfers.next()); + assertTransfers(transfers, lookupTransfers); + assertNotEquals(BigInteger.ZERO, lookupTransfers.getTimestamp()); + } + + @Test + public void testCreateInvalidTransfer() throws Throwable { + final var zeroedTransfers = new TransferBatch(1); + zeroedTransfers.add(); + + final var transferResults = client.createTransfers(zeroedTransfers); + assertHeader(zeroedTransfers, transferResults); + assertTrue(transferResults.getLength() == 1); + assertTrue(transferResults.next()); + assertEquals(CreateTransferStatus.IdMustNotBeZero, transferResults.getStatus()); + } + + @Test + public void testCreatePendingTransfers() throws Throwable { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var transfer1Id = UInt128.id(); + final var transfer2Id = UInt128.id(); + + final var accounts = generateAccounts(account1Id, account2Id); + + // Creating the accounts. + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + // Creating a pending transfer. + final var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setId(transfer1Id); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode(1); + transfers.setAmount(100); + transfers.setFlags(TransferFlags.PENDING); + transfers.setTimeout(Integer.MAX_VALUE); + + var transferResults = client.createTransfers(transfers); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + + // Looking up the accounts. + var lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertTrue(lookupAccounts.getLength() == 2); + + accounts.beforeFirst(); + + // Asserting the first account for the pending credit. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.valueOf(100), lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + + // Asserting the second account for the pending debit. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.valueOf(100), lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + + // Looking up and asserting the pending transfer. + var lookupTransfers = client.lookupTransfers(new IdBatch(transfer1Id)); + assertEquals(1, lookupTransfers.getLength()); + + transfers.beforeFirst(); + + assertTrue(transfers.next()); + assertTrue(lookupTransfers.next()); + assertTransfers(transfers, lookupTransfers); + assertNotEquals(0L, lookupTransfers.getTimestamp()); + + // Creating a post_pending transfer. + final var confirmTransfers = new TransferBatch(1); + confirmTransfers.add(); + confirmTransfers.setId(transfer2Id); + confirmTransfers.setCreditAccountId(account1Id); + confirmTransfers.setDebitAccountId(account2Id); + confirmTransfers.setLedger(720); + confirmTransfers.setCode(1); + confirmTransfers.setAmount(100); + confirmTransfers.setFlags(TransferFlags.POST_PENDING_TRANSFER); + confirmTransfers.setPendingId(transfer1Id); + + transferResults = client.createTransfers(confirmTransfers); + assertHeader(confirmTransfers, transferResults); + assertEquals(confirmTransfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + + // Looking up the accounts again for the updated balance. + lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertTrue(lookupAccounts.getLength() == 2); + + accounts.beforeFirst(); + + // Asserting the pending credit was posted for the first account. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.valueOf(100), lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + + // Asserting the pending debit was posted for the second account. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.valueOf(100), lookupAccounts.getDebitsPosted()); + + // Looking up and asserting the post_pending transfer. + final var lookupVoidTransfers = client.lookupTransfers(new IdBatch(transfer2Id)); + assertEquals(1, lookupVoidTransfers.getLength()); + + confirmTransfers.beforeFirst(); + + assertTrue(confirmTransfers.next()); + assertTrue(lookupVoidTransfers.next()); + assertTransfers(confirmTransfers, lookupVoidTransfers); + assertNotEquals(0L, lookupVoidTransfers.getTimestamp()); + } + + @Test + public void testCreatePendingTransfersAndVoid() throws Throwable { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var transfer1Id = UInt128.id(); + final var transfer2Id = UInt128.id(); + + final var accounts = generateAccounts(account1Id, account2Id); + + // Creating the accounts. + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + // Creating a pending transfer. + final var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setId(transfer1Id); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode(1); + transfers.setAmount(100); + transfers.setFlags(TransferFlags.PENDING); + transfers.setTimeout(Integer.MAX_VALUE); + + var transferResults = client.createTransfers(transfers); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + + // Looking up the accounts. + var lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertTrue(lookupAccounts.getLength() == 2); + + accounts.beforeFirst(); + + // Asserting the first account for the pending credit. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.valueOf(100), lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + + // Asserting the second account for the pending credit. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.valueOf(100), lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + + // Looking up and asserting the pending transfer. + var lookupTransfers = client.lookupTransfers(new IdBatch(transfer1Id)); + assertEquals(1, lookupTransfers.getLength()); + + transfers.beforeFirst(); + + assertTrue(transfers.next()); + assertTrue(lookupTransfers.next()); + assertTransfers(transfers, lookupTransfers); + assertNotEquals(0L, lookupTransfers.getTimestamp()); + + // Creating a void_pending transfer. + final var voidTransfers = new TransferBatch(2); + voidTransfers.add(); + voidTransfers.setId(transfer2Id); + voidTransfers.setCreditAccountId(account1Id); + voidTransfers.setDebitAccountId(account2Id); + voidTransfers.setLedger(720); + voidTransfers.setCode(1); + voidTransfers.setAmount(100); + voidTransfers.setFlags(TransferFlags.VOID_PENDING_TRANSFER); + voidTransfers.setPendingId(transfer1Id); + + transferResults = client.createTransfers(voidTransfers); + assertHeader(voidTransfers, transferResults); + assertEquals(voidTransfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + + // Looking up the accounts again for the updated balance. + lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertTrue(lookupAccounts.getLength() == 2); + + accounts.beforeFirst(); + + // Asserting the pending credit was voided for the first account. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + + // Asserting the pending debit was voided for the second account. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + + // Looking up and asserting the void_pending transfer. + final var lookupVoidTransfers = client.lookupTransfers(new IdBatch(transfer2Id)); + assertEquals(1, lookupVoidTransfers.getLength()); + + voidTransfers.beforeFirst(); + + assertTrue(voidTransfers.next()); + assertTrue(lookupVoidTransfers.next()); + assertTransfers(voidTransfers, lookupVoidTransfers); + assertNotEquals(0L, lookupVoidTransfers.getTimestamp()); + } + + @Test + public void testCreatePendingTransfersAndVoidExpired() throws Throwable { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var transfer1Id = UInt128.id(); + final var transfer2Id = UInt128.id(); + + final var accounts = generateAccounts(account1Id, account2Id); + + // Creating the accounts. + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + // Creating a pending transfer. + final var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setId(transfer1Id); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode(1); + transfers.setAmount(100); + transfers.setFlags(TransferFlags.PENDING); + transfers.setTimeout(1); + + var transferResults = client.createTransfers(transfers); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + + // Looking up the accounts. + var lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertTrue(lookupAccounts.getLength() == 2); + + accounts.beforeFirst(); + + // Asserting the first account for the pending credit. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.valueOf(100), lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + + // Asserting the second account for the pending credit. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.valueOf(100), lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + + // Looking up and asserting the pending transfer. + final var lookupTransfers = client.lookupTransfers(new IdBatch(transfer1Id)); + assertEquals(1, lookupTransfers.getLength()); + + transfers.beforeFirst(); + + assertTrue(transfers.next()); + assertTrue(lookupTransfers.next()); + assertTransfers(transfers, lookupTransfers); + assertNotEquals(0L, lookupTransfers.getTimestamp()); + + // We need to wait 1s for the server to expire the transfer, however the + // server can pulse the expiry operation anytime after the timeout, + // so adding an extra delay to avoid flaky tests. + final var timeout_ms = TimeUnit.SECONDS.toMillis(lookupTransfers.getTimeout()); + final var currentMilis = System.currentTimeMillis(); + final var extra_wait_time = 500L; + Thread.sleep(timeout_ms + extra_wait_time); + assertTrue(System.currentTimeMillis() - currentMilis > timeout_ms); + + // Looking up the accounts again for the updated balance. + lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertTrue(lookupAccounts.getLength() == 2); + + accounts.beforeFirst(); + + // Asserting the pending credit was voided. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + + // Asserting the pending debit was voided. + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + + // Creating a void_pending transfer. + final var voidTransfers = new TransferBatch(1); + voidTransfers.add(); + voidTransfers.setId(transfer2Id); + voidTransfers.setCreditAccountId(account1Id); + voidTransfers.setDebitAccountId(account2Id); + voidTransfers.setLedger(720); + voidTransfers.setCode(1); + voidTransfers.setAmount(100); + voidTransfers.setFlags(TransferFlags.VOID_PENDING_TRANSFER); + voidTransfers.setPendingId(transfer1Id); + + transferResults = client.createTransfers(voidTransfers); + assertHeader(voidTransfers, transferResults); + assertEquals(voidTransfers.getLength(), transferResults.getLength()); + assertTrue(transferResults.next()); + assertTrue(transferResults.getTimestamp() > 0); + assertEquals(CreateTransferStatus.PendingTransferExpired, transferResults.getStatus()); + } + + @Test + public void testCreateLinkedTransfers() throws Throwable { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var transfer1Id = UInt128.id(); + final var transfer2Id = UInt128.id(); + + final var accounts = generateAccounts(account1Id, account2Id); + + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + final var transfers = new TransferBatch(2); + transfers.add(); + transfers.setId(transfer1Id); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode(1); + transfers.setAmount(100); + transfers.setFlags(TransferFlags.LINKED); + + transfers.add(); + transfers.setId(transfer2Id); + transfers.setCreditAccountId(account2Id); + transfers.setDebitAccountId(account1Id); + transfers.setLedger(720); + transfers.setCode(1); + transfers.setAmount(49); + transfers.setFlags(TransferFlags.NONE); + + final var transferResults = client.createTransfers(transfers); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + + final var lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertEquals(2, lookupAccounts.getLength()); + + accounts.beforeFirst(); + + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.valueOf(100), lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.valueOf(49), lookupAccounts.getDebitsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.valueOf(49), lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.valueOf(100), lookupAccounts.getDebitsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPending()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPending()); + + final var lookupTransfers = client.lookupTransfers(new IdBatch(transfer1Id, transfer2Id)); + assertEquals(2, lookupTransfers.getLength()); + + transfers.beforeFirst(); + + assertTrue(transfers.next()); + assertTrue(lookupTransfers.next()); + assertTransfers(transfers, lookupTransfers); + assertNotEquals(0L, lookupTransfers.getTimestamp()); + + assertTrue(transfers.next()); + assertTrue(lookupTransfers.next()); + + assertTransfers(transfers, lookupTransfers); + assertNotEquals(0L, lookupTransfers.getTimestamp()); + } + + @Test + public void testCreateClosingTransfers() throws Throwable { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var closingTransferId = UInt128.id(); + + final var accounts = generateAccounts(account1Id, account2Id); + + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + var transfers = new TransferBatch(1); + transfers.add(); + transfers.setId(closingTransferId); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode(1); + transfers.setAmount(0); + transfers.setFlags( + TransferFlags.CLOSING_CREDIT | TransferFlags.CLOSING_DEBIT | TransferFlags.PENDING); + + var transferResults = client.createTransfers(transfers); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + + var lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertEquals(2, lookupAccounts.getLength()); + + accounts.beforeFirst(); + + while (lookupAccounts.next()) { + assertTrue(accounts.next()); + assertFalse(accounts.getFlags() == lookupAccounts.getFlags()); + assertTrue(AccountFlags.hasClosed(lookupAccounts.getFlags())); + } + + transfers = new TransferBatch(1); + transfers.add(); + transfers.setId(UInt128.id()); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode(1); + transfers.setAmount(0); + transfers.setPendingId(closingTransferId); + transfers.setFlags(TransferFlags.VOID_PENDING_TRANSFER); + + transferResults = client.createTransfers(transfers); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + + lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertEquals(2, lookupAccounts.getLength()); + + accounts.beforeFirst(); + + while (lookupAccounts.next()) { + assertTrue(accounts.next()); + assertAccounts(accounts, lookupAccounts); + assertFalse(AccountFlags.hasClosed(lookupAccounts.getFlags())); + } + } + + @Test + public void testCreateAccountTooMuchData() throws Throwable { + final int TOO_MUCH_DATA = 10_000; + final var accounts = new AccountBatch(TOO_MUCH_DATA); + for (int i = 0; i < TOO_MUCH_DATA; i++) { + accounts.add(); + accounts.setId(UInt128.id()); + accounts.setCode(1); + accounts.setLedger(1); + } + assertThrows(TooMuchDataException.class, () -> client.createAccounts(accounts)); + } + + @Test + public void testCreateAccountTooMuchDataAsync() throws Throwable { + final int TOO_MUCH_DATA = 10_000; + final var accounts = new AccountBatch(TOO_MUCH_DATA); + for (int i = 0; i < TOO_MUCH_DATA; i++) { + accounts.add(); + accounts.setId(UInt128.id()); + accounts.setCode(1); + accounts.setLedger(1); + } + + try { + CompletableFuture future = + client.createAccountsAsync(accounts); + assertNotNull(future); + + future.get(); + fail(); + } catch (ExecutionException executionException) { + assertTrue(executionException.getCause() instanceof TooMuchDataException); + } + } + + @Test + public void testCreateTransferTooMuchData() throws Throwable { + final int TOO_MUCH_DATA = 10_000; + final var transfers = new TransferBatch(TOO_MUCH_DATA); + + for (int i = 0; i < TOO_MUCH_DATA; i++) { + transfers.add(); + transfers.setId(UInt128.id()); + transfers.setDebitAccountId(UInt128.id()); + transfers.setDebitAccountId(UInt128.id()); + transfers.setCode(1); + transfers.setLedger(1); + } + assertThrows(TooMuchDataException.class, () -> client.createTransfers(transfers)); + + } + + @Test + public void testCreateTransferTooMuchDataAsync() throws Throwable { + final int TOO_MUCH_DATA = 10_000; + final var transfers = new TransferBatch(TOO_MUCH_DATA); + + for (int i = 0; i < TOO_MUCH_DATA; i++) { + transfers.add(); + transfers.setId(UInt128.id()); + transfers.setDebitAccountId(UInt128.id()); + transfers.setDebitAccountId(UInt128.id()); + transfers.setCode(1); + transfers.setLedger(1); + } + + try { + CompletableFuture future = + client.createTransfersAsync(transfers); + assertNotNull(future); + + future.get(); + fail(); + + } catch (ExecutionException executionException) { + assertTrue(executionException.getCause() instanceof TooMuchDataException); + } + } + + @Test + public void testClientEvicted() throws Throwable { + final int CLIENTS_MAX = 64; + + final var barrier = new CountDownLatch(CLIENTS_MAX); + final var executor = Executors.newFixedThreadPool(CLIENTS_MAX); + + // Use a separate server to avoid evicting the test's shared client. + try (final var server = new Server("testClientEvicted")) { + + try (final var client_evict = + new Client(clusterId, new String[] {server.getAddress()})) { + var accounts_first = client_evict.lookupAccounts(new IdBatch(UInt128.id())); + assertTrue(accounts_first.getLength() == 0); + + for (int i = 0; i < CLIENTS_MAX; i++) { + executor.submit(() -> { + try (final var client = + new Client(clusterId, new String[] {server.getAddress()})) { + var accounts = client.lookupAccounts(new IdBatch(UInt128.id())); + assertTrue(accounts.getLength() == 0); + } catch (InterruptedException e) { + return; + } finally { + barrier.countDown(); + } + }); + } + + barrier.await(); + executor.shutdown(); + assertThrows(ClientEvictedException.class, + () -> client_evict.lookupAccounts(new IdBatch(UInt128.id()))); + + + // The client is deinitialized after it learns it was evicted. + // Reusing an evicted client must return a "ClientShutdown" error. + assertThrows(ClientClosedException.class, + () -> client_evict.lookupAccounts(new IdBatch(UInt128.id()))); + } + } + } + + @Test + public void testZeroLengthCreateAccounts() throws Throwable { + final var accounts = new AccountBatch(1); // Capacity 1 but zero items. + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertTrue(accountResults.getLength() == 0); + } + + @Test + public void testZeroLengthCreateAccountsAsync() throws Throwable { + final var accounts = new AccountBatch(1); // Capacity 1 but zero items. + final var accountResultsFuture = client.createAccountsAsync(accounts); + final var accountResults = accountResultsFuture.get(); + assertHeader(accounts, accountResults); + assertTrue(accountResults.getLength() == 0); + } + + @Test + public void testZeroLengthCreateTransfers() throws Throwable { + final var transfers = new TransferBatch(0); + final var transferResults = client.createTransfers(transfers); + assertHeader(transfers, transferResults); + assertTrue(transferResults.getLength() == 0); + } + + @Test + public void testZeroLengthCreateTransfersAsync() throws Throwable { + final var transfers = new TransferBatch(0); + final var transferResultsFuture = client.createTransfersAsync(transfers); + final var transferResults = transferResultsFuture.get(); + assertTrue(transferResults.getLength() == 0); + } + + @Test + public void testZeroLengthLookupAccounts() throws Throwable { + final var ids = new IdBatch(0); + final var accounts = client.lookupAccounts(ids); + assertTrue(accounts.getLength() == 0); + } + + @Test + public void testZeroLengthLookupAccountsAsync() throws Throwable { + final var ids = new IdBatch(0); + final var accountsFuture = client.lookupAccountsAsync(ids); + final var accounts = accountsFuture.get(); + assertTrue(accounts.getLength() == 0); + } + + @Test + public void testZeroLengthLookupTransfers() throws Throwable { + final var ids = new IdBatch(0); + final var transfers = client.lookupTransfers(ids); + assertTrue(transfers.getLength() == 0); + } + + @Test + public void testZeroLengthLookupTransfersAsync() throws Throwable { + final var ids = new IdBatch(0); + final var transfersFuture = client.lookupTransfersAsync(ids); + final var transfers = transfersFuture.get(); + assertTrue(transfers.getLength() == 0); + } + + /** + * This test asserts that the client can handle parallel threads up to concurrencyMax. + */ + @Test + public void testConcurrentTasks() throws Throwable { + final int TASKS_COUNT = 100; + final var barrier = new CountDownLatch(TASKS_COUNT); + + try (final var client = new Client(clusterId, new String[] {server.getAddress()})) { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var accounts = generateAccounts(account1Id, account2Id); + + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + final var tasks = new TransferTask[TASKS_COUNT]; + for (int i = 0; i < TASKS_COUNT; i++) { + // Starting multiple threads submitting transfers. + tasks[i] = new TransferTask(client, account1Id, account2Id, TransferFlags.NONE, + barrier, new CountDownLatch(0)); + tasks[i].start(); + } + + // Wait for all threads: + for (int i = 0; i < TASKS_COUNT; i++) { + tasks[i].join(); + assertTrue(tasks[i].result.next()); + assertTrue(tasks[i].result.getTimestamp() > 0); + assertTrue(tasks[i].result.getStatus() == CreateTransferStatus.Created); + assertFalse(tasks[i].result.next()); + } + + // Asserting if all transfers were submitted correctly. + final var lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertEquals(2, lookupAccounts.getLength()); + + accounts.beforeFirst(); + + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.valueOf(100 * TASKS_COUNT), lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.valueOf(100 * TASKS_COUNT), lookupAccounts.getDebitsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + } + } + + /** + * This test asserts that a linked chain is consistent across concurrent requests. + */ + @Test + public void testConcurrentLinkedChainsTasks() throws Throwable { + final int TASKS_COUNT = 10_000; + final var barrier = new CountDownLatch(TASKS_COUNT); + + try (final var client = new Client(clusterId, new String[] {server.getAddress()})) { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var accounts = generateAccounts(account1Id, account2Id); + + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + final var tasks = new TransferTask[TASKS_COUNT]; + for (int i = 0; i < TASKS_COUNT; i++) { + // Starting multiple threads submitting transfers. + // The Linked flag will cause the + // batch to fail due to LinkedEventChainOpen. + final var flag = i % 10 == 0 ? TransferFlags.LINKED : TransferFlags.NONE; + tasks[i] = new TransferTask(client, account1Id, account2Id, flag, barrier, + new CountDownLatch(0)); + tasks[i].start(); + } + + // Wait for all threads: + for (int i = 0; i < TASKS_COUNT; i++) { + tasks[i].join(); + var batch = tasks[i].result; + assertTrue(batch.next()); + + if (i % 10 == 0) { + assertTrue(batch.getStatus() == CreateTransferStatus.LinkedEventChainOpen); + } else { + assertTrue(batch.getTimestamp() > 0); + assertTrue(batch.getStatus() == CreateTransferStatus.Created); + } + assertFalse(batch.next()); + } + } + } + + /** + * This test asserts that client.close() will wait for all ongoing request to complete And new + * threads trying to submit a request after the client was closed will fail with + * IllegalStateException. + */ + @Test + public void testCloseWithConcurrentTasks() throws Throwable { + // The goal here is to queue many concurrent requests, + // so we have a good chance of testing "client.close()" not only before/after + // calling "submit()", but also during the native call. + // + // Unfortunately this is a hacky test, but a reasonable one: + // Since our JNI module does not expose the acquire_packet function, + // we cannot insert a lock/wait in between "acquire_packet" and "submit" + // in order to cause and assert each variant. + final int TASKS_COUNT = 256; + final var enterBarrier = new CountDownLatch(TASKS_COUNT); + final var exitBarrier = new CountDownLatch(1); + + try (final var client = new Client(clusterId, new String[] {server.getAddress()})) { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var accounts = generateAccounts(account1Id, account2Id); + + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + final var tasks = new TransferTask[TASKS_COUNT]; + for (int i = 0; i < TASKS_COUNT; i++) { + + // Starting multiple threads submitting transfers. + tasks[i] = new TransferTask(client, account1Id, account2Id, TransferFlags.NONE, + enterBarrier, exitBarrier); + tasks[i].start(); + } + + // Waits until one thread finish. + exitBarrier.await(); + + // And then close the client while threads are still working + // Some of them have already submitted the request, while others will fail + // due to "shutdown". + client.close(); + + int failedCount = 0; + int succeededCount = 0; + + for (int i = 0; i < TASKS_COUNT; i++) { + + // The client.close must wait until all submitted requests have completed + // Asserting that either the task succeeded or failed while waiting. + tasks[i].join(); + + final var succeeded = tasks[i].result != null && createSuccess(tasks[i].result); + + // Can fail due to client closed. + final var failed = tasks[i].exception != null + && tasks[i].exception instanceof ClientClosedException; + + assertTrue(failed || succeeded); + + if (failed) { + failedCount += 1; + } else if (succeeded) { + succeededCount += 1; + } + } + + assertTrue(succeededCount + failedCount == TASKS_COUNT); + } + } + + /** + * This test asserts that submit a request after the client was closed will fail with + * ClientClosedException. + */ + @Test + public void testClose() throws Throwable { + try { + // As we call client.close() explicitly, + // we don't need to use a try-with-resources block here. + final var client = new Client(clusterId, new String[] {server.getAddress()}); + + // Creating accounts. + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var accounts = generateAccounts(account1Id, account2Id); + + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + // Closing the client. + client.close(); + + // Creating a transfer with a closed client. + final var transfers = new TransferBatch(2); + + transfers.add(); + transfers.setId(UInt128.id()); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode(1); + transfers.setFlags(TransferFlags.NONE); + transfers.setAmount(100); + + client.createTransfers(transfers); + fail(); + + } catch (Throwable any) { + assertEquals(ClientClosedException.class, any.getClass()); + } + } + + /** + * Smoke test that concurrent close does not crash the JVM. + */ + @Test + public void testCloseConcurrent() throws Throwable { + final int threadCount = 16; + final var clients = IntStream.range(0, threadCount) + .mapToObj((index) -> new Client(clusterId, new String[] {server.getAddress()})) + .collect(Collectors.toList()); + final var threads = IntStream.range(0, threadCount).mapToObj((index) -> { + final var thread = new Thread(() -> { + final var client = clients.get(index); + for (int i = 0; i < 1000; i++) { + try { + client.createAccounts(generateAccounts(UInt128.id())); + } catch (IllegalStateException | InterruptedException e) { + break; + } + } + }); + thread.start(); + return thread; + }).collect(Collectors.toList()); + for (var client : clients) { + client.close(); + } + for (var thread : threads) { + thread.join(); + } + } + + /** + * This test asserts that async calls will not block. + */ + @Test + public void testAsyncTasks() throws Throwable { + final int TASKS_COUNT = 1_000_000; + + try (final var client = new Client(clusterId, new String[] {server.getAddress()})) { + + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var accounts = generateAccounts(account1Id, account2Id); + + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + final var tasks = new CompletableFuture[TASKS_COUNT]; + for (int i = 0; i < TASKS_COUNT; i += 2) { + + final var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setId(UInt128.id()); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode(1); + transfers.setAmount(100); + + // Starting two async requests of different operations. + tasks[i] = client.createTransfersAsync(transfers); + tasks[i + 1] = client.lookupAccountsAsync(new IdBatch(account1Id)); + } + + // Wait for all tasks. + CompletableFuture.allOf(tasks).join(); + + for (int i = 0; i < TASKS_COUNT; i++) { + if (i % 2 == 0) { + @SuppressWarnings("unchecked") + final var future = (CompletableFuture) tasks[i]; + final var result = future.get(); + assertEquals(1, result.getLength()); + assertTrue(createSuccess(result)); + } else { + @SuppressWarnings("unchecked") + final var future = (CompletableFuture) tasks[i]; + final var result = future.get(); + assertEquals(1, result.getLength()); + } + } + + // Asserting if all transfers were submitted correctly. + final var lookupAccounts = client.lookupAccounts(new IdBatch(account1Id, account2Id)); + assertEquals(2, lookupAccounts.getLength()); + + accounts.beforeFirst(); + + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.valueOf(100 * (TASKS_COUNT / 2)), + lookupAccounts.getCreditsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getDebitsPosted()); + + assertTrue(accounts.next()); + assertTrue(lookupAccounts.next()); + assertAccounts(accounts, lookupAccounts); + + assertEquals(BigInteger.valueOf(100 * (TASKS_COUNT / 2)), + lookupAccounts.getDebitsPosted()); + assertEquals(BigInteger.ZERO, lookupAccounts.getCreditsPosted()); + } + } + + /** + * This test asserts that the client can handle thread interruption. + */ + @Test + public void testConcurrentInterruptedTasks() throws Throwable { + final int TASKS_COUNT = 256; + final var barrier = new CountDownLatch(TASKS_COUNT); + final var zeroedId = UInt128.asBytes(0L); + + // Connect to an invalid cluster, so the calls never complete. + try (final var client = new Client(clusterId, new String[] {"0"})) { + final var tasks = new TransferTask[TASKS_COUNT]; + for (int i = 0; i < TASKS_COUNT; i++) { + // Starting multiple threads. + tasks[i] = new TransferTask(client, zeroedId, zeroedId, TransferFlags.NONE, barrier, + new CountDownLatch(0)); + tasks[i].start(); + } + + // Waits until all threads start. + barrier.await(); + + // Interrupt all threads. + for (final var task : tasks) { + task.interrupt(); + task.join(); + + assertTrue(task.getState() == Thread.State.TERMINATED); + assertTrue(task.result == null); + assertTrue(task.exception != null); + assertTrue(task.exception instanceof InterruptedException); + } + } + } + + @Test + public void testAccountTransfers() throws Throwable { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + + { + final var accounts = generateAccounts(account1Id, account2Id); + + // Enabling AccountFlags.HISTORY: + while (accounts.next()) { + accounts.setFlags(accounts.getFlags() | AccountFlags.HISTORY); + } + accounts.beforeFirst(); + + // Creating the accounts. + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + } + + { + // Creating a transfer. + final var transfers = new TransferBatch(10); + for (int i = 0; i < 10; i++) { + transfers.add(); + transfers.setId(UInt128.id()); + + // Swap the debit and credit accounts: + if (i % 2 == 0) { + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + } else { + transfers.setCreditAccountId(account2Id); + transfers.setDebitAccountId(account1Id); + } + + transfers.setLedger(720); + transfers.setCode(1); + transfers.setFlags(TransferFlags.NONE); + transfers.setAmount(100); + } + + final var transferResults = client.createTransfers(transfers); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + } + + { + // Querying transfers where: + // `debit_account_id=$account1Id OR credit_account_id=$account1Id + // ORDER BY timestamp ASC`. + final var filter = new AccountFilter(); + filter.setAccountId(account1Id); + filter.setTimestampMin(0); + filter.setTimestampMax(0); + filter.setLimit(254); + filter.setDebits(true); + filter.setCredits(true); + filter.setReversed(false); + final var accountTransfers = client.getAccountTransfers(filter); + final var accountBalances = client.getAccountBalances(filter); + assertTrue(accountTransfers.getLength() == 10); + assertTrue(accountBalances.getLength() == 10); + long timestamp = 0; + while (accountTransfers.next()) { + assertTrue(Long.compareUnsigned(accountTransfers.getTimestamp(), timestamp) > 0); + timestamp = accountTransfers.getTimestamp(); + + assertTrue(accountBalances.next()); + assertEquals(accountTransfers.getTimestamp(), accountBalances.getTimestamp()); + } + } + + { + // Querying transfers where: + // `debit_account_id=$account2Id OR credit_account_id=$account2Id + // ORDER BY timestamp DESC`. + final var filter = new AccountFilter(); + filter.setAccountId(account2Id); + filter.setTimestampMin(0); + filter.setTimestampMax(0); + filter.setLimit(254); + filter.setDebits(true); + filter.setCredits(true); + filter.setReversed(true); + final var accountTransfers = client.getAccountTransfers(filter); + final var accountBalances = client.getAccountBalances(filter); + + assertTrue(accountTransfers.getLength() == 10); + assertTrue(accountBalances.getLength() == 10); + long timestamp = Long.MIN_VALUE; // MIN_VALUE is the unsigned MAX_VALUE. + while (accountTransfers.next()) { + assertTrue(Long.compareUnsigned(accountTransfers.getTimestamp(), timestamp) < 0); + timestamp = accountTransfers.getTimestamp(); + + assertTrue(accountBalances.next()); + assertEquals(accountTransfers.getTimestamp(), accountBalances.getTimestamp()); + } + } + + { + // Querying transfers where: + // `debit_account_id=$account1Id + // ORDER BY timestamp ASC`. + final var filter = new AccountFilter(); + filter.setAccountId(account1Id); + filter.setTimestampMin(0); + filter.setTimestampMax(0); + filter.setLimit(254); + filter.setDebits(true); + filter.setCredits(false); + filter.setReversed(false); + final var accountTransfers = client.getAccountTransfers(filter); + final var accountBalances = client.getAccountBalances(filter); + + assertTrue(accountTransfers.getLength() == 5); + assertTrue(accountBalances.getLength() == 5); + long timestamp = 0; + while (accountTransfers.next()) { + assertTrue(Long.compareUnsigned(accountTransfers.getTimestamp(), timestamp) > 0); + timestamp = accountTransfers.getTimestamp(); + + assertTrue(accountBalances.next()); + assertEquals(accountTransfers.getTimestamp(), accountBalances.getTimestamp()); + } + } + + + { + // Querying transfers where: + // `credit_account_id=$account2Id + // ORDER BY timestamp DESC`. + final var filter = new AccountFilter(); + filter.setAccountId(account2Id); + filter.setTimestampMin(1); + filter.setTimestampMax(0); + filter.setLimit(254); + filter.setDebits(false); + filter.setCredits(true); + filter.setReversed(true); + final var accountTransfers = client.getAccountTransfers(filter); + final var accountBalances = client.getAccountBalances(filter); + + assertTrue(accountTransfers.getLength() == 5); + assertTrue(accountBalances.getLength() == 5); + + long timestamp = Long.MIN_VALUE; // MIN_VALUE is the unsigned MAX_VALUE. + while (accountTransfers.next()) { + assertTrue(Long.compareUnsigned(accountTransfers.getTimestamp(), timestamp) < 0); + timestamp = accountTransfers.getTimestamp(); + + assertTrue(accountBalances.next()); + assertEquals(accountTransfers.getTimestamp(), accountBalances.getTimestamp()); + } + } + + { + // Querying transfers where: + // `debit_account_id=$account1Id OR credit_account_id=$account1Id + // ORDER BY timestamp ASC LIMIT 5`. + final var filter = new AccountFilter(); + filter.setAccountId(account1Id); + filter.setTimestampMin(0); + filter.setTimestampMax(0); + filter.setLimit(5); + filter.setDebits(true); + filter.setCredits(true); + filter.setReversed(false); + + // First 5 items: + final var accountTransfers1 = client.getAccountTransfers(filter); + final var accountBalances1 = client.getAccountBalances(filter); + + assertTrue(accountTransfers1.getLength() == 5); + assertTrue(accountBalances1.getLength() == 5); + + long timestamp = 0; + while (accountTransfers1.next()) { + assertTrue(Long.compareUnsigned(accountTransfers1.getTimestamp(), timestamp) > 0); + timestamp = accountTransfers1.getTimestamp(); + + assertTrue(accountBalances1.next()); + assertEquals(accountTransfers1.getTimestamp(), accountBalances1.getTimestamp()); + } + + // Next 5 items from this timestamp: + filter.setTimestampMin(timestamp + 1); + final var accountTransfers2 = client.getAccountTransfers(filter); + final var accountBalances2 = client.getAccountBalances(filter); + + assertTrue(accountTransfers2.getLength() == 5); + assertTrue(accountBalances2.getLength() == 5); + + while (accountTransfers2.next()) { + assertTrue(Long.compareUnsigned(accountTransfers2.getTimestamp(), timestamp) > 0); + timestamp = accountTransfers2.getTimestamp(); + + assertTrue(accountBalances2.next()); + assertEquals(accountTransfers2.getTimestamp(), accountBalances2.getTimestamp()); + } + + // No more results after that timestamp: + filter.setTimestampMin(timestamp + 1); + final var accountTransfers3 = client.getAccountTransfers(filter); + final var accountBalances3 = client.getAccountBalances(filter); + + assertTrue(accountTransfers3.getLength() == 0); + assertTrue(accountBalances3.getLength() == 0); + } + + { + // Querying transfers where: + // `debit_account_id=$account2Id OR credit_account_id=$account2Id + // ORDER BY timestamp DESC LIMIT 5`. + final var filter = new AccountFilter(); + filter.setAccountId(account2Id); + filter.setTimestampMin(0); + filter.setTimestampMax(0); + filter.setLimit(5); + filter.setDebits(true); + filter.setCredits(true); + filter.setReversed(true); + + // First 5 items: + final var accountTransfers1 = client.getAccountTransfers(filter); + final var accountBalances1 = client.getAccountBalances(filter); + + assertTrue(accountTransfers1.getLength() == 5); + assertTrue(accountTransfers1.getLength() == 5); + + long timestamp = Long.MIN_VALUE; // MIN_VALUE is the unsigned MAX_VALUE. + while (accountTransfers1.next()) { + assertTrue(Long.compareUnsigned(accountTransfers1.getTimestamp(), timestamp) < 0); + timestamp = accountTransfers1.getTimestamp(); + + assertTrue(accountBalances1.next()); + assertEquals(accountTransfers1.getTimestamp(), accountBalances1.getTimestamp()); + } + + // Next 5 items from this timestamp: + filter.setTimestampMax(timestamp - 1); + final var accountTransfers2 = client.getAccountTransfers(filter); + final var accountBalances2 = client.getAccountBalances(filter); + + assertTrue(accountTransfers2.getLength() == 5); + assertTrue(accountBalances2.getLength() == 5); + + while (accountTransfers2.next()) { + assertTrue(Long.compareUnsigned(accountTransfers2.getTimestamp(), timestamp) < 0); + timestamp = accountTransfers2.getTimestamp(); + + assertTrue(accountBalances2.next()); + assertEquals(accountTransfers2.getTimestamp(), accountBalances2.getTimestamp()); + } + + // No more results before that timestamp: + filter.setTimestampMax(timestamp - 1); + final var accountTransfers3 = client.getAccountTransfers(filter); + final var accountBalances3 = client.getAccountBalances(filter); + + assertTrue(accountTransfers3.getLength() == 0); + assertTrue(accountBalances3.getLength() == 0); + } + + // For those tests it doesn't matter using the sync or async version. We use the sync + // version here for test coverage purposes, but there's no need to duplicate the tests. + + { + // Empty filter: + final var filter = new AccountFilter(); + assertTrue(client.getAccountTransfers(filter).getLength() == 0); + assertTrue(client.getAccountBalances(filter).getLength() == 0); + } + + { + // Invalid account: + final var filter = new AccountFilter(); + filter.setAccountId(0); + filter.setTimestampMin(0); + filter.setTimestampMax(0); + filter.setLimit(254); + filter.setDebits(true); + filter.setCredits(true); + filter.setReversed(false); + assertTrue(client.getAccountTransfers(filter).getLength() == 0); + assertTrue(client.getAccountBalances(filter).getLength() == 0); + } + + { + // Invalid timestamp min: + final var filter = new AccountFilter(); + filter.setAccountId(account2Id); + filter.setTimestampMin(-1L); // -1L == ulong max value + filter.setTimestampMax(0); + filter.setLimit(254); + filter.setDebits(true); + filter.setCredits(true); + filter.setReversed(false); + assertTrue(client.getAccountTransfers(filter).getLength() == 0); + assertTrue(client.getAccountBalances(filter).getLength() == 0); + } + + { + // Invalid timestamp max: + final var filter = new AccountFilter(); + filter.setAccountId(account2Id); + filter.setTimestampMin(0); + filter.setTimestampMax(-1L); // -1L == ulong max value + filter.setLimit(254); + filter.setDebits(true); + filter.setCredits(true); + filter.setReversed(false); + assertTrue(client.getAccountTransfers(filter).getLength() == 0); + assertTrue(client.getAccountBalances(filter).getLength() == 0); + } + + { + // Invalid timestamp min > max: + final var filter = new AccountFilter(); + filter.setAccountId(account2Id); + filter.setTimestampMin(2); + filter.setTimestampMax(1); + filter.setLimit(254); + filter.setDebits(true); + filter.setCredits(true); + filter.setReversed(false); + assertTrue(client.getAccountTransfers(filter).getLength() == 0); + assertTrue(client.getAccountBalances(filter).getLength() == 0); + } + + { + // Invalid negative timestamp_min: + final var filter = new AccountFilter(); + filter.setAccountId(account2Id); + filter.setTimestampMin(-123123130); + filter.setTimestampMax(0); + filter.setLimit(254); + filter.setDebits(true); + filter.setCredits(true); + filter.setReversed(false); + assertTrue(client.getAccountTransfers(filter).getLength() == 0); + assertTrue(client.getAccountBalances(filter).getLength() == 0); + } + + { + // Invalid negative timestamp_max: + final var filter = new AccountFilter(); + filter.setAccountId(account2Id); + filter.setTimestampMin(0); + filter.setTimestampMax(-123123130); + filter.setLimit(254); + filter.setDebits(true); + filter.setCredits(true); + filter.setReversed(false); + assertTrue(client.getAccountTransfers(filter).getLength() == 0); + assertTrue(client.getAccountBalances(filter).getLength() == 0); + } + + { + // Zero limit: + final var filter = new AccountFilter(); + filter.setAccountId(account2Id); + filter.setTimestampMin(0); + filter.setTimestampMax(0); + filter.setLimit(0); + filter.setDebits(true); + filter.setCredits(true); + filter.setReversed(false); + assertTrue(client.getAccountTransfers(filter).getLength() == 0); + assertTrue(client.getAccountBalances(filter).getLength() == 0); + } + + { + // TooMuchData + final var filter = new AccountFilter(); + filter.setAccountId(account2Id); + filter.setTimestampMin(0); + filter.setTimestampMax(0); + filter.setLimit(10_000); + filter.setDebits(true); + filter.setCredits(true); + filter.setReversed(false); + assertThrows(TooMuchDataException.class, () -> client.getAccountTransfers(filter)); + assertThrows(TooMuchDataException.class, () -> client.getAccountBalances(filter)); + + } + + { + // Zero flags: + final var filter = new AccountFilter(); + filter.setAccountId(account2Id); + filter.setTimestampMin(0); + filter.setTimestampMax(0); + filter.setLimit(0); + filter.setDebits(false); + filter.setCredits(false); + filter.setReversed(false); + assertTrue(client.getAccountTransfers(filter).getLength() == 0); + assertTrue(client.getAccountBalances(filter).getLength() == 0); + } + } + + @Test + public void testQueryAccounts() throws Throwable { + + { + // Creating accounts. + final var accounts = new AccountBatch(10); + for (int i = 0; i < 10; i++) { + accounts.add(); + accounts.setId(UInt128.id()); + + if (i % 2 == 0) { + accounts.setUserData128(1000L); + accounts.setUserData64(100L); + accounts.setUserData32(10); + } else { + accounts.setUserData128(2000L); + accounts.setUserData64(200L); + accounts.setUserData32(20); + } + + accounts.setCode(999); + accounts.setLedger(720); + accounts.setFlags(TransferFlags.NONE); + } + + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + } + + { + // Querying accounts where: + // `user_data_128=1000 AND user_data_64=100 AND user_data_32=10 + // AND code=999 AND ledger=720 ORDER BY timestamp ASC`. + final var filter = new QueryFilter(); + filter.setUserData128(1000L); + filter.setUserData64(100L); + filter.setUserData32(10); + filter.setCode(999); + filter.setLedger(720); + filter.setLimit(254); + filter.setReversed(false); + final AccountBatch query = client.queryAccounts(filter); + assertTrue(query.getLength() == 5); + long timestamp = 0; + while (query.next()) { + assertTrue(Long.compareUnsigned(query.getTimestamp(), timestamp) > 0); + timestamp = query.getTimestamp(); + + assertArrayEquals(filter.getUserData128(), query.getUserData128()); + assertTrue(filter.getUserData64() == query.getUserData64()); + assertTrue(filter.getUserData32() == query.getUserData32()); + assertTrue(filter.getLedger() == query.getLedger()); + assertTrue(filter.getCode() == query.getCode()); + } + } + + { + // Querying accounts where: + // `user_data_128=2000 AND user_data_64=200 AND user_data_32=20 + // AND code=999 AND ledger=720 ORDER BY timestamp DESC`. + final var filter = new QueryFilter(); + filter.setUserData128(2000L); + filter.setUserData64(200L); + filter.setUserData32(20); + filter.setCode(999); + filter.setLedger(720); + filter.setLimit(254); + filter.setReversed(true); + final AccountBatch query = client.queryAccounts(filter); + assertTrue(query.getLength() == 5); + long timestamp = Long.MIN_VALUE; // MIN_VALUE is the unsigned MAX_VALUE. + while (query.next()) { + assertTrue(Long.compareUnsigned(query.getTimestamp(), timestamp) < 0); + timestamp = query.getTimestamp(); + + assertArrayEquals(filter.getUserData128(), query.getUserData128()); + assertTrue(filter.getUserData64() == query.getUserData64()); + assertTrue(filter.getUserData32() == query.getUserData32()); + assertTrue(filter.getLedger() == query.getLedger()); + assertTrue(filter.getCode() == query.getCode()); + } + } + + { + // Querying accounts where: + // `code=999 ORDER BY timestamp ASC`. + final var filter = new QueryFilter(); + filter.setCode(999); + filter.setLimit(254); + filter.setReversed(false); + final AccountBatch query = client.queryAccounts(filter); + assertEquals(10, query.getLength()); + long timestamp = 0L; + while (query.next()) { + assertTrue(Long.compareUnsigned(query.getTimestamp(), timestamp) > 0); + timestamp = query.getTimestamp(); + + assertTrue(filter.getCode() == query.getCode()); + } + } + + { + // Querying accounts where: + // `code=999 ORDER BY timestamp DESC LIMIT 5`. + final var filter = new QueryFilter(); + filter.setCode(999); + filter.setLimit(5); + filter.setReversed(true); + + // First 5 items: + AccountBatch query = client.queryAccounts(filter); + assertTrue(query.getLength() == 5); + long timestamp = Long.MIN_VALUE; // MIN_VALUE is the unsigned MAX_VALUE. + while (query.next()) { + assertTrue(Long.compareUnsigned(query.getTimestamp(), timestamp) < 0); + timestamp = query.getTimestamp(); + + assertTrue(filter.getCode() == query.getCode()); + } + + // Next 5 items from this timestamp: + filter.setTimestampMax(timestamp - 1); + + query = client.queryAccounts(filter); + assertTrue(query.getLength() == 5); + + while (query.next()) { + assertTrue(Long.compareUnsigned(query.getTimestamp(), timestamp) < 0); + timestamp = query.getTimestamp(); + + assertTrue(filter.getCode() == query.getCode()); + } + + // No more results: + filter.setTimestampMax(timestamp - 1); + + query = client.queryAccounts(filter); + assertTrue(query.getLength() == 0); + } + + { + // Not found: + final var filter = new QueryFilter(); + filter.setUserData64(200); + filter.setUserData32(10); + filter.setLimit(254); + filter.setReversed(false); + assertTrue(client.queryAccounts(filter).getLength() == 0); + } + } + + + @Test + public void testQueryTransfers() throws Throwable { + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + + { + // Creating the accounts. + final var accounts = generateAccounts(account1Id, account2Id); + + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + } + + { + // Creating transfers. + final var transfers = new TransferBatch(10); + for (int i = 0; i < 10; i++) { + transfers.add(); + transfers.setId(UInt128.id()); + + if (i % 2 == 0) { + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setUserData128(1000L); + transfers.setUserData64(100L); + transfers.setUserData32(10); + } else { + transfers.setCreditAccountId(account2Id); + transfers.setDebitAccountId(account1Id); + transfers.setUserData128(2000L); + transfers.setUserData64(200L); + transfers.setUserData32(20); + } + + transfers.setCode(999); + transfers.setLedger(720); + transfers.setFlags(TransferFlags.NONE); + transfers.setAmount(100); + } + + final var transferResults = client.createTransfers(transfers); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + } + + { + // Querying transfers where: + // `user_data_128=1000 AND user_data_64=100 AND user_data_32=10 + // AND code=999 AND ledger=720 ORDER BY timestamp ASC`. + final var filter = new QueryFilter(); + filter.setUserData128(1000L); + filter.setUserData64(100L); + filter.setUserData32(10); + filter.setCode(999); + filter.setLedger(720); + filter.setLimit(254); + filter.setReversed(false); + final TransferBatch query = client.queryTransfers(filter); + assertTrue(query.getLength() == 5); + long timestamp = 0; + while (query.next()) { + assertTrue(Long.compareUnsigned(query.getTimestamp(), timestamp) > 0); + timestamp = query.getTimestamp(); + + assertArrayEquals(filter.getUserData128(), query.getUserData128()); + assertTrue(filter.getUserData64() == query.getUserData64()); + assertTrue(filter.getUserData32() == query.getUserData32()); + assertTrue(filter.getLedger() == query.getLedger()); + assertTrue(filter.getCode() == query.getCode()); + } + } + + { + // Querying transfers where: + // `user_data_128=2000 AND user_data_64=200 AND user_data_32=20 + // AND code=999 AND ledger=720 ORDER BY timestamp DESC`. + final var filter = new QueryFilter(); + filter.setUserData128(2000L); + filter.setUserData64(200L); + filter.setUserData32(20); + filter.setCode(999); + filter.setLedger(720); + filter.setLimit(254); + filter.setReversed(true); + final TransferBatch query = client.queryTransfers(filter); + assertTrue(query.getLength() == 5); + long timestamp = Long.MIN_VALUE; // MIN_VALUE is the unsigned MAX_VALUE. + while (query.next()) { + assertTrue(Long.compareUnsigned(query.getTimestamp(), timestamp) < 0); + timestamp = query.getTimestamp(); + + assertArrayEquals(filter.getUserData128(), query.getUserData128()); + assertTrue(filter.getUserData64() == query.getUserData64()); + assertTrue(filter.getUserData32() == query.getUserData32()); + assertTrue(filter.getLedger() == query.getLedger()); + assertTrue(filter.getCode() == query.getCode()); + } + } + + { + // Querying transfers where: + // `code=999 ORDER BY timestamp ASC`. + final var filter = new QueryFilter(); + filter.setCode(999); + filter.setLimit(254); + filter.setReversed(false); + final TransferBatch query = client.queryTransfers(filter); + assertTrue(query.getLength() == 10); + long timestamp = 0L; + while (query.next()) { + assertTrue(Long.compareUnsigned(query.getTimestamp(), timestamp) > 0); + timestamp = query.getTimestamp(); + + assertTrue(filter.getCode() == query.getCode()); + } + } + + { + // Querying transfers where: + // `code=999 ORDER BY timestamp DESC LIMIT 5`. + final var filter = new QueryFilter(); + filter.setCode(999); + filter.setLimit(5); + filter.setReversed(true); + + // First 5 items: + TransferBatch query = client.queryTransfers(filter); + assertTrue(query.getLength() == 5); + long timestamp = Long.MIN_VALUE; // MIN_VALUE is the unsigned MAX_VALUE. + while (query.next()) { + assertTrue(Long.compareUnsigned(query.getTimestamp(), timestamp) < 0); + timestamp = query.getTimestamp(); + + assertTrue(filter.getCode() == query.getCode()); + } + + // Next 5 items from this timestamp: + filter.setTimestampMax(timestamp - 1); + + query = client.queryTransfers(filter); + assertTrue(query.getLength() == 5); + + while (query.next()) { + assertTrue(Long.compareUnsigned(query.getTimestamp(), timestamp) < 0); + timestamp = query.getTimestamp(); + + assertTrue(filter.getCode() == query.getCode()); + } + + // No more results: + filter.setTimestampMax(timestamp - 1); + + query = client.queryTransfers(filter); + assertTrue(query.getLength() == 0); + } + + { + // Not found: + final var filter = new QueryFilter(); + filter.setUserData64(200); + filter.setUserData32(10); + filter.setLimit(254); + filter.setReversed(false); + assertTrue(client.queryTransfers(filter).getLength() == 0); + } + } + + @Test + public void testInvalidQueryFilter() throws Throwable { + // For those tests it doesn't matter using the sync or async version. We use the sync + // version here for test coverage purposes, but there's no need to duplicate the tests. + + { + // Empty filter with zero limit: + final var filter = new QueryFilter(); + assertTrue(client.queryAccounts(filter).getLength() == 0); + assertTrue(client.queryTransfers(filter).getLength() == 0); + } + + { + // TooMuchData + final var filter = new QueryFilter(); + filter.setLimit(10_000); + assertThrows(TooMuchDataException.class, () -> client.queryAccounts(filter)); + assertThrows(TooMuchDataException.class, () -> client.queryTransfers(filter)); + } + + { + // Invalid timestamp min: + final var filter = new QueryFilter(); + filter.setTimestampMin(-1L); // -1L == ulong max value + filter.setTimestampMax(0); + filter.setLimit(254); + assertTrue(client.queryAccounts(filter).getLength() == 0); + assertTrue(client.queryTransfers(filter).getLength() == 0); + } + + { + // Invalid timestamp max: + final var filter = new QueryFilter(); + filter.setTimestampMin(0); + filter.setTimestampMax(-1L); // -1L == ulong max value + filter.setLimit(254); + assertTrue(client.queryAccounts(filter).getLength() == 0); + assertTrue(client.queryTransfers(filter).getLength() == 0); + } + + { + // Invalid timestamp min > max: + final var filter = new QueryFilter(); + filter.setTimestampMin(-2); // -2L == ulong max - 1 + filter.setTimestampMax(1); + filter.setLimit(254); + assertTrue(client.queryAccounts(filter).getLength() == 0); + assertTrue(client.queryTransfers(filter).getLength() == 0); + } + + { + // Invalid negative timestamp_min: + final var filter = new QueryFilter(); + filter.setTimestampMin(-123123130); + filter.setTimestampMax(0); + filter.setLimit(254); + assertTrue(client.queryAccounts(filter).getLength() == 0); + assertTrue(client.queryTransfers(filter).getLength() == 0); + } + + { + // Invalid negative timestamp_max: + final var filter = new QueryFilter(); + filter.setTimestampMin(0); + filter.setTimestampMax(-123123130); + filter.setLimit(254); + assertTrue(client.queryAccounts(filter).getLength() == 0); + assertTrue(client.queryTransfers(filter).getLength() == 0); + } + + } + + @Test + public void testConcurrentQueries() throws Throwable { + final var filterCriteria = UInt128.id(); + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var transferId = UInt128.id(); + { + // Creating the accounts and transfers. + final var accounts = new AccountBatch(2); + accounts.add(); + accounts.setId(account1Id); + accounts.setUserData128(filterCriteria); + accounts.setCode(1); + accounts.setLedger(720); + accounts.setFlags(AccountFlags.HISTORY); + + accounts.add(); + accounts.setId(account2Id); + accounts.setCode(1); + accounts.setLedger(720); + + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + final var transfers = new TransferBatch(1); + transfers.add(); + transfers.setId(transferId); + transfers.setUserData128(filterCriteria); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode((short) 1); + transfers.setAmount(100); + + final var transferResults = client.createTransfers(transfers); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + } + + final class Tasks { + CompletableFuture queryAccounts; + CompletableFuture queryTransfers; + CompletableFuture getAccountTransfers; + CompletableFuture getAccountBalances; + + void validate() throws ExecutionException, InterruptedException { + CompletableFuture.allOf(queryAccounts, queryTransfers, getAccountBalances, + getAccountTransfers).join(); + + validateAccount(queryAccounts.get()); + validateTransfer(queryTransfers.get()); + validateTransfer(getAccountTransfers.get()); + validateAccountBalances(getAccountBalances.get()); + } + + private void validateAccount(AccountBatch results) { + assertEquals(1, results.getLength()); + assertTrue(results.next()); + assertArrayEquals(results.getId(), account1Id); + } + + private void validateTransfer(TransferBatch results) { + assertEquals(1, results.getLength()); + assertTrue(results.next()); + assertArrayEquals(results.getId(), transferId); + } + + private void validateAccountBalances(AccountBalanceBatch results) { + assertEquals(1, results.getLength()); + assertTrue(results.next()); + } + } + + // Limit=1 allows multiple queries to be batched together in the same request. + // Limit=8189 prevents batching because it would exceed the message size. + final var TASKS_COUNT = 100; + final var limits = new int[] {1, 8189}; + for (var limit : limits) { + var tasks = new Tasks[TASKS_COUNT]; + for (int i = 0; i < TASKS_COUNT; i++) { + tasks[i] = new Tasks(); + + final var queryFilter = new QueryFilter(); + queryFilter.setUserData128(filterCriteria); + queryFilter.setLimit(limit); + tasks[i].queryAccounts = client.queryAccountsAsync(queryFilter); + tasks[i].queryTransfers = client.queryTransfersAsync(queryFilter); + + final var accountFilter = new AccountFilter(); + accountFilter.setAccountId(account1Id); + accountFilter.setUserData128(filterCriteria); + accountFilter.setCredits(true); + accountFilter.setLimit(limit); + tasks[i].getAccountTransfers = client.getAccountTransfersAsync(accountFilter); + tasks[i].getAccountBalances = client.getAccountBalancesAsync(accountFilter); + } + + for (var task : tasks) { + task.validate(); + } + } + } + + @Test + public void testImportedFlag() throws Throwable { + // Gets the last timestamp recorded and waits for 10ms so the + // timestamp can be used as reference for importing past movements. + var timestamp = getTimestampLast(); + Thread.sleep(10); + + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var transferId = UInt128.id(); + + final var accounts = generateAccounts(account1Id, account2Id); + while (accounts.next()) { + accounts.setFlags(AccountFlags.IMPORTED); + accounts.setTimestamp(timestamp + accounts.getPosition() + 1); + } + accounts.beforeFirst(); + + // Creating the accounts. + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + // Creating a transfer. + final var transfers = new TransferBatch(1); + + transfers.add(); + transfers.setId(transferId); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode((short) 1); + transfers.setFlags(TransferFlags.IMPORTED); + transfers.setAmount(100); + transfers.setTimestamp(timestamp + accounts.getLength() + 1); + + final var transferResults = client.createTransfers(transfers); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + + // Looking up and asserting the transfer. + final var lookupTransfers = client.lookupTransfers(new IdBatch(transferId)); + assertEquals(1, lookupTransfers.getLength()); + + transfers.beforeFirst(); + + assertTrue(transfers.next()); + assertTrue(lookupTransfers.next()); + assertTransfers(transfers, lookupTransfers); + assertEquals(timestamp + accounts.getLength() + 1, lookupTransfers.getTimestamp()); + } + + @Test + public void testImportedFlagAsync() throws Throwable { + var timestamp = getTimestampLast(); + Thread.sleep(10); + + final var account1Id = UInt128.id(); + final var account2Id = UInt128.id(); + final var transferId = UInt128.id(); + + final var accounts = generateAccounts(account1Id, account2Id); + while (accounts.next()) { + accounts.setTimestamp(timestamp + accounts.getPosition() + 1); + accounts.setFlags(AccountFlags.IMPORTED); + } + accounts.beforeFirst(); + + // Creating the accounts. + CompletableFuture accountResultsFuture = + client.createAccountsAsync(accounts); + + final var accountResults = accountResultsFuture.get(); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + while (accountResults.next()) { + assertTrue(accountResults.getTimestamp() > 0); + assertEquals(CreateAccountStatus.Created, accountResults.getStatus()); + } + + // Creating a transfer. + final var transfers = new TransferBatch(1); + + transfers.add(); + transfers.setId(transferId); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode((short) 1); + transfers.setAmount(100); + transfers.setFlags(TransferFlags.IMPORTED); + transfers.setTimestamp(timestamp + accounts.getLength() + 1); + + CompletableFuture transferResultsFuture = + client.createTransfersAsync(transfers); + final var transferResults = transferResultsFuture.get(); + assertHeader(transfers, transferResults); + assertEquals(transfers.getLength(), transferResults.getLength()); + assertTrue(createSuccess(transferResults)); + + // Looking up and asserting the transfer. + CompletableFuture lookupTransfersFuture = + client.lookupTransfersAsync(new IdBatch(transferId)); + final var lookupTransfers = lookupTransfersFuture.get(); + assertEquals(1, lookupTransfers.getLength()); + + transfers.beforeFirst(); + + assertTrue(transfers.next()); + assertTrue(lookupTransfers.next()); + assertTransfers(transfers, lookupTransfers); + assertEquals(timestamp + accounts.getLength() + 1, lookupTransfers.getTimestamp()); + } + + /** + * Asserts that empty replies are not shared. + * https://github.com/tigerbeetle/tigerbeetle/pull/2495 + */ + @Test + public void testEmptyReply() throws Throwable { + final int TASKS_COUNT = 100; + + try (final var client2 = new Client(clusterId, new String[] {server.getAddress()})) { + for (int i = 0; i < TASKS_COUNT; i++) { + var request1 = client.lookupAccountsAsync(new IdBatch(UInt128.id())); + var request2 = client2.lookupAccountsAsync(new IdBatch(UInt128.id())); + + var reply1 = request1.get(); + var reply2 = request2.get(); + assertTrue(reply1.getLength() == 0); + assertTrue(reply1.isReadOnly()); + assertTrue(reply2.getLength() == 0); + assertTrue(reply2.isReadOnly()); + + assertNotSame(reply1, reply2); + } + } + } + + private long getTimestampLast() throws InterruptedException { + final var id = UInt128.id(); + final var accounts = generateAccounts(id); + + final var accountResults = client.createAccounts(accounts); + assertHeader(accounts, accountResults); + assertEquals(accounts.getLength(), accountResults.getLength()); + assertTrue(createSuccess(accountResults)); + + final var lookupAccounts = client.lookupAccounts(new IdBatch(id)); + assertEquals(1, lookupAccounts.getLength()); + + assertTrue(lookupAccounts.next()); + return lookupAccounts.getTimestamp(); + } + + private static void assertAccounts(AccountBatch account1, AccountBatch account2) { + assertArrayEquals(account1.getId(), account2.getId()); + assertArrayEquals(account1.getUserData128(), account2.getUserData128()); + assertEquals(account1.getUserData64(), account2.getUserData64()); + assertEquals(account1.getUserData32(), account2.getUserData32()); + assertEquals(account1.getLedger(), account2.getLedger()); + assertEquals(account1.getCode(), account2.getCode()); + assertEquals(account1.getFlags(), account2.getFlags()); + } + + private static void assertTransfers(TransferBatch transfer1, TransferBatch transfer2) { + assertArrayEquals(transfer1.getId(), transfer2.getId()); + assertArrayEquals(transfer1.getDebitAccountId(), transfer2.getDebitAccountId()); + assertArrayEquals(transfer1.getCreditAccountId(), transfer2.getCreditAccountId()); + assertEquals(transfer1.getAmount(), transfer2.getAmount()); + assertArrayEquals(transfer1.getPendingId(), transfer2.getPendingId()); + assertArrayEquals(transfer1.getUserData128(), transfer2.getUserData128()); + assertEquals(transfer1.getUserData64(), transfer2.getUserData64()); + assertEquals(transfer1.getUserData32(), transfer2.getUserData32()); + assertEquals(transfer1.getTimeout(), transfer2.getTimeout()); + assertEquals(transfer1.getLedger(), transfer2.getLedger()); + assertEquals(transfer1.getCode(), transfer2.getCode()); + assertEquals(transfer1.getFlags(), transfer2.getFlags()); + } + + private static void assertHeader(Batch request, Batch response) { + assertNull(request.getHeader()); + assertNotNull(response.getHeader()); + assertTrue(response.getHeader().getTimestamp() != 0L); + } + + boolean createSuccess(Batch batch) { + try { + var hasError = false; + while (batch.next()) { + if (batch instanceof CreateAccountResultBatch) { + final var accountResults = (CreateAccountResultBatch) batch; + assertTrue(accountResults.getTimestamp() > 0); + hasError |= accountResults.getStatus() != CreateAccountStatus.Created; + } else if (batch instanceof CreateTransferResultBatch) { + final var transferResults = (CreateTransferResultBatch) batch; + assertTrue(transferResults.getTimestamp() > 0); + hasError |= transferResults.getStatus() != CreateTransferStatus.Created; + } else + fail(); + } + return !hasError; + } finally { + batch.beforeFirst(); + } + } + + private static class TransferTask extends Thread { + public final Client client; + public CreateTransferResultBatch result; + public Throwable exception; + private byte[] account1Id; + private byte[] account2Id; + private int flags; + private CountDownLatch enterBarrier; + private CountDownLatch exitBarrier; + + public TransferTask(Client client, byte[] account1Id, byte[] account2Id, int flags, + CountDownLatch enterBarrier, CountDownLatch exitBarrier) { + this.client = client; + this.result = null; + this.exception = null; + this.account1Id = account1Id; + this.account2Id = account2Id; + this.flags = flags; + this.enterBarrier = enterBarrier; + this.exitBarrier = exitBarrier; + } + + @Override + public synchronized void run() { + + final var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setId(UInt128.asBytes(UUID.randomUUID())); + transfers.setCreditAccountId(account1Id); + transfers.setDebitAccountId(account2Id); + transfers.setLedger(720); + transfers.setCode(1); + transfers.setAmount(100); + transfers.setFlags(flags); + + try { + enterBarrier.countDown(); + enterBarrier.await(); + result = client.createTransfers(transfers); + } catch (Throwable any) { + exception = any; + } finally { + exitBarrier.countDown(); + } + } + } + + private static class Server implements AutoCloseable { + + public static final String TB_SERVER = "../../../zig-out/bin/tigerbeetle"; + + public final String tb_file; + private final Process process; + private String address; + + public Server(final String label) throws IOException, Exception, InterruptedException { + this.tb_file = "./0_0.tigerbeetle." + label; + + cleanUp(); + + String exe; + switch (JNILoader.OS.getOS()) { + case windows: + exe = TB_SERVER + ".exe"; + break; + default: + exe = TB_SERVER; + break; + } + + final var format = Runtime.getRuntime().exec(new String[] {exe, "format", "--cluster=0", + "--replica=0", "--replica-count=1", "--development", tb_file}); + if (format.waitFor() != 0) { + final var reader = + new BufferedReader(new InputStreamReader(format.getErrorStream())); + final var error = reader.lines().collect(Collectors.joining(". ")); + throw new Exception("Format failed. " + error); + } + + this.process = new ProcessBuilder() + .command(new String[] {exe, "start", "--addresses=0", "--development", tb_file}) + .redirectOutput(Redirect.PIPE).redirectError(Redirect.INHERIT).start(); + + final var stdout = process.getInputStream(); + try (final var reader = new BufferedReader(new InputStreamReader(stdout))) { + this.address = reader.readLine().trim(); + } + } + + public String getAddress() { + return address; + } + + @Override + public void close() throws Exception { + cleanUp(); + } + + private void cleanUp() throws Exception { + try { + if (process != null && process.isAlive()) { + process.destroy(); + } + + final var file = new File("./" + tb_file); + file.delete(); + } catch (Throwable any) { + throw new Exception("Cleanup has failed"); + } + } + } +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/JNILoaderTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/JNILoaderTest.java new file mode 100644 index 00000000..fba758ca --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/JNILoaderTest.java @@ -0,0 +1,38 @@ +package com.tigerbeetle; + +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +public class JNILoaderTest { + + @Test + public void testResourcePath() { + + assertEquals("/lib/x86_64-linux-gnu/libtb_jniclient.so", JNILoader + .getResourcesPath(JNILoader.Arch.x86_64, JNILoader.OS.linux, JNILoader.Abi.gnu)); + + assertEquals("/lib/aarch64-linux-gnu/libtb_jniclient.so", JNILoader + .getResourcesPath(JNILoader.Arch.aarch64, JNILoader.OS.linux, JNILoader.Abi.gnu)); + + assertEquals("/lib/x86_64-linux-musl/libtb_jniclient.so", JNILoader + .getResourcesPath(JNILoader.Arch.x86_64, JNILoader.OS.linux, JNILoader.Abi.musl)); + + assertEquals("/lib/aarch64-linux-musl/libtb_jniclient.so", JNILoader + .getResourcesPath(JNILoader.Arch.aarch64, JNILoader.OS.linux, JNILoader.Abi.musl)); + + assertEquals("/lib/x86_64-macos/libtb_jniclient.dylib", JNILoader + .getResourcesPath(JNILoader.Arch.x86_64, JNILoader.OS.macos, JNILoader.Abi.none)); + + assertEquals("/lib/aarch64-macos/libtb_jniclient.dylib", JNILoader + .getResourcesPath(JNILoader.Arch.aarch64, JNILoader.OS.macos, JNILoader.Abi.none)); + + assertEquals("/lib/x86_64-windows/tb_jniclient.dll", JNILoader + .getResourcesPath(JNILoader.Arch.x86_64, JNILoader.OS.windows, JNILoader.Abi.none)); + } + + @Test(expected = AssertionError.class) + public void testUnsupportedPlatform() { + JNILoader.getResourcesPath(JNILoader.Arch.aarch64, JNILoader.OS.windows, + JNILoader.Abi.none); + } +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/QueryFilterTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/QueryFilterTest.java new file mode 100644 index 00000000..2c95e93b --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/QueryFilterTest.java @@ -0,0 +1,165 @@ +package com.tigerbeetle; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import org.junit.Test; + +public class QueryFilterTest { + + @Test + public void testDefaultValues() { + final var queryFilter = new QueryFilter(); + assertEquals(0L, queryFilter.getUserData128(UInt128.LeastSignificant)); + assertEquals(0L, queryFilter.getUserData128(UInt128.MostSignificant)); + assertEquals(0L, queryFilter.getUserData64()); + assertEquals(0, queryFilter.getUserData32()); + assertEquals(0, queryFilter.getLedger()); + assertEquals(0, queryFilter.getCode()); + assertEquals(0L, queryFilter.getTimestampMin()); + assertEquals(0L, queryFilter.getTimestampMax()); + assertEquals(0, queryFilter.getLimit()); + assertEquals(false, queryFilter.getReversed()); + } + + @Test + public void testUserData128() { + final var queryFilter = new QueryFilter(); + + queryFilter.setUserData128(100, 200); + assertEquals(100L, queryFilter.getUserData128(UInt128.LeastSignificant)); + assertEquals(200L, queryFilter.getUserData128(UInt128.MostSignificant)); + } + + @Test + public void testUserData128Long() { + final var queryFilter = new QueryFilter(); + + queryFilter.setUserData128(100); + assertEquals(100L, queryFilter.getUserData128(UInt128.LeastSignificant)); + assertEquals(0L, queryFilter.getUserData128(UInt128.MostSignificant)); + } + + @Test + public void testUserData128AsBytes() { + final var queryFilter = new QueryFilter(); + + final var data = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}; + queryFilter.setUserData128(data); + assertArrayEquals(data, queryFilter.getUserData128()); + } + + @Test + public void testUserData128Null() { + final var queryFilter = new QueryFilter(); + + final byte[] data = null; + queryFilter.setUserData128(data); + + assertArrayEquals(new byte[16], queryFilter.getUserData128()); + } + + @Test(expected = IllegalArgumentException.class) + public void testUserData128Invalid() { + final var queryFilter = new QueryFilter(); + + final var data = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + queryFilter.setUserData128(data); + fail(); + } + + @Test + public void testUserData64() { + final var queryFilter = new QueryFilter(); + + queryFilter.setUserData64(100L); + assertEquals(100L, queryFilter.getUserData64()); + } + + @Test + public void testUserData32() { + final var queryFilter = new QueryFilter(); + + queryFilter.setUserData32(10); + assertEquals(10, queryFilter.getUserData32()); + } + + @Test + public void testLedger() { + final var queryFilter = new QueryFilter(); + + queryFilter.setLedger(99); + assertEquals(99, queryFilter.getLedger()); + } + + @Test + public void testCode() { + final var queryFilter = new QueryFilter(); + + queryFilter.setCode(1); + assertEquals(1, queryFilter.getCode()); + } + + @Test + public void testTimestampMin() { + final var queryFilter = new QueryFilter(); + + queryFilter.setTimestampMin(100L); + assertEquals(100, queryFilter.getTimestampMin()); + } + + @Test + public void testTimestampMax() { + final var queryFilter = new QueryFilter(); + + queryFilter.setTimestampMax(100L); + assertEquals(100, queryFilter.getTimestampMax()); + } + + @Test + public void testLimit() { + final var queryFilter = new QueryFilter(); + + queryFilter.setLimit(30); + assertEquals(30, queryFilter.getLimit()); + } + + @Test + public void testFlags() { + final var queryFilter = new QueryFilter(); + assertEquals(false, queryFilter.getReversed()); + queryFilter.setReversed(true); + assertEquals(true, queryFilter.getReversed()); + queryFilter.setReversed(false); + assertEquals(false, queryFilter.getReversed()); + } + + @Test + public void testReserved() { + final var queryFilter = new QueryFilterBatch(1); + queryFilter.add(); + + // Empty array: + final var bytes = new byte[6]; + assertArrayEquals(new byte[6], queryFilter.getReserved()); + + // Null == empty array: + queryFilter.setReserved(null); + + for (byte i = 0; i < 6; i++) { + bytes[i] = i; + } + queryFilter.setReserved(bytes); + assertArrayEquals(bytes, queryFilter.getReserved()); + } + + @Test(expected = IllegalArgumentException.class) + public void testReservedInvalid() { + final var queryFilter = new QueryFilterBatch(1); + queryFilter.add(); + queryFilter.setReserved(new byte[7]); + fail(); + } + +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/TransferFlagsTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/TransferFlagsTest.java new file mode 100644 index 00000000..5cd7dc4e --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/TransferFlagsTest.java @@ -0,0 +1,54 @@ +package com.tigerbeetle; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +public class TransferFlagsTest { + + @Test + public void testFlags() { + + assertTrue(TransferFlags.hasLinked(TransferFlags.LINKED)); + assertTrue(TransferFlags.hasLinked(TransferFlags.LINKED | TransferFlags.PENDING)); + assertTrue(TransferFlags + .hasLinked(TransferFlags.LINKED | TransferFlags.POST_PENDING_TRANSFER)); + + assertTrue(TransferFlags.hasPending(TransferFlags.PENDING)); + assertTrue(TransferFlags.hasPending(TransferFlags.PENDING | TransferFlags.LINKED)); + assertTrue(TransferFlags.hasPending(TransferFlags.LINKED | TransferFlags.PENDING)); + + assertTrue(TransferFlags.hasPostPendingTransfer(TransferFlags.POST_PENDING_TRANSFER)); + assertTrue(TransferFlags.hasPostPendingTransfer( + TransferFlags.POST_PENDING_TRANSFER | TransferFlags.LINKED)); + assertTrue(TransferFlags.hasPostPendingTransfer( + TransferFlags.POST_PENDING_TRANSFER | TransferFlags.PENDING)); + + assertTrue(TransferFlags.hasVoidPendingTransfer(TransferFlags.VOID_PENDING_TRANSFER)); + assertTrue(TransferFlags.hasVoidPendingTransfer( + TransferFlags.VOID_PENDING_TRANSFER | TransferFlags.LINKED)); + assertTrue(TransferFlags.hasVoidPendingTransfer( + TransferFlags.VOID_PENDING_TRANSFER | TransferFlags.PENDING)); + + assertFalse(TransferFlags.hasLinked(TransferFlags.NONE)); + assertFalse(TransferFlags.hasLinked(TransferFlags.POST_PENDING_TRANSFER)); + assertFalse(TransferFlags + .hasLinked(TransferFlags.PENDING | TransferFlags.POST_PENDING_TRANSFER)); + + assertFalse(TransferFlags.hasPending(TransferFlags.NONE)); + assertFalse(TransferFlags.hasPending(TransferFlags.POST_PENDING_TRANSFER)); + assertFalse(TransferFlags + .hasPending(TransferFlags.LINKED | TransferFlags.POST_PENDING_TRANSFER)); + + assertFalse(TransferFlags.hasVoidPendingTransfer(TransferFlags.NONE)); + assertFalse(TransferFlags.hasVoidPendingTransfer(TransferFlags.LINKED)); + assertFalse( + TransferFlags.hasVoidPendingTransfer(TransferFlags.LINKED | TransferFlags.PENDING)); + + assertFalse(TransferFlags.hasPostPendingTransfer(TransferFlags.NONE)); + assertFalse(TransferFlags.hasPostPendingTransfer(TransferFlags.LINKED)); + assertFalse(TransferFlags.hasPostPendingTransfer( + TransferFlags.LINKED | TransferFlags.VOID_PENDING_TRANSFER)); + + } +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/TransferTest.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/TransferTest.java new file mode 100644 index 00000000..e32e059a --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/TransferTest.java @@ -0,0 +1,433 @@ +package com.tigerbeetle; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.math.BigInteger; +import org.junit.Test; + +public class TransferTest { + + @Test + public void testDefaultValues() { + var transfers = new TransferBatch(1); + transfers.add(); + assertEquals(0L, transfers.getId(UInt128.LeastSignificant)); + assertEquals(0L, transfers.getId(UInt128.MostSignificant)); + assertEquals(0L, transfers.getDebitAccountId(UInt128.LeastSignificant)); + assertEquals(0L, transfers.getDebitAccountId(UInt128.MostSignificant)); + assertEquals(0L, transfers.getCreditAccountId(UInt128.LeastSignificant)); + assertEquals(0L, transfers.getCreditAccountId(UInt128.MostSignificant)); + assertEquals(BigInteger.ZERO, transfers.getAmount()); + assertEquals(0L, transfers.getPendingId(UInt128.LeastSignificant)); + assertEquals(0L, transfers.getPendingId(UInt128.MostSignificant)); + assertEquals(0L, transfers.getUserData128(UInt128.LeastSignificant)); + assertEquals(0L, transfers.getUserData128(UInt128.MostSignificant)); + assertEquals(0L, transfers.getUserData64()); + assertEquals(0, transfers.getUserData32()); + assertEquals(0, transfers.getTimeout()); + assertEquals(0, transfers.getLedger()); + assertEquals(0, transfers.getCode()); + assertEquals(TransferFlags.NONE, transfers.getFlags()); + assertEquals(0L, transfers.getTimestamp()); + } + + @Test + public void testId() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setId(100, 200); + assertEquals(100L, transfers.getId(UInt128.LeastSignificant)); + assertEquals(200L, transfers.getId(UInt128.MostSignificant)); + } + + @Test + public void testIdLong() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setId(100); + assertEquals(100L, transfers.getId(UInt128.LeastSignificant)); + assertEquals(0L, transfers.getId(UInt128.MostSignificant)); + } + + @Test + public void testIdAsBytes() { + var transfers = new TransferBatch(1); + transfers.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}; + transfers.setId(id); + assertArrayEquals(id, transfers.getId()); + } + + @Test + public void testIdNull() { + var transfers = new TransferBatch(1); + transfers.add(); + + byte[] id = null; + transfers.setId(id); + + assertArrayEquals(new byte[16], transfers.getId()); + } + + @Test(expected = IllegalArgumentException.class) + public void testIdInvalid() { + var transfers = new TransferBatch(1); + transfers.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + transfers.setId(id); + fail(); + } + + @Test + public void testDebitAccountId() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setDebitAccountId(100, 200); + assertEquals(100L, transfers.getDebitAccountId(UInt128.LeastSignificant)); + assertEquals(200L, transfers.getDebitAccountId(UInt128.MostSignificant)); + } + + @Test + public void testDebitAccountIdLong() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setDebitAccountId(100); + assertEquals(100L, transfers.getDebitAccountId(UInt128.LeastSignificant)); + assertEquals(0L, transfers.getDebitAccountId(UInt128.MostSignificant)); + } + + @Test + public void testDebitAccountIdAsBytes() { + var transfers = new TransferBatch(1); + transfers.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}; + transfers.setDebitAccountId(id); + assertArrayEquals(id, transfers.getDebitAccountId()); + } + + @Test + public void testDebitAccountIdNull() { + var transfers = new TransferBatch(1); + transfers.add(); + + byte[] debitAccountId = null; + transfers.setDebitAccountId(debitAccountId); + + assertArrayEquals(new byte[16], transfers.getDebitAccountId()); + } + + @Test(expected = IllegalArgumentException.class) + public void testDebitAccountIdInvalid() { + var transfers = new TransferBatch(1); + transfers.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + transfers.setDebitAccountId(id); + fail(); + } + + @Test + public void testCreditAccountId() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setCreditAccountId(100, 200); + assertEquals(100L, transfers.getCreditAccountId(UInt128.LeastSignificant)); + assertEquals(200L, transfers.getCreditAccountId(UInt128.MostSignificant)); + } + + @Test + public void testCreditAccountIdLong() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setCreditAccountId(100); + assertEquals(100L, transfers.getCreditAccountId(UInt128.LeastSignificant)); + assertEquals(0L, transfers.getCreditAccountId(UInt128.MostSignificant)); + } + + @Test + public void testCreditAccountIdAsBytes() { + var transfers = new TransferBatch(1); + transfers.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}; + transfers.setCreditAccountId(id); + assertArrayEquals(id, transfers.getCreditAccountId()); + } + + @Test + public void testCreditAccountIdNull() { + var transfers = new TransferBatch(1); + transfers.add(); + + byte[] creditAccountId = null; + transfers.setCreditAccountId(creditAccountId); + + assertArrayEquals(new byte[16], transfers.getCreditAccountId()); + } + + @Test(expected = IllegalArgumentException.class) + public void testCreditAccountIdInvalid() { + var transfers = new TransferBatch(1); + transfers.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + transfers.setCreditAccountId(id); + fail(); + } + + @Test + public void testAmount() { + var transfers = new TransferBatch(1); + transfers.add(); + + final var value = new BigInteger("123456789012345678901234567890"); + transfers.setAmount(value); + assertEquals(value, transfers.getAmount()); + } + + @Test + public void testAmountLong() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setAmount(999); + assertEquals(BigInteger.valueOf(999), transfers.getAmount()); + + transfers.setAmount(999, 1); + assertEquals(UInt128.asBigInteger(999, 1), transfers.getAmount()); + assertEquals(999L, transfers.getAmount(UInt128.LeastSignificant)); + assertEquals(1L, transfers.getAmount(UInt128.MostSignificant)); + } + + @Test + public void testAmountMax() { + assertEquals(BigInteger.TWO.pow(128).subtract(BigInteger.ONE), TransferBatch.AMOUNT_MAX); + } + + @Test + public void testPendingId() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setPendingId(100, 200); + assertEquals(100L, transfers.getPendingId(UInt128.LeastSignificant)); + assertEquals(200L, transfers.getPendingId(UInt128.MostSignificant)); + } + + @Test + public void testPendingIdLong() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setPendingId(100); + assertEquals(100L, transfers.getPendingId(UInt128.LeastSignificant)); + assertEquals(0L, transfers.getPendingId(UInt128.MostSignificant)); + } + + @Test + public void testPendingIdAsBytes() { + var transfers = new TransferBatch(1); + transfers.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}; + transfers.setPendingId(id); + assertArrayEquals(id, transfers.getPendingId()); + } + + @Test + public void testPendingIdNull() { + var transfers = new TransferBatch(1); + transfers.add(); + + byte[] pendingId = null; + transfers.setPendingId(pendingId); + assertEquals(0L, transfers.getPendingId(UInt128.LeastSignificant)); + assertEquals(0L, transfers.getPendingId(UInt128.MostSignificant)); + } + + @Test(expected = IllegalArgumentException.class) + public void testPendingIdInvalid() { + var transfers = new TransferBatch(1); + transfers.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + transfers.setPendingId(id); + fail(); + } + + @Test + public void testUserData128Long() { + var transfers = new TransferBatch(2); + transfers.add(); + + transfers.setUserData128(100); + assertEquals(100L, transfers.getUserData128(UInt128.LeastSignificant)); + assertEquals(0L, transfers.getUserData128(UInt128.MostSignificant)); + } + + @Test + public void testUserData128() { + var transfers = new TransferBatch(2); + transfers.add(); + + transfers.setUserData128(100, 200); + assertEquals(100L, transfers.getUserData128(UInt128.LeastSignificant)); + assertEquals(200L, transfers.getUserData128(UInt128.MostSignificant)); + } + + @Test + public void testUserData128AsBytes() { + var transfers = new TransferBatch(1); + transfers.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}; + transfers.setUserData128(id); + assertArrayEquals(id, transfers.getUserData128()); + } + + @Test + public void testUserData128Null() { + var transfers = new TransferBatch(1); + transfers.add(); + + byte[] userData = null; + transfers.setUserData128(userData); + assertEquals(0L, transfers.getUserData128(UInt128.LeastSignificant)); + assertEquals(0L, transfers.getUserData128(UInt128.MostSignificant)); + } + + @Test(expected = IllegalArgumentException.class) + public void testUserData128Invalid() { + var transfers = new TransferBatch(1); + transfers.add(); + + var id = new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + transfers.setUserData128(id); + fail(); + } + + @Test + public void testUserData64() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setUserData64(1000L); + assertEquals(1000L, transfers.getUserData64()); + } + + @Test + public void testUserData32() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setUserData32(100); + assertEquals(100, transfers.getUserData32()); + } + + @Test + public void testTimeout() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setTimeout(9999); + assertEquals(9999, transfers.getTimeout()); + } + + @Test + public void testLedger() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setLedger(200); + assertEquals(200, transfers.getLedger()); + } + + @Test + public void testCode() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setCode(30); + assertEquals(30, transfers.getCode()); + } + + @Test(expected = IllegalArgumentException.class) + public void testCodeNegative() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setCode(-1); + } + + @Test(expected = IllegalArgumentException.class) + public void testCodeOverflow() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setCode(Integer.MAX_VALUE); + } + + @Test + public void testCodeUnsigned() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setCode(53000); + assertEquals(53000, transfers.getCode()); + } + + @Test + public void testFlags() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setFlags(TransferFlags.POST_PENDING_TRANSFER | TransferFlags.LINKED); + assertEquals((int) (TransferFlags.POST_PENDING_TRANSFER | TransferFlags.LINKED), + transfers.getFlags()); + } + + @Test(expected = IllegalArgumentException.class) + public void testFlagsNegative() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setFlags(-1); + } + + @Test(expected = IllegalArgumentException.class) + public void testFlagsOverflow() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setFlags(Integer.MAX_VALUE); + } + + @Test + public void testFlagsUnsigned() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setFlags(60000); + assertEquals(60000, transfers.getFlags()); + } + + @Test + public void testTimestamp() { + var transfers = new TransferBatch(1); + transfers.add(); + + transfers.setTimestamp(1234567890); + assertEquals((long) 1234567890, transfers.getTimestamp()); + } +} diff --git a/ocam/src/clients/java/src/test/java/com/tigerbeetle/UInt128Test.java b/ocam/src/clients/java/src/test/java/com/tigerbeetle/UInt128Test.java new file mode 100644 index 00000000..52813c79 --- /dev/null +++ b/ocam/src/clients/java/src/test/java/com/tigerbeetle/UInt128Test.java @@ -0,0 +1,327 @@ +package com.tigerbeetle; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.junit.Assert.assertSame; +import java.util.concurrent.CountDownLatch; +import java.math.BigInteger; +import java.util.UUID; +import org.junit.Test; + +public class UInt128Test { + + // bytes representing a pair of longs (100, 1000): + final static byte[] bytes = new byte[] {100, 0, 0, 0, 0, 0, 0, 0, -24, 3, 0, 0, 0, 0, 0, 0}; + + /// Consistency of U128 across Zig and the language clients. + /// It must be kept in sync with all platforms. + @Test + public void consistencyTest() { + // Decimal representation: + final long upper = Long.parseUnsignedLong("11647051514084770242"); + final long lower = Long.parseUnsignedLong("15119395263638463974"); + final var u128 = UInt128.asBigInteger(lower, upper); + assertEquals("214850178493633095719753766415838275046", u128.toString()); + + // Binary representation: + final byte[] binary = new byte[] {(byte) 0xe6, (byte) 0xe5, (byte) 0xe4, (byte) 0xe3, + (byte) 0xe2, (byte) 0xe1, (byte) 0xd2, (byte) 0xd1, (byte) 0xc2, (byte) 0xc1, + (byte) 0xb2, (byte) 0xb1, (byte) 0xa4, (byte) 0xa3, (byte) 0xa2, (byte) 0xa1}; + final var bytes = UInt128.asBytes(lower, upper); + assertArrayEquals(bytes, UInt128.asBytes(u128)); + assertArrayEquals(binary, bytes); + + // UUID representation: + final var guid = UUID.fromString("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6"); + assertEquals(guid, UInt128.asUUID(bytes)); + assertArrayEquals(bytes, UInt128.asBytes(guid)); + assertEquals(u128, UInt128.asBigInteger(UInt128.asBytes(guid))); + } + + @Test(expected = NullPointerException.class) + public void testAsLongNull() { + + @SuppressWarnings("unused") + var nop = UInt128.asLong(null, UInt128.LeastSignificant); + fail(); + } + + @Test(expected = IllegalArgumentException.class) + public void testAsLongInvalid() { + + byte[] bytes = new byte[] {1, 2, 3, 4, 5, 6}; + @SuppressWarnings("unused") + var nop = UInt128.asLong(bytes, UInt128.LeastSignificant); + fail(); + } + + @Test + public void testAsLong() { + var ls = UInt128.asLong(bytes, UInt128.LeastSignificant); + var ms = UInt128.asLong(bytes, UInt128.MostSignificant); + assertEquals(100L, ls); + assertEquals(1000L, ms); + + byte[] reverse = UInt128.asBytes(100, 1000); + assertArrayEquals(bytes, reverse); + } + + @Test + public void testAsBytes() { + + byte[] reverse = UInt128.asBytes(100, 1000); + assertArrayEquals(bytes, reverse); + } + + @Test + public void testAsBytesFromSingleLong() { + + byte[] singleLong = UInt128.asBytes(100L); + + assertEquals(100L, UInt128.asLong(singleLong, UInt128.LeastSignificant)); + assertEquals(0L, UInt128.asLong(singleLong, UInt128.MostSignificant)); + } + + @Test + public void testAsBytesBigInteger() { + { + final var expected = UInt128.asBytes(0); + final var actual = UInt128.asBytes(BigInteger.ZERO); + assertArrayEquals(expected, actual); + } + + { + final var expected = UInt128.asBytes(1); + final var actual = UInt128.asBytes(BigInteger.ONE); + assertArrayEquals(expected, actual); + } + + { + final var expected = UInt128.asBytes(2); + final var actual = UInt128.asBytes(BigInteger.TWO); + assertArrayEquals(expected, actual); + } + + { + final var expected = UInt128.asBytes(10); + final var actual = UInt128.asBytes(BigInteger.TEN); + assertArrayEquals(expected, actual); + } + + { + final var expected = UInt128.asBytes(-1L, -1L); + final var actual = UInt128.asBytes(UInt128.INT_MAX); + assertArrayEquals(expected, actual); + } + } + + @Test(expected = IllegalArgumentException.class) + public void testAsBytesBigIntegerNegative() { + BigInteger bigint = BigInteger.valueOf(-1); + @SuppressWarnings("unused") + var nop = UInt128.asBytes(bigint); + fail(); + } + + @Test(expected = IllegalArgumentException.class) + public void testAsBytesBigIntegerExceedU128() { + final var bigint = new BigInteger("9999999999999999999999999999999999999999", 10); + @SuppressWarnings("unused") + var nop = UInt128.asBytes(bigint); + fail(); + } + + @Test(expected = NullPointerException.class) + public void testAsBytesBigIntegerNull() { + BigInteger bigint = null; + @SuppressWarnings("unused") + var nop = UInt128.asBytes(bigint); + fail(); + } + + @Test + public void testAsBytesZero() { + + byte[] reverse = UInt128.asBytes(0, 0); + assertArrayEquals(new byte[16], reverse); + } + + @Test(expected = NullPointerException.class) + public void testAsBytesUUIDNull() { + + UUID uuid = null; + @SuppressWarnings("unused") + var nop = UInt128.asBytes(uuid); + fail(); + } + + @Test + public void testAsBytesUUID() { + var uuid = new UUID(1000, 100); + byte[] reverse = UInt128.asBytes(uuid); + assertArrayEquals(bytes, reverse); + } + + @Test + public void testAsUUID() { + var uuid = UInt128.asUUID(bytes); + assertEquals(new UUID(1000, 100), uuid); + } + + @Test(expected = NullPointerException.class) + public void testAsUUIDNull() { + + @SuppressWarnings("unused") + var nop = UInt128.asUUID(null); + fail(); + } + + @Test(expected = IllegalArgumentException.class) + public void testAsUUIDInvalid() { + + byte[] bytes = new byte[] {1, 2, 3, 4, 5, 6}; + @SuppressWarnings("unused") + var nop = UInt128.asUUID(bytes); + fail(); + } + + @Test + public void testAsBigIntegerFromLong() { + var bigint = UInt128.asBigInteger(100, 1000); + + // Bigint representation of a pair of longs (100, 1000) + var reverse = BigInteger.valueOf(100) + .add(BigInteger.valueOf(1000).multiply(BigInteger.ONE.shiftLeft(64))); + + assertEquals(reverse, bigint); + assertArrayEquals(bytes, UInt128.asBytes(bigint)); + } + + @Test + public void testAsBigIntegerFromBytes() { + var bigint = UInt128.asBigInteger(bytes); + + assertEquals(UInt128.asBigInteger(100, 1000), bigint); + assertArrayEquals(bytes, UInt128.asBytes(bigint)); + } + + @Test(expected = NullPointerException.class) + public void testAsBigIntegerNull() { + + @SuppressWarnings("unused") + var nop = UInt128.asBigInteger(null); + fail(); + } + + @Test(expected = IllegalArgumentException.class) + public void testAsBigIntegerInvalid() { + + byte[] bytes = new byte[] {1, 2, 3, 4, 5, 6}; + @SuppressWarnings("unused") + var nop = UInt128.asBigInteger(bytes); + fail(); + } + + @Test + public void testAsBigIntegerUnsigned() { + + // @bitCast(u128, [2]i64{ -100, -1000 }) == 340282366920938445035077277795926146972 + final var expected = new BigInteger("340282366920938445035077277795926146972"); + + assertEquals(expected, UInt128.asBigInteger(-100, -1000)); + assertArrayEquals(UInt128.asBytes(expected), UInt128.asBytes(-100, -1000)); + } + + @Test + public void testAsBigIntegerZero() { + assertSame(BigInteger.ZERO, UInt128.asBigInteger(0, 0)); + assertSame(BigInteger.ZERO, UInt128.asBigInteger(new byte[16])); + assertArrayEquals(new byte[16], UInt128.asBytes(BigInteger.ZERO)); + } + + @Test + public void testLittleEndian() { + // Reference test: + // https://github.com/microsoft/windows-rs/blob/f19edde93252381b7a1789bf856a3a67df23f6db/crates/tests/core/tests/guid.rs#L25-L31 + final var bytes_expected = new byte[] {(byte) 0x8f, (byte) 0x8c, (byte) 0x2b, (byte) 0x05, + (byte) 0xa4, (byte) 0x53, (byte) 0x3a, (byte) 0x82, (byte) 0xfe, (byte) 0x42, + (byte) 0xd2, (byte) 0xc0, (byte) 0xef, (byte) 0x3f, (byte) 0xd6, (byte) 0x1f,}; + + final var u128 = UInt128.asBytes(Long.parseUnsignedLong("823a53a4052b8c8f", 16), + Long.parseUnsignedLong("1fd63fefc0d242fe", 16)); + final var decimal_expected = new BigInteger("1fd63fefc0d242fe823a53a4052b8c8f", 16); + final var uuid_expected = UUID.fromString("1fd63fef-c0d2-42fe-823a-53a4052b8c8f"); + + assertEquals(decimal_expected, UInt128.asBigInteger(u128)); + assertEquals(decimal_expected, UInt128.asBigInteger(bytes_expected)); + assertEquals(decimal_expected, UInt128.asBigInteger(UInt128.asBytes(uuid_expected))); + + assertEquals(uuid_expected, UInt128.asUUID(u128)); + assertEquals(uuid_expected, UInt128.asUUID(bytes_expected)); + assertEquals(uuid_expected, UInt128.asUUID(UInt128.asBytes(decimal_expected))); + + assertArrayEquals(bytes_expected, u128); + assertArrayEquals(bytes_expected, UInt128.asBytes(uuid_expected)); + assertArrayEquals(bytes_expected, UInt128.asBytes(decimal_expected)); + + } + + @Test + public void testID() throws Exception { + { + // Generate IDs, sleeping for ~1ms occasionally to test intra-millisecond monotonicity. + var idA = UInt128.asBigInteger(UInt128.id()); + for (int i = 0; i < 1_000_000; i++) { + if (i % 10_000 == 0) { + Thread.sleep(1); + } + + var idB = UInt128.asBigInteger(UInt128.id()); + assertTrue(idB.compareTo(idA) > 0); + + // Use the generated ID as the new reference point for the next loop. + idA = idB; + } + } + + final var threadExceptions = new Exception[100]; + final var latchStart = new CountDownLatch(threadExceptions.length); + final var latchFinish = new CountDownLatch(threadExceptions.length); + + for (int i = 0; i < threadExceptions.length; i++) { + final int threadIndex = i; + new Thread(() -> { + try { + // Wait for all threads to spawn before starting. + latchStart.countDown(); + latchStart.await(); + + // Same as serial test above, but with smaller bounds. + var idA = UInt128.asBigInteger(UInt128.id()); + for (int j = 0; j < 10_000; j++) { + if (j % 1000 == 0) { + Thread.sleep(1); + } + + var idB = UInt128.asBigInteger(UInt128.id()); + assertTrue(idB.compareTo(idA) > 0); + idA = idB; + } + + } catch (Exception e) { + threadExceptions[threadIndex] = e; // Propagate exceptions to main thread. + } finally { + latchFinish.countDown(); // Make sure to unblock the main thread. + } + }).start(); + } + + latchFinish.await(); + for (var exception : threadExceptions) { + if (exception != null) + throw exception; + } + } +} diff --git a/ocam/src/clients/java/src/test/java/module-info.test b/ocam/src/clients/java/src/test/java/module-info.test new file mode 100644 index 00000000..7f21d2e5 --- /dev/null +++ b/ocam/src/clients/java/src/test/java/module-info.test @@ -0,0 +1,3 @@ +open module com.tigerbeetle { + requires junit; +} \ No newline at end of file diff --git a/ocam/src/clients/node/.gitignore b/ocam/src/clients/node/.gitignore new file mode 100644 index 00000000..1f09a70f --- /dev/null +++ b/ocam/src/clients/node/.gitignore @@ -0,0 +1,15 @@ +build/ +dist/ +node_modules/ +tigerbeetle +zig/ +zig-cache/ + +# TigerBeetle +*.tigerbeetle +benchmark.log +tigerbeetle_test.log +benchmark/ +test/ +tigerbeetle-node-*.tgz +node.lib diff --git a/ocam/src/clients/node/LICENSE b/ocam/src/clients/node/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/ocam/src/clients/node/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 [yyyy] [name of copyright owner] + + 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/ocam/src/clients/node/README.md b/ocam/src/clients/node/README.md new file mode 100644 index 00000000..f1903bf4 --- /dev/null +++ b/ocam/src/clients/node/README.md @@ -0,0 +1,832 @@ + +# tigerbeetle-node + +The TigerBeetle client for Node.js. + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Node.js >= `18` + +## Setup + +First, create a directory for your project and `cd` into the directory. + +Then, install the TigerBeetle client: + +```console +npm install --save-exact tigerbeetle-node +``` + +Now, create `main.js` and copy this into it: + +```javascript +const { createClient, id } = require("tigerbeetle-node"); +const process = require("process"); + +console.log("Import ok!"); +``` + +Finally, build and run: + +```console +node main.js +``` + +Now that all prerequisites and dependencies are correctly set +up, let's dig into using TigerBeetle. + +## Sample projects + +This document is primarily a reference guide to +the client. Below are various sample projects demonstrating +features of TigerBeetle. + +* [Basic](/src/clients/node/samples/basic/): Create two accounts and transfer an amount between them. +* [Two-Phase Transfer](/src/clients/node/samples/two-phase/): Create two accounts and start a pending transfer between +them, then post the transfer. +* [Many Two-Phase Transfers](/src/clients/node/samples/two-phase-many/): Create two accounts and start a number of pending transfers +between them, posting and voiding alternating transfers. + +### Sidenote: `BigInt` +TigerBeetle uses 64-bit integers for many fields while JavaScript's +builtin `Number` maximum value is `2^53-1`. The `n` suffix in JavaScript +means the value is a `BigInt`. This is useful for literal numbers. If +you already have a `Number` variable though, you can call the `BigInt` +constructor to get a `BigInt` from it. For example, `1n` is the same as +`BigInt(1)`. + +## Creating a Client + +A client is created with a cluster ID and replica +addresses for all replicas in the cluster. The cluster +ID and replica addresses are both chosen by the system that +starts the TigerBeetle cluster. + +Clients are thread-safe and a single instance should be shared +between multiple concurrent tasks. This allows events to be +[automatically batched](https://docs.tigerbeetle.com/coding/requests/#batching-events). + +Multiple clients are useful when connecting to more than +one TigerBeetle cluster. + +In this example the cluster ID is `0` and there is one +replica. The address is read from the `TB_ADDRESS` +environment variable and defaults to port `3000`. + +```javascript +const client = createClient({ + cluster_id: 0n, + replica_addresses: [process.env.TB_ADDRESS || "3000"], +}); +``` + +The following are valid addresses: +* `3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1:3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1` (interpreted as `127.0.0.1:3001`, `3001` is the default port) + +## Creating Accounts + +See details for account fields in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account). + +```javascript +const account = { + id: id(), // TigerBeetle time-based ID. + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 718, + flags: 0, + timestamp: 0n, +}; + +const account_results = await client.createAccounts([account]); +// Results handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +### Account Flags + +The account flags value is a bitfield. See details for +these flags in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account#flags). + +To toggle behavior for an account, combine enum values stored in the +`AccountFlags` object (in TypeScript it is an actual enum) with +bitwise-or: + +* `AccountFlags.linked` +* `AccountFlags.debits_must_not_exceed_credits` +* `AccountFlags.credits_must_not_exceed_credits` +* `AccountFlags.history` + + +For example, to link two accounts where the first account +additionally has the `debits_must_not_exceed_credits` constraint: + +```javascript +const account0 = { + id: 100n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + timestamp: 0n, + flags: AccountFlags.linked | AccountFlags.debits_must_not_exceed_credits, +}; +const account1 = { + id: 101n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + timestamp: 0n, + flags: AccountFlags.history, +}; + +const account_results = await client.createAccounts([account0, account1]); +// Results handling omitted. +``` + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each account in the request batch: +- Successfully created accounts with the status + [`created`](https://docs.tigerbeetle.com/reference/requests/create_accounts#created) + return the timestamp assigned to the `Account` object. +- Already existing accounts with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists) + return the timestamp of the original existing object. +- Failed accounts return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_accounts reference](https://docs.tigerbeetle.com/reference/requests/create_accounts#status). + +```javascript +const account0 = { + id: 102n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + timestamp: 0n, + flags: 0, +}; +const account1 = { + id: 103n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + timestamp: 0n, + flags: 0, +}; +const account2 = { + id: 104n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + timestamp: 0n, + flags: 0, +}; + +const account_results = await client.createAccounts([account0, account1, account2]); +for (let i = 0; i < account_results.length; i++) { + switch (account_results[i].status) { + case CreateAccountStatus.created: + console.error(`Batch account at ${i} successfully created with timestamp ${account_results[i].timestamp}.`); + break; + case CreateAccountStatus.exists: + console.error(`Batch account at ${i} already exists with timestamp ${account_results[i].timestamp}.`); + break; + default: + console.error(`Batch account at ${i} failed to create: ${account_results[i].status}`); + break; + } +} +``` + +To handle errors you can either 1) exactly match error codes returned +from `client.createAccounts` with enum values in the +`CreateAccountError` object, or you can 2) look up the error code in +the `CreateAccountError` object for a human-readable string. + +## Account Lookup + +Account lookup is batched, like account creation. Pass +in all IDs to fetch. The account for each matched ID is returned. + +If no account matches an ID, no object is returned for +that account. So the order of accounts in the response is +not necessarily the same as the order of IDs in the +request. You can refer to the ID field in the response to +distinguish accounts. + +```javascript +const accounts = await client.lookupAccounts([100n, 101n]); +``` + +## Create Transfers + +This creates a journal entry between two accounts. + +See details for transfer fields in the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer). + +```javascript +const transfers = [{ + id: id(), // TigerBeetle time-based ID. + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: 0, + timestamp: 0n, +}]; + +const transfers_results = await client.createTransfers(transfers); +// Results handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each transfer in the request batch: +- Successfully created transfers with the result + [`created`](https://docs.tigerbeetle.com/reference/requests/create_transfers#created) + return the timestamp assigned to the `Transfer` object. +- Already existing transfers with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists) + return the timestamp of the original existing object. +- Failed transfers return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_transfers reference](https://docs.tigerbeetle.com/reference/requests/create_transfers#status). + +```javascript +const transfers = [{ + id: 1n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: 0, + timestamp: 0n, +}, +{ + id: 2n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: 0, + timestamp: 0n, +}, +{ + id: 3n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: 0, + timestamp: 0n, +}]; + +const transfers_results = await client.createTransfers(batch); +for (let i = 0; i < transfers_results.length; i++) { + switch (transfers_results[i].status) { + case CreateTransferStatus.created: + console.error(`Batch transfer at ${i} successfully created with timestamp ${transfers_results[i].timestamp}.`); + break; + case CreateTransferStatus.exists: + console.error(`Batch transfer at ${i} already exists with timestamp ${transfers_results[i].timestamp}.`); + break; + default: + console.error(`Batch transfer at ${i} failed to create: ${transfers_results[i].status}`); + break; + } +} +``` + +To handle errors you can either 1) exactly match error codes returned +from `client.createTransfers` with enum values in the +`CreateTransferError` object, or you can 2) look up the error code in +the `CreateTransferError` object for a human-readable string. + +## Batching + +TigerBeetle performance is maximized when you batch +API requests. + +A client instance shared across multiple threads/tasks can automatically +batch concurrent requests, but the application must still send as many events +as possible in a single call. + +For example, if you insert 1 million transfers sequentially, one at a time, +the insert rate will be a *fraction* of the potential, because the client will +wait for a reply between each one. +Instead, **always batch as much as you can**. + +The maximum batch size is set in the TigerBeetle server. The default is 8189. + +```javascript +const batch = []; // Array of transfer to create. +const BATCH_SIZE = 8189; +for (let i = 0; i < batch.length; i += BATCH_SIZE) { + const transfers_results = await client.createTransfers( + batch.slice(i, Math.min(batch.length, BATCH_SIZE)), + ); + // Results handling omitted. +} +``` + +### Queues and Workers + +If you are making requests to TigerBeetle from workers +pulling jobs from a queue, you can batch requests to +TigerBeetle by having the worker act on multiple jobs from +the queue at once rather than one at a time. i.e. pulling +multiple jobs from the queue rather than just one. + +## Transfer Flags + +The transfer `flags` value is a bitfield. See details for these flags in +the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer#flags). + +To toggle behavior for a transfer, combine enum values stored in the +`TransferFlags` object (in TypeScript it is an actual enum) with +bitwise-or: + +* `TransferFlags.linked` +* `TransferFlags.pending` +* `TransferFlags.post_pending_transfer` +* `TransferFlags.void_pending_transfer` + +For example, to link `transfer0` and `transfer1`: + +```javascript +const transfer0 = { + id: 4n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: TransferFlags.linked, + timestamp: 0n, +}; +const transfer1 = { + id: 5n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: 0, + timestamp: 0n, +}; + +// Create the transfer +const transfers_results = await client.createTransfers([transfer0, transfer1]); +// Results handling omitted. +``` + +### Two-Phase Transfers + +Two-phase transfers are supported natively by toggling the appropriate +flag. TigerBeetle will then adjust the `credits_pending` and +`debits_pending` fields of the appropriate accounts. A corresponding +post pending transfer then needs to be sent to post or void the +transfer. + +#### Post a Pending Transfer + +With `flags` set to `post_pending_transfer`, +TigerBeetle will post the transfer. TigerBeetle will atomically roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and apply them to the `debits_posted` and +`credits_posted` balances. + +```javascript +const transfer0 = { + id: 6n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: TransferFlags.pending, + timestamp: 0n, +}; + +let transfers_results = await client.createTransfers([transfer0]); +// Results handling omitted. + +const transfer1 = { + id: 7n, + debit_account_id: 102n, + credit_account_id: 103n, + // Post the entire pending amount. + amount: amount_max, + pending_id: 6n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: TransferFlags.post_pending_transfer, + timestamp: 0n, +}; + +transfers_results = await client.createTransfers([transfer1]); +// Results handling omitted. +``` + +#### Void a Pending Transfer + +In contrast, with `flags` set to `void_pending_transfer`, +TigerBeetle will void the transfer. TigerBeetle will roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and **not** apply them to the `debits_posted` and +`credits_posted` balances. + +```javascript +const transfer0 = { + id: 8n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: TransferFlags.pending, + timestamp: 0n, +}; + +let transfers_results = await client.createTransfers([transfer0]); +// Results handling omitted. + +const transfer1 = { + id: 9n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 0n, + pending_id: 8n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: TransferFlags.void_pending_transfer, + timestamp: 0n, +}; + +transfers_results = await client.createTransfers([transfer1]); +// Results handling omitted. +``` + +## Transfer Lookup + +NOTE: While transfer lookup exists, it is not a flexible query API. We +are developing query APIs and there will be new methods for querying +transfers in the future. + +Transfer lookup is batched, like transfer creation. Pass in all `id`s to +fetch, and matched transfers are returned. + +If no transfer matches an `id`, no object is returned for that +transfer. So the order of transfers in the response is not necessarily +the same as the order of `id`s in the request. You can refer to the +`id` field in the response to distinguish transfers. + +```javascript +const transfers = await client.lookupTransfers([1n, 2n]); +``` + +## Get Account Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the transfers involving a given account, allowing basic filter and pagination +capabilities. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```javascript +const filter = { + account_id: 2n, + user_data_128: 0n, // No filter by UserData. + user_data_64: 0n, + user_data_32: 0, + code: 0, // No filter by Code. + timestamp_min: 0n, // No filter by Timestamp. + timestamp_max: 0n, // No filter by Timestamp. + limit: 10, // Limit to ten transfers at most. + flags: AccountFilterFlags.debits | // Include transfer from the debit side. + AccountFilterFlags.credits | // Include transfer from the credit side. + AccountFilterFlags.reversed, // Sort by timestamp in reverse-chronological order. +}; + +const account_transfers = await client.getAccountTransfers(filter); +``` + +## Get Account Balances + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the point-in-time balances of a given account, allowing basic filter and +pagination capabilities. + +Only accounts created with the flag +[`history`](https://docs.tigerbeetle.com/reference/account#flagshistory) set retain +[historical balances](https://docs.tigerbeetle.com/reference/requests/get_account_balances). + +The balances in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```javascript +const filter = { + account_id: 2n, + user_data_128: 0n, // No filter by UserData. + user_data_64: 0n, + user_data_32: 0, + code: 0, // No filter by Code. + timestamp_min: 0n, // No filter by Timestamp. + timestamp_max: 0n, // No filter by Timestamp. + limit: 10, // Limit to ten balances at most. + flags: AccountFilterFlags.debits | // Include transfer from the debit side. + AccountFilterFlags.credits | // Include transfer from the credit side. + AccountFilterFlags.reversed, // Sort by timestamp in reverse-chronological order. +}; + +const account_balances = await client.getAccountBalances(filter); +``` + +## Query Accounts + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query accounts by the intersection of some fields and by timestamp range. + +The accounts in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```javascript +const query_filter = { + user_data_128: 1000n, // Filter by UserData. + user_data_64: 100n, + user_data_32: 10, + code: 1, // Filter by Code. + ledger: 0, // No filter by Ledger. + timestamp_min: 0n, // No filter by Timestamp. + timestamp_max: 0n, // No filter by Timestamp. + limit: 10, // Limit to ten accounts at most. + flags: QueryFilterFlags.reversed, // Sort by timestamp in reverse-chronological order. +}; + +const query_accounts = await client.queryAccounts(query_filter); +``` + +## Query Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query transfers by the intersection of some fields and by timestamp range. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```javascript +const query_filter = { + user_data_128: 1000n, // Filter by UserData. + user_data_64: 100n, + user_data_32: 10, + code: 1, // Filter by Code. + ledger: 0, // No filter by Ledger. + timestamp_min: 0n, // No filter by Timestamp. + timestamp_max: 0n, // No filter by Timestamp. + limit: 10, // Limit to ten transfers at most. + flags: QueryFilterFlags.reversed, // Sort by timestamp in reverse-chronological order. +}; + +const query_transfers = await client.queryTransfers(query_filter); +``` + +## Linked Events + +When the `linked` flag is specified for an account when creating accounts or +a transfer when creating transfers, it links that event with the next event in the +batch, to create a chain of events, of arbitrary length, which all +succeed or fail together. The tail of a chain is denoted by the first +event without this flag. The last event in a batch may therefore never +have the `linked` flag set as this would leave a chain +open-ended. Multiple chains or individual events may coexist within a +batch to succeed or fail independently. + +Events within a chain are executed within order, or are rolled back on +error, so that the effect of each event in the chain is visible to the +next, and so that the chain is either visible or invisible as a unit +to subsequent events after the chain. The event that was the first to +break the chain will have a unique error result. Other events in the +chain will have their error result set to `linked_event_failed`. + +```javascript +const batch = []; // Array of transfer to create. +let linkedFlag = 0; +linkedFlag |= TransferFlags.linked; + +// An individual transfer (successful): +batch.push({ id: 1n /* , ... */ }); + +// A chain of 4 transfers (the last transfer in the chain closes the chain with linked=false): +batch.push({ id: 2n, /* ..., */ flags: linkedFlag }); // Commit/rollback. +batch.push({ id: 3n, /* ..., */ flags: linkedFlag }); // Commit/rollback. +batch.push({ id: 2n, /* ..., */ flags: linkedFlag }); // Fail with exists +batch.push({ id: 4n, /* ..., */ flags: 0 }); // Fail without committing. + +// An individual transfer (successful): +// This should not see any effect from the failed chain above. +batch.push({ id: 2n, /* ..., */ flags: 0 }); + +// A chain of 2 transfers (the first transfer fails the chain): +batch.push({ id: 2n, /* ..., */ flags: linkedFlag }); +batch.push({ id: 3n, /* ..., */ flags: 0 }); + +// A chain of 2 transfers (successful): +batch.push({ id: 3n, /* ..., */ flags: linkedFlag }); +batch.push({ id: 4n, /* ..., */ flags: 0 }); + +const transfers_results = await client.createTransfers(batch); +// Results handling omitted. +``` + +## Imported Events + +When the `imported` flag is specified for an account when creating accounts or +a transfer when creating transfers, it allows importing historical events with +a user-defined timestamp. + +The entire batch of events must be set with the flag `imported`. + +It's recommended to submit the whole batch as a `linked` chain of events, ensuring that +if any event fails, none of them are committed, preserving the last timestamp unchanged. +This approach gives the application a chance to correct failed imported events, re-submitting +the batch again with the same user-defined timestamps. + +```javascript +// External source of time. +let historical_timestamp = 0n +// Events loaded from an external source. +const historical_accounts = []; // Loaded from an external source. +const historical_transfers = []; // Loaded from an external source. + +// First, load and import all accounts with their timestamps from the historical source. +const accounts = []; +for (let index = 0; i < historical_accounts.length; i++) { + let account = historical_accounts[i]; + // Set a unique and strictly increasing timestamp. + historical_timestamp += 1; + account.timestamp = historical_timestamp; + // Set the account as `imported`. + account.flags = AccountFlags.imported; + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + if (index < historical_accounts.length - 1) { + account.flags |= AccountFlags.linked; + } + + accounts.push(account); +} + +const account_results = await client.createAccounts(accounts); +// Results handling omitted. + +// Then, load and import all transfers with their timestamps from the historical source. +const transfers = []; +for (let index = 0; i < historical_transfers.length; i++) { + let transfer = historical_transfers[i]; + // Set a unique and strictly increasing timestamp. + historical_timestamp += 1; + transfer.timestamp = historical_timestamp; + // Set the account as `imported`. + transfer.flags = TransferFlags.imported; + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + if (index < historical_transfers.length - 1) { + transfer.flags |= TransferFlags.linked; + } + + transfers.push(transfer); +} + +const transfers_results = await client.createTransfers(transfers); +// Results handling omitted. + +// Since it is a linked chain, in case of any error the entire batch is rolled back and can be retried +// with the same historical timestamps without regressing the cluster timestamp. +``` + +## Timeouts And Cancellation + +The Client retries indefinitely and doesn't impose any per-request timeout. Cancellation is +provided as a mechanism, and the specific cancellation policy is left to the +application. A Client instance can be closed at any time. On close, all in-flight +requests are canceled and return an error to the caller. Even if an error is returned, +a request might still be processed by the TigerBeetle server. +[Reliable transaction submission](https://docs.tigerbeetle.com/coding/reliable-transaction-submission/) +explains how to make transfers retry-proof using IDs for end-to-end idempotency. diff --git a/ocam/src/clients/node/ci.zig b/ocam/src/clients/node/ci.zig new file mode 100644 index 00000000..96be18a2 --- /dev/null +++ b/ocam/src/clients/node/ci.zig @@ -0,0 +1,209 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const log = std.log; +const assert = std.debug.assert; + +const Shell = @import("stdx").Shell; +const TmpTigerBeetle = @import("../../testing/tmp_tigerbeetle.zig"); + +pub fn tests(shell: *Shell, gpa: std.mem.Allocator) !void { + assert(shell.file_exists("package.json")); + + try shell.exec_zig("build clients:node -Drelease", .{}); + + // Integration tests. + + // We need to build the tigerbeetle-node library manually for samples/testers to work. + try shell.exec("npm install", .{}); + + for ([_][]const u8{ "test", "benchmark" }) |tester| { + log.info("testing {s}s", .{tester}); + + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + try shell.exec("node ./dist/{tester}", .{ .tester = tester }); + } + + inline for ([_][]const u8{ "basic", "two-phase", "two-phase-many", "walkthrough" }) |sample| { + log.info("testing sample '{s}'", .{sample}); + + try shell.pushd("./samples/" ++ sample); + defer shell.popd(); + + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + try shell.exec("npm install", .{}); + try shell.exec("node main.js", .{}); + } + + // Container smoke tests. + if (builtin.target.os.tag == .linux) { + try shell.exec("npm pack --quiet", .{}); + + for ([_][]const u8{ "node:18", "node:18-alpine" }) |image| { + log.info("testing docker image: '{s}'", .{image}); + + try shell.exec( + \\docker run + \\--security-opt seccomp=unconfined + \\--volume ./:/host + \\{image} + \\sh + \\-c {script} + , .{ + .image = image, + .script = + \\set -ex + \\mkdir test-project && cd test-project + \\npm install /host/tigerbeetle-node-*.tgz + \\node -e 'require("tigerbeetle-node"); console.log("SUCCESS!")' + , + }); + } + } +} + +pub fn validate_release_package(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, +}) !void { + const tmp_dir = try shell.create_tmp_dir(); + defer shell.cwd.deleteTree(tmp_dir) catch {}; + + const published_url = try shell.fmt( + "https://registry.npmjs.org/tigerbeetle-node/-/tigerbeetle-node-{s}.tgz", + .{options.release}, + ); + const published_tgz = try shell.fmt("{s}/published.tgz", .{tmp_dir}); + const published_dir = try shell.fmt("{s}/published", .{tmp_dir}); + try shell.cwd.makePath(published_dir); + + log.info("validating node package {s}", .{published_url}); + + // Multiple attempts in case of network errors. + const attempts_max = 5; + for (0..attempts_max) |attempt_index| { + // TODO(zig): use `shell.http_get` when there's no TLS error. + const result = try shell.exec_raw( + "wget --quiet --output-document={out} {url}", + .{ + .out = published_tgz, + .url = published_url, + }, + ); + switch (result.term) { + .Exited => |code| if (code == 0) break, + else => {}, + } + + const attempt = attempt_index + 1; + log.warn("node package download failed. Attempt={}", .{attempt}); + if (attempt == attempts_max) { + return error.DownloadAttemptsExceeded; + } + // Wait before next attempt. + std.Thread.sleep(5 * std.time.ns_per_s); + } + + const local_path_relative = try shell.fmt( + "zig-out/dist/node/tigerbeetle-node-{s}.tgz", + .{options.release}, + ); + const local_tgz = try shell.cwd.realpathAlloc( + shell.arena.allocator(), + local_path_relative, + ); + const local_dir = try shell.fmt("{s}/local", .{tmp_dir}); + try shell.cwd.makePath(local_dir); + + // npm repacks the tarball on publish with a different compression, so we extract and diff. + try shell.exec( + "tar --extract --file {tgz} --directory {dir}", + .{ .tgz = published_tgz, .dir = published_dir }, + ); + try shell.exec( + "tar --extract --file {tgz} --directory {dir}", + .{ .tgz = local_tgz, .dir = local_dir }, + ); + try shell.exec( + "diff --recursive {published} {local}", + .{ .published = published_dir, .local = local_dir }, + ); + + const package_json_path = try shell.fmt("{s}/package/package.json", .{published_dir}); + try validate_npm_metadata(shell, gpa, package_json_path); +} + +pub fn validate_release_sample(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, + tigerbeetle: []const u8, +}) !void { + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + .prebuilt = options.tigerbeetle, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + + try shell.exec("npm install tigerbeetle-node@{release}", .{ + .release = options.release, + }); + + try Shell.copy_path( + shell.cwd, + "src/clients/node/samples/basic/main.js", + shell.cwd, + "main.js", + ); + try shell.exec("node main.js", .{}); +} + +pub fn release_published_latest(shell: *Shell) ![]const u8 { + return try shell.exec_stdout("npm view tigerbeetle-node version", .{}); +} + +fn validate_npm_metadata( + shell: *Shell, + gpa: std.mem.Allocator, + package_json_path: []const u8, +) !void { + const package_json = try shell.cwd.readFileAlloc(gpa, package_json_path, 4 * 1024); + defer gpa.free(package_json); + + const parsed = try std.json.parseFromSlice( + std.json.Value, + shell.arena.allocator(), + package_json, + .{}, + ); + defer parsed.deinit(); + + // Verify no runtime dependencies were introduced. + if (parsed.value.object.get("dependencies")) |deps| { + if (deps == .object and deps.object.count() > 0) { + std.debug.panic("unexpected dependencies in tigerbeetle-node", .{}); + } + } + + // Verify no install-time hooks that could run arbitrary code. + if (parsed.value.object.get("scripts")) |scripts| { + if (scripts == .object) { + for ([_][]const u8{ "install", "preinstall", "postinstall" }) |hook| { + if (scripts.object.get(hook) != null) { + std.debug.panic("unexpected '{s}' script in tigerbeetle-node", .{hook}); + } + } + } + } +} diff --git a/ocam/src/clients/node/docs.zig b/ocam/src/clients/node/docs.zig new file mode 100644 index 00000000..bc213f9e --- /dev/null +++ b/ocam/src/clients/node/docs.zig @@ -0,0 +1,75 @@ +const Docs = @import("../docs_types.zig").Docs; + +pub const NodeDocs = Docs{ + .directory = "node", + + .markdown_name = "javascript", + .extension = "js", + .proper_name = "Node.js", + + .test_source_path = "", + + .name = "tigerbeetle-node", + .description = + \\The TigerBeetle client for Node.js. + , + .prerequisites = + \\* Node.js >= `18` + , + + .project_file = "", + .project_file_name = "", + .test_file_name = "main", + + .install_commands = "npm install --save-exact tigerbeetle-node", + .run_commands = "node main.js", + + .examples = + \\### Sidenote: `BigInt` + \\TigerBeetle uses 64-bit integers for many fields while JavaScript's + \\builtin `Number` maximum value is `2^53-1`. The `n` suffix in JavaScript + \\means the value is a `BigInt`. This is useful for literal numbers. If + \\you already have a `Number` variable though, you can call the `BigInt` + \\constructor to get a `BigInt` from it. For example, `1n` is the same as + \\`BigInt(1)`. + , + + .client_object_documentation = "", + .create_accounts_documentation = "", + .account_flags_documentation = + \\To toggle behavior for an account, combine enum values stored in the + \\`AccountFlags` object (in TypeScript it is an actual enum) with + \\bitwise-or: + \\ + \\* `AccountFlags.linked` + \\* `AccountFlags.debits_must_not_exceed_credits` + \\* `AccountFlags.credits_must_not_exceed_credits` + \\* `AccountFlags.history` + \\ + , + + .create_accounts_errors_documentation = + \\To handle errors you can either 1) exactly match error codes returned + \\from `client.createAccounts` with enum values in the + \\`CreateAccountError` object, or you can 2) look up the error code in + \\the `CreateAccountError` object for a human-readable string. + , + .create_transfers_documentation = "", + .create_transfers_errors_documentation = + \\To handle errors you can either 1) exactly match error codes returned + \\from `client.createTransfers` with enum values in the + \\`CreateTransferError` object, or you can 2) look up the error code in + \\the `CreateTransferError` object for a human-readable string. + , + + .transfer_flags_documentation = + \\To toggle behavior for a transfer, combine enum values stored in the + \\`TransferFlags` object (in TypeScript it is an actual enum) with + \\bitwise-or: + \\ + \\* `TransferFlags.linked` + \\* `TransferFlags.pending` + \\* `TransferFlags.post_pending_transfer` + \\* `TransferFlags.void_pending_transfer` + , +}; diff --git a/ocam/src/clients/node/node.zig b/ocam/src/clients/node/node.zig new file mode 100644 index 00000000..7018ab23 --- /dev/null +++ b/ocam/src/clients/node/node.zig @@ -0,0 +1,591 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const c = @import("src/c.zig").c; +const translate = @import("src/translate.zig"); +const tb = vsr.tigerbeetle; +const tb_client = vsr.tb_client; + +const Operation = tb.Operation; +const Account = tb.Account; +const Transfer = tb.Transfer; +const AccountFilter = tb.AccountFilter; +const AccountBalance = tb.AccountBalance; +const QueryFilter = tb.QueryFilter; + +const vsr = @import("vsr"); +const constants = vsr.constants; +const stdx = vsr.stdx; + +const global_allocator = std.heap.c_allocator; + +pub const std_options: std.Options = .{ + .log_level = .debug, + .logFn = tb_client.exports.Logging.application_logger, +}; + +// Cached value for JS (null). +var napi_null: c.napi_value = undefined; + +// Cached `RequestError` constructor. +var request_error_ctor_ref: c.napi_ref = undefined; + +// Must be kept in sync with `index.ts`. +const ErrorCodes = enum { + ERR_CLIENT_CLOSED, + ERR_CLIENT_EVICTED, + ERR_CLIENT_RELEASE_TOO_LOW, + ERR_CLIENT_RELEASE_TOO_HIGH, + ERR_TOO_MUCH_DATA, +}; + +/// N-API will call this constructor automatically to register the module. +export fn napi_register_module_v1(env: c.napi_env, exports: c.napi_value) c.napi_value { + napi_null = translate.capture_null(env) catch return null; + + translate.register_function(env, exports, "init", init) catch return null; + translate.register_function(env, exports, "deinit", deinit) catch return null; + translate.register_function(env, exports, "submit", submit) catch return null; + return exports; +} + +// Add-on code + +fn init(env: c.napi_env, info: c.napi_callback_info) callconv(.c) c.napi_value { + const args = translate.extract_args(env, info, .{ + .count = 1, + .function = "init", + }) catch return null; + + const cluster = translate.u128_from_object(env, args[0], "cluster_id") catch return null; + const addresses = translate.slice_from_object( + env, + args[0], + "replica_addresses", + ) catch return null; + const request_error_ctor = translate.get_object_property( + env, + args[0], + "request_error_class", + ) catch return null; + assert(request_error_ctor != null); + + translate.create_reference( + env, + request_error_ctor, + // Weak reference: type and symbol references are never + // GCed and cannot be deleted during cleanup. + .weak, + &request_error_ctor_ref, + "Cannot reference the object constructor", + ) catch return null; + assert(request_error_ctor_ref != null); + + return create(env, cluster, addresses) catch null; +} + +fn deinit(env: c.napi_env, info: c.napi_callback_info) callconv(.c) c.napi_value { + const args = translate.extract_args(env, info, .{ + .count = 1, + .function = "deinit", + }) catch return null; + + destroy(env, args[0]) catch {}; + return null; +} + +fn submit(env: c.napi_env, info: c.napi_callback_info) callconv(.c) c.napi_value { + const args = translate.extract_args(env, info, .{ + .count = 4, + .function = "submit", + }) catch return null; + + const operation_int = translate.u32_from_value(env, args[1], "operation") catch return null; + if (!@as(vsr.Operation, @enumFromInt(operation_int)).valid(Operation)) { + translate.throw(env, .{ + .message = "Unknown operation.", + }) catch return null; + } + + var is_array: bool = undefined; + if (c.napi_is_array(env, args[2], &is_array) != c.napi_ok) { + translate.throw(env, .{ + .message = "Failed to check array argument type.", + }) catch return null; + } + if (!is_array) { + translate.throw(env, .{ + .message = "Array argument must be an [object Array].", + }) catch return null; + } + + var callback_type: c.napi_valuetype = undefined; + if (c.napi_typeof(env, args[3], &callback_type) != c.napi_ok) { + translate.throw(env, .{ + .message = "Failed to check callback argument type.", + }) catch return null; + } + if (callback_type != c.napi_function) { + translate.throw(env, .{ + .message = "Callback argument must be a Function.", + }) catch return null; + } + + request( + env, + args[0], // tb_client + @enumFromInt(@as(u8, @intCast(operation_int))), + args[2], // request array + args[3], // callback + ) catch {}; + return null; +} + +// tb_client Logic + +fn create( + env: c.napi_env, + cluster_id: u128, + addresses: []const u8, +) !c.napi_value { + var tsfn_name: c.napi_value = undefined; + if (c.napi_create_string_utf8(env, "tb_client", c.NAPI_AUTO_LENGTH, &tsfn_name) != c.napi_ok) { + return translate.throw( + env, + .{ .message = "Failed to create resource name for thread-safe function." }, + ); + } + + var completion_tsfn: c.napi_threadsafe_function = undefined; + if (c.napi_create_threadsafe_function( + env, + null, // No javascript function to call directly from here. + null, // No async resource. + tsfn_name, + 0, // Max queue size of 0 means no limit. + 1, // Number of acquires/threads that will be calling this TSFN. + null, // No finalization data. + null, // No finalization callback. + null, // No custom context. + on_completion_js, // Function to call on JS thread when TSFN is called. + &completion_tsfn, // TSFN out handle. + ) != c.napi_ok) { + return translate.throw(env, .{ + .message = "Failed to create thread-safe function.", + }); + } + errdefer if (c.napi_release_threadsafe_function( + completion_tsfn, + c.napi_tsfn_abort, + ) != c.napi_ok) { + std.log.warn("Failed to release allocated thread-safe function on error.", .{}); + }; + + const client = global_allocator.create(tb_client.ClientInterface) catch { + return translate.throw(env, .{ + .message = "Failed to allocated the client interface.", + }); + }; + errdefer global_allocator.destroy(client); + + tb_client.init( + global_allocator, + client, + cluster_id, + addresses, + @intFromPtr(completion_tsfn), + on_completion, + ) catch |err| switch (err) { + error.OutOfMemory => return translate.throw(env, .{ + .message = "Failed to allocate memory for Client.", + }), + error.AddressInvalid => return translate.throw(env, .{ + .message = "Invalid replica address.", + }), + error.AddressLimitExceeded => return translate.throw(env, .{ + .message = "Too many replica addresses.", + }), + error.SystemResources => return translate.throw(env, .{ + .message = "Failed to reserve system resources.", + }), + error.NetworkSubsystemFailed => return translate.throw(env, .{ + .message = "Network stack failure.", + }), + error.Unexpected => return translate.throw(env, .{ + .message = "Unexpected error occurred on Client.", + }), + }; + errdefer client.deinit() catch unreachable; + + return try translate.create_external(env, client); +} + +// Javascript is single threaded so no synchronization is necessary for closing/accessing a client. +fn destroy(env: c.napi_env, context: c.napi_value) !void { + const client_ptr = try translate.value_external( + env, + context, + "Failed to get client context pointer.", + ); + const client: *tb_client.ClientInterface = @ptrCast(@alignCast(client_ptr.?)); + defer { + client.deinit() catch unreachable; + global_allocator.destroy(client); + } + + const completion_ctx = client.completion_context() catch |err| switch (err) { + error.ClientInvalid => return request_error(env, .ERR_CLIENT_CLOSED), + }; + const completion_tsfn: c.napi_threadsafe_function = @ptrFromInt(completion_ctx); + if (c.napi_release_threadsafe_function(completion_tsfn, c.napi_tsfn_release) != c.napi_ok) { + return translate.throw(env, .{ + .message = "Failed to release allocated thread-safe function on error.", + }); + } +} + +fn request( + env: c.napi_env, + context: c.napi_value, + operation: Operation, + array: c.napi_value, + callback: c.napi_value, +) !void { + const client_ptr = try translate.value_external( + env, + context, + "Failed to get client context pointer.", + ); + const client: *tb_client.ClientInterface = @ptrCast(@alignCast(client_ptr.?)); + + // Create a reference to the callback so it stay alive until the packet completes. + var callback_ref: c.napi_ref = undefined; + try translate.create_reference( + env, + callback, + .strong, + &callback_ref, + "Failed to create reference to callback.", + ); + errdefer translate.delete_reference(env, callback_ref) catch { + std.log.warn("Failed to delete reference to callback on error.", .{}); + }; + + const array_length: u32 = try translate.array_length(env, array); + const packet, const packet_data = switch (operation) { + inline else => |operation_comptime| blk: { + const Event = operation_comptime.EventType(); + // Avoid allocating memory for requests that are known to be too large. + // However, the final validation happens in `tb_client` against the runtime-known + // maximum size. + const event_max: u32 = comptime operation_comptime.event_max( + constants.message_body_size_max, + ); + if (array_length > event_max) { + return request_error(env, .ERR_TOO_MUCH_DATA); + } + + const packet = global_allocator.create(tb_client.Packet) catch { + return translate.throw(env, .{ + .message = "Failed to allocated a new packet.", + }); + }; + errdefer global_allocator.destroy(packet); + + const buffer: []Event = global_allocator.alloc(Event, array_length) catch { + return translate.throw(env, .{ + .message = "Failed to allocated the request buffer.", + }); + }; + errdefer global_allocator.free(buffer); + + try decode_array(Event, env, array, buffer); + break :blk .{ packet, std.mem.sliceAsBytes(buffer) }; + }, + .pulse, .get_change_events => unreachable, + }; + + packet.* = .{ + .user_data = callback_ref, + .operation = @intFromEnum(operation), + .data = packet_data.ptr, + .data_size = @intCast(packet_data.len), + .user_tag = 0, + .status = undefined, + }; + client.submit(packet) catch |err| switch (err) { + error.ClientInvalid => return request_error(env, .ERR_CLIENT_CLOSED), + }; +} + +fn request_error(env: c.napi_env, code: ErrorCodes) translate.Error { + return translate.throw_typed_error(env, request_error_ctor_ref, @tagName(code)); +} + +fn on_completion( + completion_ctx: usize, + packet_extern: *tb_client.Packet, + timestamp: u64, + result: ?[*]const u8, + result_size: u32, +) callconv(.c) void { + _ = timestamp; + + switch (packet_extern.status) { + .ok => { + const operation: Operation = @enumFromInt(packet_extern.operation); + switch (operation) { + inline else => |operation_comptime| { + const Event = operation_comptime.EventType(); + const Result = operation_comptime.ResultType(); + + const packet = packet_extern.cast(); + const request_buffer: []align(@alignOf(Event)) u8 = + @constCast(@alignCast(packet.slice())); + // Trying to reallocate the request buffer instead of allocating a new one. + // This is optimal for create_* operations. + const reply_buffer: []align(@alignOf(Result)) u8 = global_allocator.realloc( + request_buffer, + result_size, + ) catch { + // We can't throw Js exceptions from the native callback. + @panic("Failed to allocated the request buffer."); + }; + + const source = stdx.bytes_as_slice( + .exact, + Result, + result.?[0..result_size], + ); + const target = stdx.bytes_as_slice( + .exact, + Result, + reply_buffer, + ); + + stdx.copy_disjoint( + .exact, + Result, + target, + source, + ); + + // Store the size of the results in the `tag` field, so we can access it back + // during `on_completion_js`. + packet.data = reply_buffer.ptr; + packet.data_size = @intCast(reply_buffer.len); + }, + .pulse, .get_change_events => unreachable, + } + }, + .client_evicted, + .client_release_too_low, + .client_release_too_high, + .client_shutdown, + .too_much_data, + => {}, // Handled on the JS side to throw exception. + .invalid_operation => unreachable, // We check the operation during request(). + .invalid_data_size => unreachable, // We set correct data size during request(). + } + + // Queue the packet to be processed on the JS thread to invoke its JS callback. + const completion_tsfn: c.napi_threadsafe_function = @ptrFromInt(completion_ctx); + switch (c.napi_call_threadsafe_function( + completion_tsfn, + packet_extern, + c.napi_tsfn_nonblocking, + )) { + c.napi_ok => {}, + c.napi_queue_full => @panic( + "ThreadSafe Function queue is full when created with no limit.", + ), + else => unreachable, + } +} + +fn on_completion_js( + env: c.napi_env, + unused_js_cb: c.napi_value, + unused_context: ?*anyopaque, + packet_argument: ?*anyopaque, +) callconv(.c) void { + _ = unused_js_cb; + _ = unused_context; + + // Extract the remaining packet information from the packet before it's freed. + const packet_extern: *tb_client.Packet = @ptrCast(@alignCast(packet_argument.?)); + const callback_ref: c.napi_ref = @ptrCast(@alignCast(packet_extern.user_data.?)); + + // Decode the packet's Buffer results into an array then free the packet/Buffer. + const operation: Operation = @enumFromInt(packet_extern.operation); + const array_or_error = switch (operation) { + inline else => |operation_comptime| blk: { + const Result = operation_comptime.ResultType(); + + const packet = packet_extern.cast(); + defer global_allocator.destroy(packet); + + const buffer: []const u8 = packet.slice(); + defer global_allocator.free(buffer); + + switch (packet.status) { + .ok => { + const results = stdx.bytes_as_slice( + .exact, + Result, + buffer, + ); + break :blk encode_array(Result, env, results); + }, + .client_shutdown => { + break :blk request_error(env, .ERR_CLIENT_CLOSED); + }, + .client_evicted => { + break :blk request_error(env, .ERR_CLIENT_EVICTED); + }, + .client_release_too_low => { + break :blk request_error(env, .ERR_CLIENT_RELEASE_TOO_LOW); + }, + .client_release_too_high => { + break :blk request_error(env, .ERR_CLIENT_RELEASE_TOO_HIGH); + }, + .too_much_data => { + break :blk request_error(env, .ERR_TOO_MUCH_DATA); + }, + else => unreachable, // all other packet status' handled in previous callback. + } + }, + .pulse, .get_change_events => unreachable, + }; + + // Parse Result array out of packet data, freeing it in the process. + // NOTE: Ensure this is called before anything that could early-return to avoid a alloc leak. + var callback_error = napi_null; + const callback_result = array_or_error catch |err| switch (err) { + error.ExceptionThrown => blk: { + if (c.napi_get_and_clear_last_exception(env, &callback_error) != c.napi_ok) { + std.log.warn("Failed to capture callback error from thrown Exception.", .{}); + } + break :blk napi_null; + }, + }; + + // Make sure to delete the callback reference once we're done calling it. + defer if (c.napi_delete_reference(env, callback_ref) != c.napi_ok) { + std.log.warn("Failed to delete reference to user's JS callback.", .{}); + }; + + const callback = translate.reference_value( + env, + callback_ref, + "Failed to get callback from reference.", + ) catch return; + + var args = [_]c.napi_value{ callback_error, callback_result }; + _ = translate.call_function(env, napi_null, callback, &args) catch return; +} + +// (De)Serialization + +fn decode_array(comptime Event: type, env: c.napi_env, array: c.napi_value, events: []Event) !void { + for (events, 0..) |*event, i| { + const object = try translate.array_element(env, array, @intCast(i)); + switch (Event) { + Account, + Transfer, + AccountFilter, + AccountBalance, + QueryFilter, + => { + inline for (std.meta.fields(Event)) |field| { + const value: field.type = switch (@typeInfo(field.type)) { + .@"struct" => |info| @bitCast(try @field( + translate, + @typeName(info.backing_integer.?) ++ "_from_object", + )( + env, + object, + add_trailing_null(field.name), + )), + .int => try @field(translate, @typeName(field.type) ++ "_from_object")( + env, + object, + add_trailing_null(field.name), + ), + // Arrays are only used for padding/reserved fields, + // instead of requiring the user to explicitly set an empty buffer, + // we just hide those fields and preserve their default value. + .array => @as( + *const field.type, + @ptrCast(@alignCast(field.default_value_ptr.?)), + ).*, + else => unreachable, + }; + + @field(event, field.name) = value; + } + }, + u128 => event.* = try translate.u128_from_value(env, object, "lookup"), + else => @compileError("invalid Event type"), + } + } +} + +fn encode_array(comptime Result: type, env: c.napi_env, results: []const Result) !c.napi_value { + const array = try translate.create_array( + env, + @intCast(results.len), + "Failed to allocate array for results.", + ); + + for (results, 0..) |*result, i| { + const object = try translate.create_object( + env, + "Failed to create " ++ @typeName(Result) ++ " object.", + ); + + inline for (std.meta.fields(Result)) |field| { + const FieldInt = switch (@typeInfo(field.type)) { + .@"struct" => |info| info.backing_integer.?, + .@"enum" => |info| info.tag_type, + // Arrays are only used for padding/reserved fields. + .array => continue, + else => field.type, + }; + + const value: FieldInt = switch (@typeInfo(field.type)) { + .@"struct" => @bitCast(@field(result, field.name)), + .@"enum" => @intFromEnum(@field(result, field.name)), + else => @field(result, field.name), + }; + + try @field(translate, @typeName(FieldInt) ++ "_into_object")( + env, + object, + add_trailing_null(field.name), + value, + "Failed to set property \"" ++ field.name ++ + "\" of " ++ @typeName(Result) ++ " object", + ); + + try translate.set_array_element( + env, + array, + @intCast(i), + object, + "Failed to set element in results array.", + ); + } + } + + return array; +} + +fn add_trailing_null(comptime input: []const u8) [:0]const u8 { + // Concatenating `[]const u8` with an empty string `[0:0]const u8`, + // gives us a null-terminated string `[:0]const u8`. + const output = input ++ ""; + comptime assert(output.len == input.len); + comptime assert(output[output.len] == 0); + return output; +} diff --git a/ocam/src/clients/node/node_bindings.zig b/ocam/src/clients/node/node_bindings.zig new file mode 100644 index 00000000..83ae8598 --- /dev/null +++ b/ocam/src/clients/node/node_bindings.zig @@ -0,0 +1,273 @@ +const std = @import("std"); +const vsr = @import("vsr"); + +const assert = std.debug.assert; +const tb = vsr.tigerbeetle; +const tb_client = vsr.tb_client; + +const TypeMapping = struct { + name: []const u8, + hidden_fields: []const []const u8 = &.{}, + docs_link: ?[]const u8 = null, + + pub fn hidden(comptime self: @This(), name: []const u8) bool { + inline for (self.hidden_fields) |field| { + if (std.mem.eql(u8, field, name)) { + return true; + } + } else return false; + } +}; + +const type_mappings = .{ + .{ tb.AccountFlags, TypeMapping{ + .name = "AccountFlags", + .hidden_fields = &.{"padding"}, + .docs_link = "reference/account#flags", + } }, + .{ tb.TransferFlags, TypeMapping{ + .name = "TransferFlags", + .hidden_fields = &.{"padding"}, + .docs_link = "reference/transfer#flags", + } }, + .{ tb.AccountFilterFlags, TypeMapping{ + .name = "AccountFilterFlags", + .hidden_fields = &.{"padding"}, + .docs_link = "reference/account-filter#flags", + } }, + .{ tb.QueryFilterFlags, TypeMapping{ + .name = "QueryFilterFlags", + .hidden_fields = &.{"padding"}, + .docs_link = "reference/query-filter#flags", + } }, + .{ tb.Account, TypeMapping{ + .name = "Account", + .docs_link = "reference/account/#", + } }, + .{ tb.Transfer, TypeMapping{ + .name = "Transfer", + .docs_link = "reference/transfer/#", + } }, + .{ tb.CreateAccountStatus, TypeMapping{ + .name = "CreateAccountStatus", + .docs_link = "reference/requests/create_accounts#", + } }, + .{ tb.CreateTransferStatus, TypeMapping{ + .name = "CreateTransferStatus", + .docs_link = "reference/requests/create_transfers#", + } }, + .{ tb.CreateAccountResult, TypeMapping{ + .name = "CreateAccountResult", + .hidden_fields = &.{"reserved"}, + } }, + .{ tb.CreateTransferResult, TypeMapping{ + .name = "CreateTransferResult", + .hidden_fields = &.{"reserved"}, + } }, + .{ tb.AccountFilter, TypeMapping{ + .name = "AccountFilter", + .hidden_fields = &.{"reserved"}, + .docs_link = "reference/account-filter#", + } }, + .{ tb.QueryFilter, TypeMapping{ + .name = "QueryFilter", + .hidden_fields = &.{"reserved"}, + .docs_link = "reference/query-filter#", + } }, + .{ tb.AccountBalance, TypeMapping{ + .name = "AccountBalance", + .hidden_fields = &.{"reserved"}, + .docs_link = "reference/account-balances#", + } }, + .{ tb_client.Operation, TypeMapping{ + .name = "Operation", + .hidden_fields = &.{ "reserved", "root", "register" }, + } }, +}; + +fn typescript_type(comptime Type: type) []const u8 { + switch (@typeInfo(Type)) { + .@"enum" => return comptime get_mapped_type_name(Type) orelse @compileError( + "Type " ++ @typeName(Type) ++ " not mapped.", + ), + .@"struct" => |info| switch (info.layout) { + .@"packed" => return comptime typescript_type( + std.meta.Int(.unsigned, @bitSizeOf(Type)), + ), + else => return comptime get_mapped_type_name(Type) orelse @compileError( + "Type " ++ @typeName(Type) ++ " not mapped.", + ), + }, + .int => |info| { + assert(info.signedness == .unsigned); + return switch (info.bits) { + 16 => "number", + 32 => "number", + 64 => "bigint", + 128 => "bigint", + else => @compileError("invalid int type: " ++ @typeName(Type)), + }; + }, + else => @compileError("Unhandled type: " ++ @typeName(Type)), + } +} + +fn get_mapped_type_name(comptime Type: type) ?[]const u8 { + inline for (type_mappings) |type_mapping| { + if (Type == type_mapping[0]) { + return type_mapping[1].name; + } + } else return null; +} + +fn emit_enum( + buffer: *std.ArrayList(u8), + comptime Type: type, + comptime mapping: TypeMapping, +) !void { + try emit_docs(buffer, mapping, 0, null); + + try buffer.writer().print("export enum {s} {{\n", .{mapping.name}); + + inline for (@typeInfo(Type).@"enum".fields) |field| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + if (comptime mapping.hidden(field.name)) continue; + + try emit_docs(buffer, mapping, 1, field.name); + + const int_value = @intFromEnum(@field(Type, field.name)); + try buffer.writer().print(" {s} = {s},\n", .{ + field.name, + if (int_value == std.math.maxInt(@TypeOf(int_value))) + std.fmt.comptimePrint("0x{X}", .{int_value}) + else + std.fmt.comptimePrint("{}", .{int_value}), + }); + } + + try buffer.writer().print("}}\n\n", .{}); +} + +fn emit_packed_struct( + buffer: *std.ArrayList(u8), + comptime type_info: anytype, + comptime mapping: TypeMapping, +) !void { + assert(type_info.layout == .@"packed"); + try emit_docs(buffer, mapping, 0, null); + + try buffer.writer().print( + \\export enum {s} {{ + \\ none = 0, + \\ + , .{mapping.name}); + + inline for (type_info.fields, 0..) |field, i| { + if (comptime mapping.hidden(field.name)) continue; + + try emit_docs(buffer, mapping, 1, field.name); + + try buffer.writer().print(" {s} = (1 << {d}),\n", .{ + field.name, + i, + }); + } + + try buffer.writer().print("}}\n\n", .{}); +} + +fn emit_struct( + buffer: *std.ArrayList(u8), + comptime type_info: anytype, + comptime mapping: TypeMapping, +) !void { + try emit_docs(buffer, mapping, 0, null); + + try buffer.writer().print("export type {s} = {{\n", .{ + mapping.name, + }); + + inline for (type_info.fields) |field| { + if (comptime mapping.hidden(field.name)) continue; + + try emit_docs(buffer, mapping, 1, field.name); + + switch (@typeInfo(field.type)) { + .array => try buffer.writer().print(" {s}: Buffer\n", .{ + field.name, + }), + else => try buffer.writer().print( + " {s}: {s}\n", + .{ + field.name, + typescript_type(field.type), + }, + ), + } + } + + try buffer.writer().print("}}\n\n", .{}); +} + +fn emit_docs( + buffer: anytype, + comptime mapping: TypeMapping, + comptime indent: comptime_int, + comptime field: ?[]const u8, +) !void { + if (mapping.docs_link) |docs_link| { + try buffer.writer().print( + \\ + \\{[indent]s}/** + \\{[indent]s} * See [{[name]s}](https://docs.tigerbeetle.com/{[docs_link]s}{[field]s}) + \\{[indent]s} */ + \\ + , .{ + .indent = " " ** indent, + .name = field orelse mapping.name, + .docs_link = docs_link, + .field = field orelse "", + }); + } +} + +pub fn generate_bindings(buffer: *std.ArrayList(u8)) !void { + @setEvalBranchQuota(100_000); + + try buffer.writer().print( + \\/////////////////////////////////////////////////////// + \\// This file was auto-generated by node_bindings.zig // + \\// Do not manually modify. // + \\/////////////////////////////////////////////////////// + \\ + \\ + , .{}); + + // Emit JS declarations. + inline for (type_mappings) |type_mapping| { + const ZigType = type_mapping[0]; + const mapping = type_mapping[1]; + + switch (@typeInfo(ZigType)) { + .@"struct" => |info| switch (info.layout) { + .auto => @compileError( + "Only packed or extern structs are supported: " ++ @typeName(ZigType), + ), + .@"packed" => try emit_packed_struct(buffer, info, mapping), + .@"extern" => try emit_struct(buffer, info, mapping), + }, + .@"enum" => try emit_enum(buffer, ZigType, mapping), + else => @compileError("Type cannot be represented: " ++ @typeName(ZigType)), + } + } +} + +pub fn main() !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var buffer = std.ArrayList(u8).init(allocator); + try generate_bindings(&buffer); + try std.io.getStdOut().writeAll(buffer.items); +} diff --git a/ocam/src/clients/node/package-lock.json b/ocam/src/clients/node/package-lock.json new file mode 100644 index 00000000..29da1a8b --- /dev/null +++ b/ocam/src/clients/node/package-lock.json @@ -0,0 +1,87 @@ +{ + "name": "tigerbeetle-node", + "version": "0.12.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "tigerbeetle-node", + "version": "0.12.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^18.0.0", + "node-api-headers": "^0.0.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/node-api-headers": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/node-api-headers/-/node-api-headers-0.0.2.tgz", + "integrity": "sha512-YsjmaKGPDkmhoNKIpkChtCsPVaRE0a274IdERKnuc/E8K1UJdBZ4/mvI006OijlQZHCfpRNOH3dfHQs92se8gg==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + } + }, + "dependencies": { + "@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } + }, + "node-api-headers": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/node-api-headers/-/node-api-headers-0.0.2.tgz", + "integrity": "sha512-YsjmaKGPDkmhoNKIpkChtCsPVaRE0a274IdERKnuc/E8K1UJdBZ4/mvI006OijlQZHCfpRNOH3dfHQs92se8gg==", + "dev": true + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true + }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + } + } +} diff --git a/ocam/src/clients/node/package.json b/ocam/src/clients/node/package.json new file mode 100644 index 00000000..bf4b0fd8 --- /dev/null +++ b/ocam/src/clients/node/package.json @@ -0,0 +1,42 @@ +{ + "name": "tigerbeetle-node", + "version": "0.12.0", + "description": "TigerBeetle Node.js client", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/tigerbeetle/tigerbeetle.git", + "directory": "src/clients/node" + }, + "preferUnplugged": true, + "files": [ + "LICENSE", + "README.md", + "dist", + "src", + "!src/zig-cache", + "package.json", + "package-lock.json", + "tsconfig.json" + ], + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "prepare": "tsc" + }, + "author": "TigerBeetle, Inc", + "license": "Apache-2.0", + "contributors": [ + "Donovan Changfoot ", + "Isaac Freund ", + "Jason Bruwer ", + "Joran Dirk Greef " + ], + "devDependencies": { + "@types/node": "^18.0.0", + "node-api-headers": "^0.0.2", + "typescript": "^5.9.3" + } +} diff --git a/ocam/src/clients/node/samples/basic/README.md b/ocam/src/clients/node/samples/basic/README.md new file mode 100644 index 00000000..fd602578 --- /dev/null +++ b/ocam/src/clients/node/samples/basic/README.md @@ -0,0 +1,61 @@ + +# Basic Node.js Sample + +Code for this sample is in [./main.js](./main.js). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Node.js >= `18` + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/node/samples/basic`. + +Then, install the TigerBeetle client: + +```console +npm install --save-exact tigerbeetle-node +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +node main.js +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create a transfer + +Then it transfers `10` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate account balances + +Then it fetches both accounts, checks they both exist, and +checks that **account `1`** has: + * `debits_posted = 10` + * and `credits_posted = 0` + +And that **account `2`** has: + * `debits_posted= 0` + * and `credits_posted = 10` diff --git a/ocam/src/clients/node/samples/basic/main.js b/ocam/src/clients/node/samples/basic/main.js new file mode 100644 index 00000000..9563e47c --- /dev/null +++ b/ocam/src/clients/node/samples/basic/main.js @@ -0,0 +1,92 @@ +const assert = require("assert"); +const process = require("process"); + +const { + createClient, + CreateAccountStatus, + CreateTransferStatus, +} = require("tigerbeetle-node"); + +const client = createClient({ + cluster_id: 0n, + replica_addresses: [process.env.TB_ADDRESS || '3000'], +}); + +async function main() { + let accountResults = await client.createAccounts([ + { + id: 1n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + flags: 0, + timestamp: 0n, + }, + { + id: 2n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + flags: 0, + timestamp: 0n, + }, + ]); + assert.strictEqual(accountResults.length, 2); + assert.strictEqual(accountResults[0].status, CreateAccountStatus.created); + assert.strictEqual(accountResults[1].status, CreateAccountStatus.created); + + let transferResults = await client.createTransfers([ + { + id: 1n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: 0, + timestamp: 0n, + }, + ]); + assert.strictEqual(transferResults.length, 1); + assert.strictEqual(transferResults[0].status, CreateTransferStatus.created); + + let accounts = await client.lookupAccounts([1n, 2n]); + assert.strictEqual(accounts.length, 2); + for (let account of accounts) { + if (account.id === 1n) { + assert.strictEqual(account.debits_posted, 10n); + assert.strictEqual(account.credits_posted, 0n); + } else if (account.id === 2n) { + assert.strictEqual(account.debits_posted, 0n); + assert.strictEqual(account.credits_posted, 10n); + } else { + assert.fail("Unexpected account: " + JSON.stringify(account, null, 2)); + } + } +} + +main().then(() => { + process.exit(0); +}).catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/ocam/src/clients/node/samples/basic/package-lock.json b/ocam/src/clients/node/samples/basic/package-lock.json new file mode 100644 index 00000000..7d180483 --- /dev/null +++ b/ocam/src/clients/node/samples/basic/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "basic", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "tigerbeetle-node": "file:../../" + } + }, + "../..": { + "name": "tigerbeetle-node", + "version": "0.12.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^18.0.0", + "node-api-headers": "^0.0.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/tigerbeetle-node": { + "resolved": "../..", + "link": true + } + } +} diff --git a/ocam/src/clients/node/samples/basic/package.json b/ocam/src/clients/node/samples/basic/package.json new file mode 100644 index 00000000..86318688 --- /dev/null +++ b/ocam/src/clients/node/samples/basic/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "tigerbeetle-node": "file:../../" + } +} diff --git a/ocam/src/clients/node/samples/two-phase-many/README.md b/ocam/src/clients/node/samples/two-phase-many/README.md new file mode 100644 index 00000000..bbd05a69 --- /dev/null +++ b/ocam/src/clients/node/samples/two-phase-many/README.md @@ -0,0 +1,91 @@ + +# Many Two-Phase Transfers Node.js Sample + +Code for this sample is in [./main.js](./main.js). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Node.js >= `18` + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/node/samples/two-phase-many`. + +Then, install the TigerBeetle client: + +```console +npm install --save-exact tigerbeetle-node +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +node main.js +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfers + +Then it begins 5 pending transfers of amounts `100` to +`500`, incrementing by `100` for each transfer. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 1500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 1500` + +(This is because a pending transfer only affects **pending** +credits and debits on accounts, not **posted** credits and +debits.) + +## 4. Post and void alternating transfers + +Then it alternatively posts and voids each transfer, +checking account balances after each transfer. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +solely *posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 900` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 900` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/node/samples/two-phase-many/main.js b/ocam/src/clients/node/samples/two-phase-many/main.js new file mode 100644 index 00000000..51a490c3 --- /dev/null +++ b/ocam/src/clients/node/samples/two-phase-many/main.js @@ -0,0 +1,364 @@ +const assert = require("assert"); +const process = require("process"); + +const { + createClient, + CreateAccountStatus, + CreateTransferStatus, + TransferFlags, +} = require("tigerbeetle-node"); + +const client = createClient({ + cluster_id: 0n, + replica_addresses: [process.env.TB_ADDRESS || '3000'], +}); + +async function main() { + // Create two accounts. + let accountResults = await client.createAccounts([ + { + id: 1n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + flags: 0, + timestamp: 0n, + }, + { + id: 2n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + flags: 0, + timestamp: 0n, + }, + ]); + assert.strictEqual(accountResults.length, 2); + assert.strictEqual(accountResults[0].status, CreateAccountStatus.created); + assert.strictEqual(accountResults[1].status, CreateAccountStatus.created); + + // Start five pending transfer. + let transfers = [ + { + id: 1n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 100n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.pending, + timestamp: 0n, + }, + { + id: 2n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 200n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.pending, + timestamp: 0n, + }, + { + id: 3n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 300n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.pending, + timestamp: 0n, + }, + { + id: 4n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 400n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.pending, + timestamp: 0n, + }, + { + id: 5n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 500n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.pending, + timestamp: 0n, + }, + ]; + let transferResults = await client.createTransfers(transfers); + assert.strictEqual(transferResults.length, transfers.length); + for (const result of transferResults) { + assert.strictEqual(result.status, CreateTransferStatus.created); + } + + // Validate accounts pending and posted debits/credits before + // finishing the two-phase transfer. + let accounts = await client.lookupAccounts([1n, 2n]); + assert.strictEqual(accounts.length, 2); + for (let account of accounts) { + if (account.id === 1n) { + assert.strictEqual(account.debits_posted, 0n); + assert.strictEqual(account.credits_posted, 0n); + assert.strictEqual(account.debits_pending, 1500n); + assert.strictEqual(account.credits_pending, 0n); + } else if (account.id === 2n) { + assert.strictEqual(account.debits_posted, 0n); + assert.strictEqual(account.credits_posted, 0n); + assert.strictEqual(account.debits_pending, 0n); + assert.strictEqual(account.credits_pending, 1500n); + } else { + assert.fail("Unexpected account: " + JSON.stringify(account, null, 2)); + } + } + + // Create a 6th transfer posting the 1st transfer. + transferResults = await client.createTransfers([ + { + id: 6n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 100n, + pending_id: 1n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.post_pending_transfer, + timestamp: 0n, + }, + ]); + assert.strictEqual(transferResults.length, 1); + assert.strictEqual(transferResults[0].status, CreateTransferStatus.created); + + // Validate account balances after posting 1st pending transfer. + accounts = await client.lookupAccounts([1n, 2n]); + assert.strictEqual(accounts.length, 2); + for (let account of accounts) { + if (account.id === 1n) { + assert.strictEqual(account.debits_posted, 100n); + assert.strictEqual(account.credits_posted, 0n); + assert.strictEqual(account.debits_pending, 1400n); + assert.strictEqual(account.credits_pending, 0n); + } else if (account.id === 2n) { + assert.strictEqual(account.debits_posted, 0n); + assert.strictEqual(account.credits_posted, 100n); + assert.strictEqual(account.debits_pending, 0n); + assert.strictEqual(account.credits_pending, 1400n); + } else { + assert.fail("Unexpected account: " + account.id); + } + } + + // Create a 7th transfer voiding the 2nd transfer. + transferResults = await client.createTransfers([ + { + id: 7n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 200n, + pending_id: 2n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.void_pending_transfer, + timestamp: 0n, + }, + ]); + assert.strictEqual(transferResults.length, 1); + assert.strictEqual(transferResults[0].status, CreateTransferStatus.created); + + // Validate account balances after voiding 2nd pending transfer. + accounts = await client.lookupAccounts([1n, 2n]); + assert.strictEqual(accounts.length, 2); + for (let account of accounts) { + if (account.id === 1n) { + assert.strictEqual(account.debits_posted, 100n); + assert.strictEqual(account.credits_posted, 0n); + assert.strictEqual(account.debits_pending, 1200n); + assert.strictEqual(account.credits_pending, 0n); + } else if (account.id === 2n) { + assert.strictEqual(account.debits_posted, 0n); + assert.strictEqual(account.credits_posted, 100n); + assert.strictEqual(account.debits_pending, 0n); + assert.strictEqual(account.credits_pending, 1200n); + } else { + assert.fail("Unexpected account: " + account.id); + } + } + + // Create a 8th transfer posting the 3rd transfer. + transferResults = await client.createTransfers([ + { + id: 8n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 300n, + pending_id: 3n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.post_pending_transfer, + timestamp: 0n, + }, + ]); + assert.strictEqual(transferResults.length, 1); + assert.strictEqual(transferResults[0].status, CreateTransferStatus.created); + + // Validate account balances after posting 3rd pending transfer. + accounts = await client.lookupAccounts([1n, 2n]); + assert.strictEqual(accounts.length, 2); + for (let account of accounts) { + if (account.id === 1n) { + assert.strictEqual(account.debits_posted, 400n); + assert.strictEqual(account.credits_posted, 0n); + assert.strictEqual(account.debits_pending, 900n); + assert.strictEqual(account.credits_pending, 0n); + } else if (account.id === 2n) { + assert.strictEqual(account.debits_posted, 0n); + assert.strictEqual(account.credits_posted, 400n); + assert.strictEqual(account.debits_pending, 0n); + assert.strictEqual(account.credits_pending, 900n); + } else { + assert.fail("Unexpected account: " + account.id); + } + } + + // Create a 9th transfer voiding the 4th transfer. + transferResults = await client.createTransfers([ + { + id: 9n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 400n, + pending_id: 4n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.void_pending_transfer, + timestamp: 0n, + }, + ]); + assert.strictEqual(transferResults.length, 1); + assert.strictEqual(transferResults[0].status, CreateTransferStatus.created); + + // Validate account balances after voiding 4th pending transfer. + accounts = await client.lookupAccounts([1n, 2n]); + assert.strictEqual(accounts.length, 2); + for (let account of accounts) { + if (account.id === 1n) { + assert.strictEqual(account.debits_posted, 400n); + assert.strictEqual(account.credits_posted, 0n); + assert.strictEqual(account.debits_pending, 500n); + assert.strictEqual(account.credits_pending, 0n); + } else if (account.id === 2n) { + assert.strictEqual(account.debits_posted, 0n); + assert.strictEqual(account.credits_posted, 400n); + assert.strictEqual(account.debits_pending, 0n); + assert.strictEqual(account.credits_pending, 500n); + } else { + assert.fail("Unexpected account: " + account.id); + } + } + + // Create a 10th transfer posting the 5th transfer. + transferResults = await client.createTransfers([ + { + id: 10n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 500n, + pending_id: 5n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.post_pending_transfer, + timestamp: 0n, + }, + ]); + assert.strictEqual(transferResults.length, 1); + assert.strictEqual(transferResults[0].status, CreateTransferStatus.created); + + // Validate account balances after posting 5th pending transfer. + accounts = await client.lookupAccounts([1n, 2n]); + assert.strictEqual(accounts.length, 2); + for (let account of accounts) { + if (account.id === 1n) { + assert.strictEqual(account.debits_posted, 900n); + assert.strictEqual(account.credits_posted, 0n); + assert.strictEqual(account.debits_pending, 0n); + assert.strictEqual(account.credits_pending, 0n); + } else if (account.id === 2n) { + assert.strictEqual(account.debits_posted, 0n); + assert.strictEqual(account.credits_posted, 900n); + assert.strictEqual(account.debits_pending, 0n); + assert.strictEqual(account.credits_pending, 0n); + } else { + assert.fail("Unexpected account: " + account.id); + } + } +} + +main().then(() => { + process.exit(0); +}).catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/ocam/src/clients/node/samples/two-phase-many/package-lock.json b/ocam/src/clients/node/samples/two-phase-many/package-lock.json new file mode 100644 index 00000000..dbd61c52 --- /dev/null +++ b/ocam/src/clients/node/samples/two-phase-many/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "two-phase-many", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "tigerbeetle-node": "file:../../" + } + }, + "../..": { + "name": "tigerbeetle-node", + "version": "0.12.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^18.0.0", + "node-api-headers": "^0.0.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/tigerbeetle-node": { + "resolved": "../..", + "link": true + } + } +} diff --git a/ocam/src/clients/node/samples/two-phase-many/package.json b/ocam/src/clients/node/samples/two-phase-many/package.json new file mode 100644 index 00000000..86318688 --- /dev/null +++ b/ocam/src/clients/node/samples/two-phase-many/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "tigerbeetle-node": "file:../../" + } +} diff --git a/ocam/src/clients/node/samples/two-phase/README.md b/ocam/src/clients/node/samples/two-phase/README.md new file mode 100644 index 00000000..6ca1a687 --- /dev/null +++ b/ocam/src/clients/node/samples/two-phase/README.md @@ -0,0 +1,100 @@ + +# Two-Phase Transfer Node.js Sample + +Code for this sample is in [./main.js](./main.js). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Node.js >= `18` + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/node/samples/two-phase`. + +Then, install the TigerBeetle client: + +```console +npm install --save-exact tigerbeetle-node +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +node main.js +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfer + +Then it begins a +pending transfer of `500` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 500` + +(This is because a pending +transfer only affects **pending** credits and debits on accounts, +not **posted** credits and debits.) + +## 4. Post pending transfer + +Then it creates a second transfer that marks the first +transfer as posted. + +## 5. Fetch and validate transfers + +Then it fetches both transfers, validates +that the two transfers exist, validates that the first +transfer had (and still has) a `pending` flag, and validates +that the second transfer had (and still has) a +`post_pending_transfer` flag. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +*posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 500` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 500` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/node/samples/two-phase/main.js b/ocam/src/clients/node/samples/two-phase/main.js new file mode 100644 index 00000000..8cb97648 --- /dev/null +++ b/ocam/src/clients/node/samples/two-phase/main.js @@ -0,0 +1,153 @@ +const assert = require("assert"); +const process = require("process"); + +const { + createClient, + CreateAccountStatus, + CreateTransferStatus, + TransferFlags, +} = require("tigerbeetle-node"); + +const client = createClient({ + cluster_id: 0n, + replica_addresses: [process.env.TB_ADDRESS || '3000'], +}); + +async function main() { + // Create two accounts + let accountResults = await client.createAccounts([ + { + id: 1n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + flags: 0, + timestamp: 0n, + }, + { + id: 2n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + flags: 0, + timestamp: 0n, + }, + ]); + assert.strictEqual(accountResults.length, 2); + assert.strictEqual(accountResults[0].status, CreateAccountStatus.created); + assert.strictEqual(accountResults[1].status, CreateAccountStatus.created); + + // Start a pending transfer + let transferResults = await client.createTransfers([ + { + id: 1n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 500n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.pending, + timestamp: 0n, + }, + ]); + assert.strictEqual(transferResults.length, 1); + assert.strictEqual(transferResults[0].status, CreateTransferStatus.created); + + // Validate accounts pending and posted debits/credits before finishing the two-phase transfer + let accounts = await client.lookupAccounts([1n, 2n]); + assert.strictEqual(accounts.length, 2); + for (let account of accounts) { + if (account.id === 1n) { + assert.strictEqual(account.debits_posted, 0n); + assert.strictEqual(account.credits_posted, 0n); + assert.strictEqual(account.debits_pending, 500n); + assert.strictEqual(account.credits_pending, 0n); + } else if (account.id === 2n) { + assert.strictEqual(account.debits_posted, 0n); + assert.strictEqual(account.credits_posted, 0n); + assert.strictEqual(account.debits_pending, 0n); + assert.strictEqual(account.credits_pending, 500n); + } else { + assert.fail("Unexpected account: " + JSON.stringify(account, null, 2)); + } + } + + // Create a second transfer simply posting the first transfer + transferResults = await client.createTransfers([ + { + id: 2n, + debit_account_id: 1n, + credit_account_id: 2n, + amount: 500n, + pending_id: 1n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.post_pending_transfer, + timestamp: 0n, + }, + ]); + assert.strictEqual(transferResults.length, 1); + assert.strictEqual(transferResults[0].status, CreateTransferStatus.created); + + // Validate the contents of all transfers + let transfers = await client.lookupTransfers([1n, 2n]); + assert.strictEqual(transfers.length, 2); + for (let transfer of transfers) { + if (transfer.id === 1n) { + assert.strictEqual(transfer.flags & TransferFlags.pending, TransferFlags.pending); + } else if (transfer.id === 2n) { + assert.strictEqual(transfer.flags & TransferFlags.post_pending_transfer, TransferFlags.post_pending_transfer); + } else { + assert.fail("Unexpected transfer: " + transfer.id); + } + } + + // Validate accounts pending and posted debits/credits after finishing the two-phase transfer + accounts = await client.lookupAccounts([1n, 2n]); + assert.strictEqual(accounts.length, 2); + for (let account of accounts) { + if (account.id === 1n) { + assert.strictEqual(account.debits_posted, 500n); + assert.strictEqual(account.credits_posted, 0n); + assert.strictEqual(account.debits_pending, 0n); + assert.strictEqual(account.credits_pending, 0n); + } else if (account.id === 2n) { + assert.strictEqual(account.debits_posted, 0n); + assert.strictEqual(account.credits_posted, 500n); + assert.strictEqual(account.debits_pending, 0n); + assert.strictEqual(account.credits_pending, 0n); + } else { + assert.fail("Unexpected account: " + account.id); + } + } +} + +main().then(() => { + process.exit(0); +}).catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/ocam/src/clients/node/samples/two-phase/package-lock.json b/ocam/src/clients/node/samples/two-phase/package-lock.json new file mode 100644 index 00000000..57c60edd --- /dev/null +++ b/ocam/src/clients/node/samples/two-phase/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "two-phase", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "tigerbeetle-node": "file:../../" + } + }, + "../..": { + "name": "tigerbeetle-node", + "version": "0.12.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^18.0.0", + "node-api-headers": "^0.0.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/tigerbeetle-node": { + "resolved": "../..", + "link": true + } + } +} diff --git a/ocam/src/clients/node/samples/two-phase/package.json b/ocam/src/clients/node/samples/two-phase/package.json new file mode 100644 index 00000000..86318688 --- /dev/null +++ b/ocam/src/clients/node/samples/two-phase/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "tigerbeetle-node": "file:../../" + } +} diff --git a/ocam/src/clients/node/samples/walkthrough/README.md b/ocam/src/clients/node/samples/walkthrough/README.md new file mode 100644 index 00000000..b657597b --- /dev/null +++ b/ocam/src/clients/node/samples/walkthrough/README.md @@ -0,0 +1 @@ +Code from the [top-level README.md](../../README.md) collected into a single runnable project. diff --git a/ocam/src/clients/node/samples/walkthrough/main.js b/ocam/src/clients/node/samples/walkthrough/main.js new file mode 100644 index 00000000..4a167854 --- /dev/null +++ b/ocam/src/clients/node/samples/walkthrough/main.js @@ -0,0 +1,563 @@ +// section:imports +const { createClient, id } = require("tigerbeetle-node"); +const process = require("process"); + +console.log("Import ok!"); +// endsection:imports + +const { + AccountFlags, + TransferFlags, + CreateTransferStatus, + CreateAccountStatus, + AccountFilterFlags, + QueryFilterFlags, + amount_max, +} = require("tigerbeetle-node"); + +async function main() { + // section:client + const client = createClient({ + cluster_id: 0n, + replica_addresses: [process.env.TB_ADDRESS || "3000"], + }); + // endsection:client + + // The examples currently throws because the batch is actually invalid (most of fields are + // undefined). Ideally, we prepare a correct batch here while keeping the syntax compact, + // for the example, but for the time being lets prioritize a readable example and just + // swallow the error. + + try { + // section:create-accounts + const account = { + id: id(), // TigerBeetle time-based ID. + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 718, + flags: 0, + timestamp: 0n, + }; + + const account_results = await client.createAccounts([account]); + // Results handling omitted. + // endsection:create-accounts + } catch (exception) {} + + try { + // section:account-flags + const account0 = { + id: 100n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + timestamp: 0n, + flags: AccountFlags.linked | AccountFlags.debits_must_not_exceed_credits, + }; + const account1 = { + id: 101n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + timestamp: 0n, + flags: AccountFlags.history, + }; + + const account_results = await client.createAccounts([account0, account1]); + // Results handling omitted. + // endsection:account-flags + } catch (exception) {} + + try { + // section:create-accounts-errors + const account0 = { + id: 102n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + timestamp: 0n, + flags: 0, + }; + const account1 = { + id: 103n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + timestamp: 0n, + flags: 0, + }; + const account2 = { + id: 104n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + timestamp: 0n, + flags: 0, + }; + + const account_results = await client.createAccounts([account0, account1, account2]); + for (let i = 0; i < account_results.length; i++) { + switch (account_results[i].status) { + case CreateAccountStatus.created: + console.error(`Batch account at ${i} successfully created with timestamp ${account_results[i].timestamp}.`); + break; + case CreateAccountStatus.exists: + console.error(`Batch account at ${i} already exists with timestamp ${account_results[i].timestamp}.`); + break; + default: + console.error(`Batch account at ${i} failed to create: ${account_results[i].status}`); + break; + } + } + // endsection:create-accounts-errors + } catch (exception) {} + + try { + // section:lookup-accounts + const accounts = await client.lookupAccounts([100n, 101n]); + // endsection:lookup-accounts + } catch (exception) {} + + try { + // section:create-transfers + const transfers = [{ + id: id(), // TigerBeetle time-based ID. + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: 0, + timestamp: 0n, + }]; + + const transfers_results = await client.createTransfers(transfers); + // Results handling omitted. + // endsection:create-transfers + } catch (exception) {} + + try { + // section:create-transfers-errors + const transfers = [{ + id: 1n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: 0, + timestamp: 0n, + }, + { + id: 2n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: 0, + timestamp: 0n, + }, + { + id: 3n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: 0, + timestamp: 0n, + }]; + + const transfers_results = await client.createTransfers(batch); + for (let i = 0; i < transfers_results.length; i++) { + switch (transfers_results[i].status) { + case CreateTransferStatus.created: + console.error(`Batch transfer at ${i} successfully created with timestamp ${transfers_results[i].timestamp}.`); + break; + case CreateTransferStatus.exists: + console.error(`Batch transfer at ${i} already exists with timestamp ${transfers_results[i].timestamp}.`); + break; + default: + console.error(`Batch transfer at ${i} failed to create: ${transfers_results[i].status}`); + break; + } + } + // endsection:create-transfers-errors + } catch (exception) {} + + try { + // section:batch + const batch = []; // Array of transfer to create. + const BATCH_SIZE = 8189; + for (let i = 0; i < batch.length; i += BATCH_SIZE) { + const transfers_results = await client.createTransfers( + batch.slice(i, Math.min(batch.length, BATCH_SIZE)), + ); + // Results handling omitted. + } + // endsection:batch + } catch (exception) {} + + try { + // section:transfer-flags-link + const transfer0 = { + id: 4n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: TransferFlags.linked, + timestamp: 0n, + }; + const transfer1 = { + id: 5n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: 0, + timestamp: 0n, + }; + + // Create the transfer + const transfers_results = await client.createTransfers([transfer0, transfer1]); + // Results handling omitted. + // endsection:transfer-flags-link + } catch (exception) {} + + try { + // section:transfer-flags-post + const transfer0 = { + id: 6n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: TransferFlags.pending, + timestamp: 0n, + }; + + let transfers_results = await client.createTransfers([transfer0]); + // Results handling omitted. + + const transfer1 = { + id: 7n, + debit_account_id: 102n, + credit_account_id: 103n, + // Post the entire pending amount. + amount: amount_max, + pending_id: 6n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: TransferFlags.post_pending_transfer, + timestamp: 0n, + }; + + transfers_results = await client.createTransfers([transfer1]); + // Results handling omitted. + // endsection:transfer-flags-post + } catch (exception) {} + + try { + // section:transfer-flags-void + const transfer0 = { + id: 8n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 10n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: TransferFlags.pending, + timestamp: 0n, + }; + + let transfers_results = await client.createTransfers([transfer0]); + // Results handling omitted. + + const transfer1 = { + id: 9n, + debit_account_id: 102n, + credit_account_id: 103n, + amount: 0n, + pending_id: 8n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: TransferFlags.void_pending_transfer, + timestamp: 0n, + }; + + transfers_results = await client.createTransfers([transfer1]); + // Results handling omitted. + // endsection:transfer-flags-void + } catch (exception) {} + + try { + // section:lookup-transfers + const transfers = await client.lookupTransfers([1n, 2n]); + // endsection:lookup-transfers + } catch (exception) {} + + try { + // section:get-account-transfers + const filter = { + account_id: 2n, + user_data_128: 0n, // No filter by UserData. + user_data_64: 0n, + user_data_32: 0, + code: 0, // No filter by Code. + timestamp_min: 0n, // No filter by Timestamp. + timestamp_max: 0n, // No filter by Timestamp. + limit: 10, // Limit to ten transfers at most. + flags: AccountFilterFlags.debits | // Include transfer from the debit side. + AccountFilterFlags.credits | // Include transfer from the credit side. + AccountFilterFlags.reversed, // Sort by timestamp in reverse-chronological order. + }; + + const account_transfers = await client.getAccountTransfers(filter); + // endsection:get-account-transfers + } catch (exception) {} + + try { + // section:get-account-balances + const filter = { + account_id: 2n, + user_data_128: 0n, // No filter by UserData. + user_data_64: 0n, + user_data_32: 0, + code: 0, // No filter by Code. + timestamp_min: 0n, // No filter by Timestamp. + timestamp_max: 0n, // No filter by Timestamp. + limit: 10, // Limit to ten balances at most. + flags: AccountFilterFlags.debits | // Include transfer from the debit side. + AccountFilterFlags.credits | // Include transfer from the credit side. + AccountFilterFlags.reversed, // Sort by timestamp in reverse-chronological order. + }; + + const account_balances = await client.getAccountBalances(filter); + // endsection:get-account-balances + } catch (exception) {} + + try { + // section:query-accounts + const query_filter = { + user_data_128: 1000n, // Filter by UserData. + user_data_64: 100n, + user_data_32: 10, + code: 1, // Filter by Code. + ledger: 0, // No filter by Ledger. + timestamp_min: 0n, // No filter by Timestamp. + timestamp_max: 0n, // No filter by Timestamp. + limit: 10, // Limit to ten accounts at most. + flags: QueryFilterFlags.reversed, // Sort by timestamp in reverse-chronological order. + }; + + const query_accounts = await client.queryAccounts(query_filter); + // endsection:query-accounts + } catch (exception) {} + + try { + // section:query-transfers + const query_filter = { + user_data_128: 1000n, // Filter by UserData. + user_data_64: 100n, + user_data_32: 10, + code: 1, // Filter by Code. + ledger: 0, // No filter by Ledger. + timestamp_min: 0n, // No filter by Timestamp. + timestamp_max: 0n, // No filter by Timestamp. + limit: 10, // Limit to ten transfers at most. + flags: QueryFilterFlags.reversed, // Sort by timestamp in reverse-chronological order. + }; + + const query_transfers = await client.queryTransfers(query_filter); + // endsection:query-transfers + } catch (exception) {} + + try { + // section:linked-events + const batch = []; // Array of transfer to create. + let linkedFlag = 0; + linkedFlag |= TransferFlags.linked; + + // An individual transfer (successful): + batch.push({ id: 1n /* , ... */ }); + + // A chain of 4 transfers (the last transfer in the chain closes the chain with linked=false): + batch.push({ id: 2n, /* ..., */ flags: linkedFlag }); // Commit/rollback. + batch.push({ id: 3n, /* ..., */ flags: linkedFlag }); // Commit/rollback. + batch.push({ id: 2n, /* ..., */ flags: linkedFlag }); // Fail with exists + batch.push({ id: 4n, /* ..., */ flags: 0 }); // Fail without committing. + + // An individual transfer (successful): + // This should not see any effect from the failed chain above. + batch.push({ id: 2n, /* ..., */ flags: 0 }); + + // A chain of 2 transfers (the first transfer fails the chain): + batch.push({ id: 2n, /* ..., */ flags: linkedFlag }); + batch.push({ id: 3n, /* ..., */ flags: 0 }); + + // A chain of 2 transfers (successful): + batch.push({ id: 3n, /* ..., */ flags: linkedFlag }); + batch.push({ id: 4n, /* ..., */ flags: 0 }); + + const transfers_results = await client.createTransfers(batch); + // Results handling omitted. + // endsection:linked-events + } catch (exception) {} + + try { + // section:imported-events + // External source of time. + let historical_timestamp = 0n + // Events loaded from an external source. + const historical_accounts = []; // Loaded from an external source. + const historical_transfers = []; // Loaded from an external source. + + // First, load and import all accounts with their timestamps from the historical source. + const accounts = []; + for (let index = 0; i < historical_accounts.length; i++) { + let account = historical_accounts[i]; + // Set a unique and strictly increasing timestamp. + historical_timestamp += 1; + account.timestamp = historical_timestamp; + // Set the account as `imported`. + account.flags = AccountFlags.imported; + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + if (index < historical_accounts.length - 1) { + account.flags |= AccountFlags.linked; + } + + accounts.push(account); + } + + const account_results = await client.createAccounts(accounts); + // Results handling omitted. + + // Then, load and import all transfers with their timestamps from the historical source. + const transfers = []; + for (let index = 0; i < historical_transfers.length; i++) { + let transfer = historical_transfers[i]; + // Set a unique and strictly increasing timestamp. + historical_timestamp += 1; + transfer.timestamp = historical_timestamp; + // Set the account as `imported`. + transfer.flags = TransferFlags.imported; + // To ensure atomicity, the entire batch (except the last event in the chain) + // must be `linked`. + if (index < historical_transfers.length - 1) { + transfer.flags |= TransferFlags.linked; + } + + transfers.push(transfer); + } + + const transfers_results = await client.createTransfers(transfers); + // Results handling omitted. + + // Since it is a linked chain, in case of any error the entire batch is rolled back and can be retried + // with the same historical timestamps without regressing the cluster timestamp. + // endsection:imported-events + } catch (exception) {} +} + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/ocam/src/clients/node/samples/walkthrough/package-lock.json b/ocam/src/clients/node/samples/walkthrough/package-lock.json new file mode 100644 index 00000000..6a8b34cb --- /dev/null +++ b/ocam/src/clients/node/samples/walkthrough/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "walkthrough", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "tigerbeetle-node": "file:../../" + } + }, + "../..": { + "name": "tigerbeetle-node", + "version": "0.12.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^18.0.0", + "node-api-headers": "^0.0.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/tigerbeetle-node": { + "resolved": "../..", + "link": true + } + } +} diff --git a/ocam/src/clients/node/samples/walkthrough/package.json b/ocam/src/clients/node/samples/walkthrough/package.json new file mode 100644 index 00000000..86318688 --- /dev/null +++ b/ocam/src/clients/node/samples/walkthrough/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "tigerbeetle-node": "file:../../" + } +} diff --git a/ocam/src/clients/node/src/benchmark.ts b/ocam/src/clients/node/src/benchmark.ts new file mode 100644 index 00000000..6147d8a7 --- /dev/null +++ b/ocam/src/clients/node/src/benchmark.ts @@ -0,0 +1,168 @@ +import assert from 'assert' +import { + Account, + createClient, + Transfer, + TransferFlags, +} from '.' + +const MAX_TRANSFERS = 51200 +// CI runs benchmark.ts against a "--development" replica. +const MAX_REQUEST_BATCH_SIZE = 253 +const IS_TWO_PHASE_TRANSFER = false + +const client = createClient({ + cluster_id: 0n, + replica_addresses: [process.env.TB_ADDRESS || '3000'] +}) + +const TRANSFER_SIZE = 128 +const accountA: Account = { + id: 137n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + flags: 0, + timestamp: 0n, +} + +const accountB: Account = { + id: 138n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 1, + flags: 0, + timestamp: 0n, +} + +const runBenchmark = async () => { + console.log(`pre-allocating ${MAX_TRANSFERS} transfers and posts...`) + const transfers: Transfer[][] = [] + const posts: Transfer[][] = [] + + let count = 0 + while (count < MAX_TRANSFERS) { + const pendingBatch: Transfer[] = [] + const postBatch: Transfer[] = [] + for (let i = 0; i < MAX_REQUEST_BATCH_SIZE; i++) { + if (count === MAX_TRANSFERS) break + + count += 1 + pendingBatch.push({ + id: BigInt(count), + debit_account_id: accountA.id, + credit_account_id: accountB.id, + amount: 1n, + pending_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: IS_TWO_PHASE_TRANSFER ? 2 : 0, + code: 1, + ledger: 1, + flags: IS_TWO_PHASE_TRANSFER ? TransferFlags.pending : 0, + timestamp: 0n, + }) + + if (IS_TWO_PHASE_TRANSFER) { + postBatch.push({ + id: BigInt(MAX_TRANSFERS + count), + debit_account_id: accountA.id, + credit_account_id: accountB.id, + amount: 1n, + pending_id: BigInt(count), + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + code: 1, + ledger: 1, + flags: IS_TWO_PHASE_TRANSFER ? TransferFlags.post_pending_transfer : 0, + timestamp: 0n, + }) + } + } + + transfers.push(pendingBatch) + if (IS_TWO_PHASE_TRANSFER) posts.push(postBatch) + } + assert(count === MAX_TRANSFERS) + + console.log(`starting benchmark. MAX_TRANSFERS=${MAX_TRANSFERS} REQUEST_BATCH_SIZE=${MAX_REQUEST_BATCH_SIZE} NUMBER_OF_BATCHES=${transfers.length}`) + let maxCreateTransfersLatency = 0 + let maxCommitTransfersLatency = 0 + const start = Date.now() + + for (let i = 0; i < transfers.length; i++) { + const ms1 = Date.now() + + const transferResults = await client.createTransfers(transfers[i]) + assert(transferResults.length === transfers[i].length) + + const ms2 = Date.now() + const createTransferLatency = ms2 - ms1 + if (createTransferLatency > maxCreateTransfersLatency) { + maxCreateTransfersLatency = createTransferLatency + } + + if (IS_TWO_PHASE_TRANSFER) { + const postTransferResults = await client.createTransfers(posts[i]) + assert(postTransferResults.length === posts[i].length) + + const ms3 = Date.now() + const commitTransferLatency = ms3 - ms2 + if (commitTransferLatency > maxCommitTransfersLatency) { + maxCommitTransfersLatency = commitTransferLatency + } + } + } + + const ms = Date.now() - start + + return { + ms, + maxCommitTransfersLatency, + maxCreateTransfersLatency + } +} + +const main = async () => { + console.log("creating the accounts...") + await client.createAccounts([accountA, accountB]) + const accountResults = await client.lookupAccounts([accountA.id, accountB.id]) + assert(accountResults.length === 2) + assert(accountResults[0].debits_posted === 0n) + assert(accountResults[1].debits_posted === 0n) + + const benchmark = await runBenchmark() + + const accounts = await client.lookupAccounts([accountA.id, accountB.id]) + const result = Math.floor((1000 * MAX_TRANSFERS)/benchmark.ms) + console.log("=============================") + console.log(`${IS_TWO_PHASE_TRANSFER ? 'two-phase ' : ''}transfers per second: ${result}`) + console.log(`create transfers max p100 latency per 10 000 transfers = ${benchmark.maxCreateTransfersLatency}ms`) + console.log(`commit transfers max p100 latency per 10 000 transfers = ${benchmark.maxCommitTransfersLatency}ms`) + assert(accounts.length === 2) + assert(accounts[0].debits_posted === BigInt(MAX_TRANSFERS)) + assert(accounts[1].credits_posted === BigInt(MAX_TRANSFERS)) +} + +main().catch(error => { + console.log(error) +}).finally(async () => { + await client.destroy() +}) diff --git a/ocam/src/clients/node/src/bindings.ts b/ocam/src/clients/node/src/bindings.ts new file mode 100644 index 00000000..10a313f7 --- /dev/null +++ b/ocam/src/clients/node/src/bindings.ts @@ -0,0 +1,924 @@ +/////////////////////////////////////////////////////// +// This file was auto-generated by node_bindings.zig // +// Do not manually modify. // +/////////////////////////////////////////////////////// + + +/** + * See [AccountFlags](https://docs.tigerbeetle.com/reference/account#flags) + */ +export enum AccountFlags { + none = 0, + + /** + * See [linked](https://docs.tigerbeetle.com/reference/account#flagslinked) + */ + linked = (1 << 0), + + /** + * See [debits_must_not_exceed_credits](https://docs.tigerbeetle.com/reference/account#flagsdebits_must_not_exceed_credits) + */ + debits_must_not_exceed_credits = (1 << 1), + + /** + * See [credits_must_not_exceed_debits](https://docs.tigerbeetle.com/reference/account#flagscredits_must_not_exceed_debits) + */ + credits_must_not_exceed_debits = (1 << 2), + + /** + * See [history](https://docs.tigerbeetle.com/reference/account#flagshistory) + */ + history = (1 << 3), + + /** + * See [imported](https://docs.tigerbeetle.com/reference/account#flagsimported) + */ + imported = (1 << 4), + + /** + * See [closed](https://docs.tigerbeetle.com/reference/account#flagsclosed) + */ + closed = (1 << 5), +} + + +/** + * See [TransferFlags](https://docs.tigerbeetle.com/reference/transfer#flags) + */ +export enum TransferFlags { + none = 0, + + /** + * See [linked](https://docs.tigerbeetle.com/reference/transfer#flagslinked) + */ + linked = (1 << 0), + + /** + * See [pending](https://docs.tigerbeetle.com/reference/transfer#flagspending) + */ + pending = (1 << 1), + + /** + * See [post_pending_transfer](https://docs.tigerbeetle.com/reference/transfer#flagspost_pending_transfer) + */ + post_pending_transfer = (1 << 2), + + /** + * See [void_pending_transfer](https://docs.tigerbeetle.com/reference/transfer#flagsvoid_pending_transfer) + */ + void_pending_transfer = (1 << 3), + + /** + * See [balancing_debit](https://docs.tigerbeetle.com/reference/transfer#flagsbalancing_debit) + */ + balancing_debit = (1 << 4), + + /** + * See [balancing_credit](https://docs.tigerbeetle.com/reference/transfer#flagsbalancing_credit) + */ + balancing_credit = (1 << 5), + + /** + * See [closing_debit](https://docs.tigerbeetle.com/reference/transfer#flagsclosing_debit) + */ + closing_debit = (1 << 6), + + /** + * See [closing_credit](https://docs.tigerbeetle.com/reference/transfer#flagsclosing_credit) + */ + closing_credit = (1 << 7), + + /** + * See [imported](https://docs.tigerbeetle.com/reference/transfer#flagsimported) + */ + imported = (1 << 8), +} + + +/** + * See [AccountFilterFlags](https://docs.tigerbeetle.com/reference/account-filter#flags) + */ +export enum AccountFilterFlags { + none = 0, + + /** + * See [debits](https://docs.tigerbeetle.com/reference/account-filter#flagsdebits) + */ + debits = (1 << 0), + + /** + * See [credits](https://docs.tigerbeetle.com/reference/account-filter#flagscredits) + */ + credits = (1 << 1), + + /** + * See [reversed](https://docs.tigerbeetle.com/reference/account-filter#flagsreversed) + */ + reversed = (1 << 2), +} + + +/** + * See [QueryFilterFlags](https://docs.tigerbeetle.com/reference/query-filter#flags) + */ +export enum QueryFilterFlags { + none = 0, + + /** + * See [reversed](https://docs.tigerbeetle.com/reference/query-filter#flagsreversed) + */ + reversed = (1 << 0), +} + + +/** + * See [Account](https://docs.tigerbeetle.com/reference/account/#) + */ +export type Account = { + + /** + * See [id](https://docs.tigerbeetle.com/reference/account/#id) + */ + id: bigint + + /** + * See [debits_pending](https://docs.tigerbeetle.com/reference/account/#debits_pending) + */ + debits_pending: bigint + + /** + * See [debits_posted](https://docs.tigerbeetle.com/reference/account/#debits_posted) + */ + debits_posted: bigint + + /** + * See [credits_pending](https://docs.tigerbeetle.com/reference/account/#credits_pending) + */ + credits_pending: bigint + + /** + * See [credits_posted](https://docs.tigerbeetle.com/reference/account/#credits_posted) + */ + credits_posted: bigint + + /** + * See [user_data_128](https://docs.tigerbeetle.com/reference/account/#user_data_128) + */ + user_data_128: bigint + + /** + * See [user_data_64](https://docs.tigerbeetle.com/reference/account/#user_data_64) + */ + user_data_64: bigint + + /** + * See [user_data_32](https://docs.tigerbeetle.com/reference/account/#user_data_32) + */ + user_data_32: number + + /** + * See [reserved](https://docs.tigerbeetle.com/reference/account/#reserved) + */ + reserved: number + + /** + * See [ledger](https://docs.tigerbeetle.com/reference/account/#ledger) + */ + ledger: number + + /** + * See [code](https://docs.tigerbeetle.com/reference/account/#code) + */ + code: number + + /** + * See [flags](https://docs.tigerbeetle.com/reference/account/#flags) + */ + flags: number + + /** + * See [timestamp](https://docs.tigerbeetle.com/reference/account/#timestamp) + */ + timestamp: bigint +} + + +/** + * See [Transfer](https://docs.tigerbeetle.com/reference/transfer/#) + */ +export type Transfer = { + + /** + * See [id](https://docs.tigerbeetle.com/reference/transfer/#id) + */ + id: bigint + + /** + * See [debit_account_id](https://docs.tigerbeetle.com/reference/transfer/#debit_account_id) + */ + debit_account_id: bigint + + /** + * See [credit_account_id](https://docs.tigerbeetle.com/reference/transfer/#credit_account_id) + */ + credit_account_id: bigint + + /** + * See [amount](https://docs.tigerbeetle.com/reference/transfer/#amount) + */ + amount: bigint + + /** + * See [pending_id](https://docs.tigerbeetle.com/reference/transfer/#pending_id) + */ + pending_id: bigint + + /** + * See [user_data_128](https://docs.tigerbeetle.com/reference/transfer/#user_data_128) + */ + user_data_128: bigint + + /** + * See [user_data_64](https://docs.tigerbeetle.com/reference/transfer/#user_data_64) + */ + user_data_64: bigint + + /** + * See [user_data_32](https://docs.tigerbeetle.com/reference/transfer/#user_data_32) + */ + user_data_32: number + + /** + * See [timeout](https://docs.tigerbeetle.com/reference/transfer/#timeout) + */ + timeout: number + + /** + * See [ledger](https://docs.tigerbeetle.com/reference/transfer/#ledger) + */ + ledger: number + + /** + * See [code](https://docs.tigerbeetle.com/reference/transfer/#code) + */ + code: number + + /** + * See [flags](https://docs.tigerbeetle.com/reference/transfer/#flags) + */ + flags: number + + /** + * See [timestamp](https://docs.tigerbeetle.com/reference/transfer/#timestamp) + */ + timestamp: bigint +} + + +/** + * See [CreateAccountStatus](https://docs.tigerbeetle.com/reference/requests/create_accounts#) + */ +export enum CreateAccountStatus { + + /** + * See [created](https://docs.tigerbeetle.com/reference/requests/create_accounts#created) + */ + created = 0xFFFFFFFF, + + /** + * See [linked_event_failed](https://docs.tigerbeetle.com/reference/requests/create_accounts#linked_event_failed) + */ + linked_event_failed = 1, + + /** + * See [linked_event_chain_open](https://docs.tigerbeetle.com/reference/requests/create_accounts#linked_event_chain_open) + */ + linked_event_chain_open = 2, + + /** + * See [imported_event_expected](https://docs.tigerbeetle.com/reference/requests/create_accounts#imported_event_expected) + */ + imported_event_expected = 22, + + /** + * See [imported_event_not_expected](https://docs.tigerbeetle.com/reference/requests/create_accounts#imported_event_not_expected) + */ + imported_event_not_expected = 23, + + /** + * See [timestamp_must_be_zero](https://docs.tigerbeetle.com/reference/requests/create_accounts#timestamp_must_be_zero) + */ + timestamp_must_be_zero = 3, + + /** + * See [imported_event_timestamp_out_of_range](https://docs.tigerbeetle.com/reference/requests/create_accounts#imported_event_timestamp_out_of_range) + */ + imported_event_timestamp_out_of_range = 24, + + /** + * See [imported_event_timestamp_must_not_advance](https://docs.tigerbeetle.com/reference/requests/create_accounts#imported_event_timestamp_must_not_advance) + */ + imported_event_timestamp_must_not_advance = 25, + + /** + * See [reserved_field](https://docs.tigerbeetle.com/reference/requests/create_accounts#reserved_field) + */ + reserved_field = 4, + + /** + * See [reserved_flag](https://docs.tigerbeetle.com/reference/requests/create_accounts#reserved_flag) + */ + reserved_flag = 5, + + /** + * See [id_must_not_be_zero](https://docs.tigerbeetle.com/reference/requests/create_accounts#id_must_not_be_zero) + */ + id_must_not_be_zero = 6, + + /** + * See [id_must_not_be_int_max](https://docs.tigerbeetle.com/reference/requests/create_accounts#id_must_not_be_int_max) + */ + id_must_not_be_int_max = 7, + + /** + * See [exists_with_different_flags](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists_with_different_flags) + */ + exists_with_different_flags = 15, + + /** + * See [exists_with_different_user_data_128](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists_with_different_user_data_128) + */ + exists_with_different_user_data_128 = 16, + + /** + * See [exists_with_different_user_data_64](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists_with_different_user_data_64) + */ + exists_with_different_user_data_64 = 17, + + /** + * See [exists_with_different_user_data_32](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists_with_different_user_data_32) + */ + exists_with_different_user_data_32 = 18, + + /** + * See [exists_with_different_ledger](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists_with_different_ledger) + */ + exists_with_different_ledger = 19, + + /** + * See [exists_with_different_code](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists_with_different_code) + */ + exists_with_different_code = 20, + + /** + * See [exists](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists) + */ + exists = 21, + + /** + * See [flags_are_mutually_exclusive](https://docs.tigerbeetle.com/reference/requests/create_accounts#flags_are_mutually_exclusive) + */ + flags_are_mutually_exclusive = 8, + + /** + * See [debits_pending_must_be_zero](https://docs.tigerbeetle.com/reference/requests/create_accounts#debits_pending_must_be_zero) + */ + debits_pending_must_be_zero = 9, + + /** + * See [debits_posted_must_be_zero](https://docs.tigerbeetle.com/reference/requests/create_accounts#debits_posted_must_be_zero) + */ + debits_posted_must_be_zero = 10, + + /** + * See [credits_pending_must_be_zero](https://docs.tigerbeetle.com/reference/requests/create_accounts#credits_pending_must_be_zero) + */ + credits_pending_must_be_zero = 11, + + /** + * See [credits_posted_must_be_zero](https://docs.tigerbeetle.com/reference/requests/create_accounts#credits_posted_must_be_zero) + */ + credits_posted_must_be_zero = 12, + + /** + * See [ledger_must_not_be_zero](https://docs.tigerbeetle.com/reference/requests/create_accounts#ledger_must_not_be_zero) + */ + ledger_must_not_be_zero = 13, + + /** + * See [code_must_not_be_zero](https://docs.tigerbeetle.com/reference/requests/create_accounts#code_must_not_be_zero) + */ + code_must_not_be_zero = 14, + + /** + * See [imported_event_timestamp_must_not_regress](https://docs.tigerbeetle.com/reference/requests/create_accounts#imported_event_timestamp_must_not_regress) + */ + imported_event_timestamp_must_not_regress = 26, +} + + +/** + * See [CreateTransferStatus](https://docs.tigerbeetle.com/reference/requests/create_transfers#) + */ +export enum CreateTransferStatus { + + /** + * See [created](https://docs.tigerbeetle.com/reference/requests/create_transfers#created) + */ + created = 0xFFFFFFFF, + + /** + * See [linked_event_failed](https://docs.tigerbeetle.com/reference/requests/create_transfers#linked_event_failed) + */ + linked_event_failed = 1, + + /** + * See [linked_event_chain_open](https://docs.tigerbeetle.com/reference/requests/create_transfers#linked_event_chain_open) + */ + linked_event_chain_open = 2, + + /** + * See [imported_event_expected](https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_expected) + */ + imported_event_expected = 56, + + /** + * See [imported_event_not_expected](https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_not_expected) + */ + imported_event_not_expected = 57, + + /** + * See [timestamp_must_be_zero](https://docs.tigerbeetle.com/reference/requests/create_transfers#timestamp_must_be_zero) + */ + timestamp_must_be_zero = 3, + + /** + * See [imported_event_timestamp_out_of_range](https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_timestamp_out_of_range) + */ + imported_event_timestamp_out_of_range = 58, + + /** + * See [imported_event_timestamp_must_not_advance](https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_timestamp_must_not_advance) + */ + imported_event_timestamp_must_not_advance = 59, + + /** + * See [reserved_flag](https://docs.tigerbeetle.com/reference/requests/create_transfers#reserved_flag) + */ + reserved_flag = 4, + + /** + * See [id_must_not_be_zero](https://docs.tigerbeetle.com/reference/requests/create_transfers#id_must_not_be_zero) + */ + id_must_not_be_zero = 5, + + /** + * See [id_must_not_be_int_max](https://docs.tigerbeetle.com/reference/requests/create_transfers#id_must_not_be_int_max) + */ + id_must_not_be_int_max = 6, + + /** + * See [exists_with_different_flags](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_flags) + */ + exists_with_different_flags = 36, + + /** + * See [exists_with_different_pending_id](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_pending_id) + */ + exists_with_different_pending_id = 40, + + /** + * See [exists_with_different_timeout](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_timeout) + */ + exists_with_different_timeout = 44, + + /** + * See [exists_with_different_debit_account_id](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_debit_account_id) + */ + exists_with_different_debit_account_id = 37, + + /** + * See [exists_with_different_credit_account_id](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_credit_account_id) + */ + exists_with_different_credit_account_id = 38, + + /** + * See [exists_with_different_amount](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_amount) + */ + exists_with_different_amount = 39, + + /** + * See [exists_with_different_user_data_128](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_user_data_128) + */ + exists_with_different_user_data_128 = 41, + + /** + * See [exists_with_different_user_data_64](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_user_data_64) + */ + exists_with_different_user_data_64 = 42, + + /** + * See [exists_with_different_user_data_32](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_user_data_32) + */ + exists_with_different_user_data_32 = 43, + + /** + * See [exists_with_different_ledger](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_ledger) + */ + exists_with_different_ledger = 67, + + /** + * See [exists_with_different_code](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists_with_different_code) + */ + exists_with_different_code = 45, + + /** + * See [exists](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists) + */ + exists = 46, + + /** + * See [id_already_failed](https://docs.tigerbeetle.com/reference/requests/create_transfers#id_already_failed) + */ + id_already_failed = 68, + + /** + * See [flags_are_mutually_exclusive](https://docs.tigerbeetle.com/reference/requests/create_transfers#flags_are_mutually_exclusive) + */ + flags_are_mutually_exclusive = 7, + + /** + * See [debit_account_id_must_not_be_zero](https://docs.tigerbeetle.com/reference/requests/create_transfers#debit_account_id_must_not_be_zero) + */ + debit_account_id_must_not_be_zero = 8, + + /** + * See [debit_account_id_must_not_be_int_max](https://docs.tigerbeetle.com/reference/requests/create_transfers#debit_account_id_must_not_be_int_max) + */ + debit_account_id_must_not_be_int_max = 9, + + /** + * See [credit_account_id_must_not_be_zero](https://docs.tigerbeetle.com/reference/requests/create_transfers#credit_account_id_must_not_be_zero) + */ + credit_account_id_must_not_be_zero = 10, + + /** + * See [credit_account_id_must_not_be_int_max](https://docs.tigerbeetle.com/reference/requests/create_transfers#credit_account_id_must_not_be_int_max) + */ + credit_account_id_must_not_be_int_max = 11, + + /** + * See [accounts_must_be_different](https://docs.tigerbeetle.com/reference/requests/create_transfers#accounts_must_be_different) + */ + accounts_must_be_different = 12, + + /** + * See [pending_id_must_be_zero](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_id_must_be_zero) + */ + pending_id_must_be_zero = 13, + + /** + * See [pending_id_must_not_be_zero](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_id_must_not_be_zero) + */ + pending_id_must_not_be_zero = 14, + + /** + * See [pending_id_must_not_be_int_max](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_id_must_not_be_int_max) + */ + pending_id_must_not_be_int_max = 15, + + /** + * See [pending_id_must_be_different](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_id_must_be_different) + */ + pending_id_must_be_different = 16, + + /** + * See [timeout_reserved_for_pending_transfer](https://docs.tigerbeetle.com/reference/requests/create_transfers#timeout_reserved_for_pending_transfer) + */ + timeout_reserved_for_pending_transfer = 17, + + /** + * See [closing_transfer_must_be_pending](https://docs.tigerbeetle.com/reference/requests/create_transfers#closing_transfer_must_be_pending) + */ + closing_transfer_must_be_pending = 64, + + /** + * See [ledger_must_not_be_zero](https://docs.tigerbeetle.com/reference/requests/create_transfers#ledger_must_not_be_zero) + */ + ledger_must_not_be_zero = 19, + + /** + * See [code_must_not_be_zero](https://docs.tigerbeetle.com/reference/requests/create_transfers#code_must_not_be_zero) + */ + code_must_not_be_zero = 20, + + /** + * See [debit_account_not_found](https://docs.tigerbeetle.com/reference/requests/create_transfers#debit_account_not_found) + */ + debit_account_not_found = 21, + + /** + * See [credit_account_not_found](https://docs.tigerbeetle.com/reference/requests/create_transfers#credit_account_not_found) + */ + credit_account_not_found = 22, + + /** + * See [accounts_must_have_the_same_ledger](https://docs.tigerbeetle.com/reference/requests/create_transfers#accounts_must_have_the_same_ledger) + */ + accounts_must_have_the_same_ledger = 23, + + /** + * See [transfer_must_have_the_same_ledger_as_accounts](https://docs.tigerbeetle.com/reference/requests/create_transfers#transfer_must_have_the_same_ledger_as_accounts) + */ + transfer_must_have_the_same_ledger_as_accounts = 24, + + /** + * See [pending_transfer_not_found](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_not_found) + */ + pending_transfer_not_found = 25, + + /** + * See [pending_transfer_not_pending](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_not_pending) + */ + pending_transfer_not_pending = 26, + + /** + * See [pending_transfer_has_different_debit_account_id](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_has_different_debit_account_id) + */ + pending_transfer_has_different_debit_account_id = 27, + + /** + * See [pending_transfer_has_different_credit_account_id](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_has_different_credit_account_id) + */ + pending_transfer_has_different_credit_account_id = 28, + + /** + * See [pending_transfer_has_different_ledger](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_has_different_ledger) + */ + pending_transfer_has_different_ledger = 29, + + /** + * See [pending_transfer_has_different_code](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_has_different_code) + */ + pending_transfer_has_different_code = 30, + + /** + * See [exceeds_pending_transfer_amount](https://docs.tigerbeetle.com/reference/requests/create_transfers#exceeds_pending_transfer_amount) + */ + exceeds_pending_transfer_amount = 31, + + /** + * See [pending_transfer_has_different_amount](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_has_different_amount) + */ + pending_transfer_has_different_amount = 32, + + /** + * See [pending_transfer_already_posted](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_already_posted) + */ + pending_transfer_already_posted = 33, + + /** + * See [pending_transfer_already_voided](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_already_voided) + */ + pending_transfer_already_voided = 34, + + /** + * See [pending_transfer_expired](https://docs.tigerbeetle.com/reference/requests/create_transfers#pending_transfer_expired) + */ + pending_transfer_expired = 35, + + /** + * See [imported_event_timestamp_must_not_regress](https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_timestamp_must_not_regress) + */ + imported_event_timestamp_must_not_regress = 60, + + /** + * See [imported_event_timestamp_must_postdate_debit_account](https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_timestamp_must_postdate_debit_account) + */ + imported_event_timestamp_must_postdate_debit_account = 61, + + /** + * See [imported_event_timestamp_must_postdate_credit_account](https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_timestamp_must_postdate_credit_account) + */ + imported_event_timestamp_must_postdate_credit_account = 62, + + /** + * See [imported_event_timeout_must_be_zero](https://docs.tigerbeetle.com/reference/requests/create_transfers#imported_event_timeout_must_be_zero) + */ + imported_event_timeout_must_be_zero = 63, + + /** + * See [debit_account_already_closed](https://docs.tigerbeetle.com/reference/requests/create_transfers#debit_account_already_closed) + */ + debit_account_already_closed = 65, + + /** + * See [credit_account_already_closed](https://docs.tigerbeetle.com/reference/requests/create_transfers#credit_account_already_closed) + */ + credit_account_already_closed = 66, + + /** + * See [overflows_debits_pending](https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_debits_pending) + */ + overflows_debits_pending = 47, + + /** + * See [overflows_credits_pending](https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_credits_pending) + */ + overflows_credits_pending = 48, + + /** + * See [overflows_debits_posted](https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_debits_posted) + */ + overflows_debits_posted = 49, + + /** + * See [overflows_credits_posted](https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_credits_posted) + */ + overflows_credits_posted = 50, + + /** + * See [overflows_debits](https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_debits) + */ + overflows_debits = 51, + + /** + * See [overflows_credits](https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_credits) + */ + overflows_credits = 52, + + /** + * See [overflows_timeout](https://docs.tigerbeetle.com/reference/requests/create_transfers#overflows_timeout) + */ + overflows_timeout = 53, + + /** + * See [exceeds_credits](https://docs.tigerbeetle.com/reference/requests/create_transfers#exceeds_credits) + */ + exceeds_credits = 54, + + /** + * See [exceeds_debits](https://docs.tigerbeetle.com/reference/requests/create_transfers#exceeds_debits) + */ + exceeds_debits = 55, +} + +export type CreateAccountResult = { + timestamp: bigint + status: CreateAccountStatus +} + +export type CreateTransferResult = { + timestamp: bigint + status: CreateTransferStatus +} + + +/** + * See [AccountFilter](https://docs.tigerbeetle.com/reference/account-filter#) + */ +export type AccountFilter = { + + /** + * See [account_id](https://docs.tigerbeetle.com/reference/account-filter#account_id) + */ + account_id: bigint + + /** + * See [user_data_128](https://docs.tigerbeetle.com/reference/account-filter#user_data_128) + */ + user_data_128: bigint + + /** + * See [user_data_64](https://docs.tigerbeetle.com/reference/account-filter#user_data_64) + */ + user_data_64: bigint + + /** + * See [user_data_32](https://docs.tigerbeetle.com/reference/account-filter#user_data_32) + */ + user_data_32: number + + /** + * See [code](https://docs.tigerbeetle.com/reference/account-filter#code) + */ + code: number + + /** + * See [timestamp_min](https://docs.tigerbeetle.com/reference/account-filter#timestamp_min) + */ + timestamp_min: bigint + + /** + * See [timestamp_max](https://docs.tigerbeetle.com/reference/account-filter#timestamp_max) + */ + timestamp_max: bigint + + /** + * See [limit](https://docs.tigerbeetle.com/reference/account-filter#limit) + */ + limit: number + + /** + * See [flags](https://docs.tigerbeetle.com/reference/account-filter#flags) + */ + flags: number +} + + +/** + * See [QueryFilter](https://docs.tigerbeetle.com/reference/query-filter#) + */ +export type QueryFilter = { + + /** + * See [user_data_128](https://docs.tigerbeetle.com/reference/query-filter#user_data_128) + */ + user_data_128: bigint + + /** + * See [user_data_64](https://docs.tigerbeetle.com/reference/query-filter#user_data_64) + */ + user_data_64: bigint + + /** + * See [user_data_32](https://docs.tigerbeetle.com/reference/query-filter#user_data_32) + */ + user_data_32: number + + /** + * See [ledger](https://docs.tigerbeetle.com/reference/query-filter#ledger) + */ + ledger: number + + /** + * See [code](https://docs.tigerbeetle.com/reference/query-filter#code) + */ + code: number + + /** + * See [timestamp_min](https://docs.tigerbeetle.com/reference/query-filter#timestamp_min) + */ + timestamp_min: bigint + + /** + * See [timestamp_max](https://docs.tigerbeetle.com/reference/query-filter#timestamp_max) + */ + timestamp_max: bigint + + /** + * See [limit](https://docs.tigerbeetle.com/reference/query-filter#limit) + */ + limit: number + + /** + * See [flags](https://docs.tigerbeetle.com/reference/query-filter#flags) + */ + flags: number +} + + +/** + * See [AccountBalance](https://docs.tigerbeetle.com/reference/account-balances#) + */ +export type AccountBalance = { + + /** + * See [debits_pending](https://docs.tigerbeetle.com/reference/account-balances#debits_pending) + */ + debits_pending: bigint + + /** + * See [debits_posted](https://docs.tigerbeetle.com/reference/account-balances#debits_posted) + */ + debits_posted: bigint + + /** + * See [credits_pending](https://docs.tigerbeetle.com/reference/account-balances#credits_pending) + */ + credits_pending: bigint + + /** + * See [credits_posted](https://docs.tigerbeetle.com/reference/account-balances#credits_posted) + */ + credits_posted: bigint + + /** + * See [timestamp](https://docs.tigerbeetle.com/reference/account-balances#timestamp) + */ + timestamp: bigint +} + +export enum Operation { + pulse = 128, + get_change_events = 137, + lookup_accounts = 140, + lookup_transfers = 141, + get_account_transfers = 142, + get_account_balances = 143, + query_accounts = 144, + query_transfers = 145, + create_accounts = 146, + create_transfers = 147, +} + diff --git a/ocam/src/clients/node/src/c.zig b/ocam/src/clients/node/src/c.zig new file mode 100644 index 00000000..d9bcc158 --- /dev/null +++ b/ocam/src/clients/node/src/c.zig @@ -0,0 +1,3 @@ +pub const c = @cImport({ + @cInclude("node_api.h"); +}); diff --git a/ocam/src/clients/node/src/index.ts b/ocam/src/clients/node/src/index.ts new file mode 100644 index 00000000..d3674638 --- /dev/null +++ b/ocam/src/clients/node/src/index.ts @@ -0,0 +1,232 @@ +export * from './bindings' +import { + Account, + Transfer, + CreateAccountResult, + CreateTransferResult, + Operation, + AccountFilter, + AccountBalance, + QueryFilter, +} from './bindings' +import { randomFillSync } from 'node:crypto' + +const binding: Binding = (() => { + const { arch, platform } = process + + const archMap = { + "arm64": "aarch64", + "x64": "x86_64" + } + + const platformMap = { + "linux": "linux", + "darwin": "macos", + "win32" : "windows", + } + + if (! (arch in archMap)) { + throw new Error(`Unsupported arch: ${arch}`) + } + + if (! (platform in platformMap)) { + throw new Error(`Unsupported platform: ${platform}`) + } + + let linuxABI = '' + + /** + * We need to detect during runtime which libc we're running on to load the correct NAPI. + * binary. + */ + if (platform === 'linux') { + const glibcVersionRuntime = (process.report.getReport() as any).header.glibcVersionRuntime + if (glibcVersionRuntime) { + linuxABI = '-gnu' + } else { + linuxABI = '-musl' + } + } + + const filename = `./bin/${archMap[arch as keyof typeof archMap]}-` + + `${platformMap[platform as keyof typeof platformMap]}${linuxABI}/client.node` + return require(filename) +})() + +export type Context = object // tb_client +export type AccountID = bigint // u128 +export type TransferID = bigint // u128 +export type Event = Account | Transfer | AccountID | TransferID | AccountFilter | QueryFilter +export type Result = CreateAccountResult | CreateTransferResult | Account | Transfer | AccountBalance +export type ResultCallback = (error: Error | null, results: Result[] | null) => void + +export const amount_max: bigint = (2n ** 128n) - 1n + +// Error codes returned by the client. +export const ErrorCodes = { + ERR_CLIENT_CLOSED: 'ERR_CLIENT_CLOSED', + ERR_CLIENT_EVICTED: 'ERR_CLIENT_EVICTED', + ERR_CLIENT_RELEASE_TOO_LOW: 'ERR_CLIENT_RELEASE_TOO_LOW', + ERR_CLIENT_RELEASE_TOO_HIGH: 'ERR_CLIENT_RELEASE_TOO_HIGH', + ERR_TOO_MUCH_DATA: 'ERR_TOO_MUCH_DATA', +} as const; + +export type ErrorCodes = typeof ErrorCodes[keyof typeof ErrorCodes]; + +export class RequestError extends Error { + code: ErrorCodes; + + constructor(code: ErrorCodes) { + super(RequestError.errorMessage(code)); + this.name = 'RequestError'; + this.code = code; + } + + static errorMessage(code: ErrorCodes): string { + switch (code) { + case ErrorCodes.ERR_CLIENT_CLOSED: + return 'Client was closed.' + case ErrorCodes.ERR_CLIENT_EVICTED: + return 'Client was evicted.' + case ErrorCodes.ERR_CLIENT_RELEASE_TOO_LOW: + return 'Client was evicted: release too old.' + case ErrorCodes.ERR_CLIENT_RELEASE_TOO_HIGH: + return 'Client was evicted: release too new.' + case ErrorCodes.ERR_TOO_MUCH_DATA: + return 'Too much data was sent or requested in this batch.' + default: + throw new Error("Unknown error code.") + } + } + +} + +interface BindingInitArgs { + cluster_id: bigint, // u128 + replica_addresses: Buffer, + request_error_class: typeof RequestError, +} + +interface Binding { + init: (args: BindingInitArgs) => Context + submit: (context: Context, operation: Operation, batch: Event[], callback: ResultCallback) => void + deinit: (context: Context) => void, +} + +export interface ClientInitArgs { + cluster_id: bigint, // u128 + replica_addresses: Array, +} + +export interface Client { + createAccounts: (batch: Account[]) => Promise + createTransfers: (batch: Transfer[]) => Promise + lookupAccounts: (batch: AccountID[]) => Promise + lookupTransfers: (batch: TransferID[]) => Promise + getAccountTransfers: (filter: AccountFilter) => Promise + getAccountBalances: (filter: AccountFilter) => Promise + queryAccounts: (filter: QueryFilter) => Promise + queryTransfers: (filter: QueryFilter) => Promise + destroy: () => void +} + +export function createClient (args: ClientInitArgs): Client { + // Context becomes null when `destroy` is called. After that point, further `request` Promises + // throw a shutdown Error. This prevents tb_client calls from happening after tb_client_deinit(). + let context: Context | null = binding.init({ + cluster_id: args.cluster_id, + replica_addresses: Buffer.from(args.replica_addresses.join(',')), + request_error_class: RequestError, + }) + + const destroy = () => { + if (context) binding.deinit(context) + context = null; + } + + const request = (operation: Operation, batch: Event[]): Promise => { + return new Promise((resolve, reject) => { + try { + if (!context) throw new RequestError(ErrorCodes.ERR_CLIENT_CLOSED); + + binding.submit(context, operation, batch, (error, result) => { + if (error) { + reject(error) + } else if (result) { + resolve(result as T[]) + } else { + throw new Error("UB: Binding invoked callback without error or result") + } + }) + } catch (err) { + reject(err) + } + }) + } + + return { + createAccounts(batch) { return request(Operation.create_accounts, batch) }, + createTransfers(batch) { return request(Operation.create_transfers, batch) }, + lookupAccounts(batch) { return request(Operation.lookup_accounts, batch) }, + lookupTransfers(batch) { return request(Operation.lookup_transfers, batch) }, + getAccountTransfers(filter) { return request(Operation.get_account_transfers, [filter]) }, + getAccountBalances(filter) { return request(Operation.get_account_balances, [filter]) }, + queryAccounts(filter) { return request(Operation.query_accounts, [filter]) }, + queryTransfers(filter) { return request(Operation.query_transfers, [filter]) }, + destroy, + } +} + +let idLastTimestamp = 0; + +// These are two references to the same buffer. +// We only need the `Uint8Array` because in Node.js 24, but not earlier, `crypto.randomFillSync` +// rejects `DataView` typed arguments. +const idLastBuffer = new DataView(new ArrayBuffer(16)); +const idLastBufferArray = new Uint8Array( + idLastBuffer.buffer, idLastBuffer.byteOffset, idLastBuffer.byteLength +); + +/** + * Generates a Universally Unique and Sortable Identifier as a u128 bigint. + * + * @remarks + * Based on {@link https://github.com/ulid/spec}, IDs returned are guaranteed to be monotonically + * increasing. + */ +export function id(): bigint { + // Ensure timestamp monotonically increases and generate a new random on each new timestamp. + let timestamp = Date.now() + if (timestamp <= idLastTimestamp) { + timestamp = idLastTimestamp + } else { + idLastTimestamp = timestamp + randomFillSync(idLastBufferArray) + } + + // Increment the u80 in idLastBuffer using carry arithmetic on u32s (as JS doesn't have fast u64). + const littleEndian = true + const randomLo32 = idLastBuffer.getUint32(0, littleEndian) + 1 + const randomHi32 = idLastBuffer.getUint32(4, littleEndian) + (randomLo32 > 0xFFFF_FFFF ? 1 : 0) + const randomHi16 = idLastBuffer.getUint16(8, littleEndian) + (randomHi32 > 0xFFFF_FFFF ? 1 : 0) + if (randomHi16 > 0xFFFF) { + timestamp += 1 + idLastTimestamp = timestamp + + if (timestamp === 0x1_0000_0000_0000) { + throw new Error('timestamp overflow on monotonic increment') + } + } + + // Store the incremented random monotonic and the timestamp into the buffer. + idLastBuffer.setUint32(0, randomLo32 & 0xFFFF_FFFF, littleEndian) + idLastBuffer.setUint32(4, randomHi32 & 0xFFFF_FFFF, littleEndian) + idLastBuffer.setUint16(8, randomHi16, littleEndian) // No need to mask since checked above. + idLastBuffer.setUint16(10, timestamp & 0xFFFF, littleEndian) // timestamp lo. + idLastBuffer.setUint32(12, (timestamp / 0x10000) | 0, littleEndian) // timestamp hi. + + // Then return the buffer's contents as a little-endian u128 bigint. + const lo = idLastBuffer.getBigUint64(0, littleEndian) + const hi = idLastBuffer.getBigUint64(8, littleEndian) + return (hi << 64n) | lo +} diff --git a/ocam/src/clients/node/src/test.ts b/ocam/src/clients/node/src/test.ts new file mode 100644 index 00000000..dd7b2cf2 --- /dev/null +++ b/ocam/src/clients/node/src/test.ts @@ -0,0 +1,1693 @@ +import assert, { AssertionError } from 'assert' +import { + createClient, + Account, + Transfer, + TransferFlags, + CreateAccountStatus, + CreateTransferStatus, + AccountFilter, + AccountFilterFlags, + AccountFlags, + amount_max, + id, + QueryFilter, + QueryFilterFlags, + ErrorCodes, + RequestError, +} from '.' + +async function sleep_ms(ms: number): Promise { + await new Promise(resolve => setTimeout(resolve, ms)) +} + +function range(n: number): number[] { + return Array.from({ length: n }, (_, i) => i); +} + +function random_index(array: Array): number { + return Math.floor(Math.random() * array.length); +} + +const REPLICA_ADDRESSES = [process.env.TB_ADDRESS || '3000']; +const client = createClient({ + cluster_id: 0n, + replica_addresses: REPLICA_ADDRESSES +}) + +// Test data +const accountA: Account = { + id: 17n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 718, + flags: 0, + timestamp: 0n // this will be set correctly by the TigerBeetle server +} +const accountB: Account = { + id: 19n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 719, + flags: 0, + timestamp: 0n // this will be set correctly by the TigerBeetle server +} + +const BATCH_MAX = 8189; + +const tests: Array<{ name: string, fn: () => Promise }> = [] +function test(name: string, fn: () => Promise) { + tests.push({ name, fn }) +} +test.skip = (name: string, fn: () => Promise) => { + console.log(name + ': SKIPPED') +} + +test('Serialization: BigInt exceeds U128', async (): Promise => { + const transfer: Transfer = { + id: 9999999999999999999999999999999999999999n, + debit_account_id: 0n, + credit_account_id: 0n, + amount: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 0n, + timeout: 0, + ledger: 0, + code: 0, + flags: 0, + timestamp: 0n, + }; + + assert.rejects(async() => await client.createTransfers([transfer]), (err) => { + assert.ok(err instanceof Error) + assert.strictEqual(err.message, "id must fit in 128 bits") + return true + }) +}) + +test('Serialization: BigInt negative', async (): Promise => { + const transfer: Transfer = { + id: -1n, + debit_account_id: 0n, + credit_account_id: 0n, + amount: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 0n, + timeout: 0, + ledger: 0, + code: 0, + flags: 0, + timestamp: 0n, + }; + + assert.rejects(async() => await client.createTransfers([transfer]), (err) => { + assert.ok(err instanceof Error) + assert.strictEqual(err.message, "id must be positive") + return true + }) +}) + + +test('id() monotonically increasing', async (): Promise => { + let idA = id(); + for (let i = 0; i < 10_000_000; i++) { + // Ensure ID is monotonic between milliseconds if the loop executes too fast. + if (i % 10_000 == 0) { + await sleep_ms(1) + } + + const idB = id(); + assert.ok(idB > idA, 'id() returned an id that did not monotonically increase'); + idA = idB; + } +}) + +test('range check `code` on Account to be u16', async (): Promise => { + const account = { ...accountA, id: 0n } + + account.code = 65535 + 1 + const codeError = await client.createAccounts([account]).catch(error => error) + assert.strictEqual(codeError.message, 'code must be a u16.') + + const accounts = await client.lookupAccounts([account.id]) + assert.deepStrictEqual(accounts, []) +}) + +test('can create accounts', async (): Promise => { + const account_results = await client.createAccounts([accountA]) + assert.deepStrictEqual(account_results.length, 1) + assert.ok(account_results[0].timestamp > 0) + assert.deepStrictEqual(account_results[0].status, CreateAccountStatus.created) +}) + +test('can return error on account', async (): Promise => { + const account_results = await client.createAccounts([accountA, accountB]) + assert.deepStrictEqual(account_results.length, 2) + assert.ok(account_results[0].timestamp > 0) + assert.deepStrictEqual(account_results[0].status, CreateAccountStatus.exists) + assert.ok(account_results[1].timestamp > 0) + assert.deepStrictEqual(account_results[1].status, CreateAccountStatus.created) +}) + +test('error if timestamp is not set to 0n on account', async (): Promise => { + const account = { ...accountA, timestamp: 2n, id: 3n } + const account_results = await client.createAccounts([account]) + assert.deepStrictEqual(account_results.length, 1) + assert.ok(account_results[0].timestamp > 0) + assert.deepStrictEqual(account_results[0].status, CreateAccountStatus.timestamp_must_be_zero) +}) + +test('batch max size', async (): Promise => { + const BATCH_SIZE = 10_000; + const transfers: Transfer[] = []; + for (let i=0; i await client.createTransfers(transfers), (err) => { + assert.ok(err instanceof RequestError) + assert.strictEqual(err.code, ErrorCodes.ERR_TOO_MUCH_DATA) + return true + }) +}) + +test('batch invalid size', async (): Promise => { + const transfers: Transfer[] = []; + transfers.length = 0xffffffff; + + assert.rejects(async() => await client.createTransfers(transfers), (err) => { + assert.ok(err instanceof RequestError) + assert.strictEqual(err.code, ErrorCodes.ERR_TOO_MUCH_DATA) + return true + }) +}) + +test('can lookup accounts', async (): Promise => { + const accounts = await client.lookupAccounts([accountA.id, accountB.id]) + + assert.strictEqual(accounts.length, 2) + const account1 = accounts[0] + assert.strictEqual(account1.id, 17n) + assert.strictEqual(account1.credits_posted, 0n) + assert.strictEqual(account1.credits_pending, 0n) + assert.strictEqual(account1.debits_posted, 0n) + assert.strictEqual(account1.debits_pending, 0n) + assert.strictEqual(account1.user_data_128, 0n) + assert.strictEqual(account1.user_data_64, 0n) + assert.strictEqual(account1.user_data_32, 0) + assert.strictEqual(account1.code, 718) + assert.strictEqual(account1.ledger, 1) + assert.strictEqual(account1.flags, 0) + assert.ok(account1.timestamp > 0n) + + const account2 = accounts[1] + assert.strictEqual(account2.id, 19n) + assert.strictEqual(account2.credits_posted, 0n) + assert.strictEqual(account2.credits_pending, 0n) + assert.strictEqual(account2.debits_posted, 0n) + assert.strictEqual(account2.debits_pending, 0n) + assert.strictEqual(account2.user_data_128, 0n) + assert.strictEqual(account2.user_data_64, 0n) + assert.strictEqual(account2.user_data_32, 0) + assert.strictEqual(account2.code, 719) + assert.strictEqual(account2.ledger, 1) + assert.strictEqual(account2.flags, 0) + assert.ok(account2.timestamp > 0n) +}) + +test('can create a transfer', async (): Promise => { + const transfer: Transfer = { + id: 1n, + debit_account_id: accountB.id, + credit_account_id: accountA.id, + amount: 100n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 0n, + timeout: 0, + ledger: 1, + code: 1, + flags: 0, + timestamp: 0n, // this will be set correctly by the TigerBeetle server + } + + const transfers_results = await client.createTransfers([transfer]) + assert.deepStrictEqual(transfers_results.length, 1) + assert.ok(transfers_results[0].timestamp > 0) + assert.deepStrictEqual(transfers_results[0].status, CreateTransferStatus.created) + + const accounts = await client.lookupAccounts([accountA.id, accountB.id]) + assert.strictEqual(accounts.length, 2) + assert.strictEqual(accounts[0].credits_posted, 100n) + assert.strictEqual(accounts[0].credits_pending, 0n) + assert.strictEqual(accounts[0].debits_posted, 0n) + assert.strictEqual(accounts[0].debits_pending, 0n) + + assert.strictEqual(accounts[1].credits_posted, 0n) + assert.strictEqual(accounts[1].credits_pending, 0n) + assert.strictEqual(accounts[1].debits_posted, 100n) + assert.strictEqual(accounts[1].debits_pending, 0n) +}) + +test('can create a two-phase transfer', async (): Promise => { + let flags = 0 + flags |= TransferFlags.pending + const transfer: Transfer = { + id: 2n, + debit_account_id: accountB.id, + credit_account_id: accountA.id, + amount: 50n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 0n, + timeout: 2e9, + ledger: 1, + code: 1, + flags, + timestamp: 0n, // this will be set correctly by the TigerBeetle server + } + + const transfers_results = await client.createTransfers([transfer]) + assert.deepStrictEqual(transfers_results.length, 1) + assert.ok(transfers_results[0].timestamp > 0) + assert.deepStrictEqual(transfers_results[0].status, CreateTransferStatus.created) + + const accounts = await client.lookupAccounts([accountA.id, accountB.id]) + assert.strictEqual(accounts.length, 2) + assert.strictEqual(accounts[0].credits_posted, 100n) + assert.strictEqual(accounts[0].credits_pending, 50n) + assert.strictEqual(accounts[0].debits_posted, 0n) + assert.strictEqual(accounts[0].debits_pending, 0n) + + assert.strictEqual(accounts[1].credits_posted, 0n) + assert.strictEqual(accounts[1].credits_pending, 0n) + assert.strictEqual(accounts[1].debits_posted, 100n) + assert.strictEqual(accounts[1].debits_pending, 50n) + + // Lookup the transfer: + const transfers = await client.lookupTransfers([transfer.id]) + assert.strictEqual(transfers.length, 1) + assert.strictEqual(transfers[0].id, 2n) + assert.strictEqual(transfers[0].debit_account_id, accountB.id) + assert.strictEqual(transfers[0].credit_account_id, accountA.id) + assert.strictEqual(transfers[0].amount, 50n) + assert.strictEqual(transfers[0].user_data_128, 0n) + assert.strictEqual(transfers[0].user_data_64, 0n) + assert.strictEqual(transfers[0].user_data_32, 0) + assert.strictEqual(transfers[0].timeout > 0, true) + assert.strictEqual(transfers[0].code, 1) + assert.strictEqual(transfers[0].flags, 2) + assert.strictEqual(transfers[0].timestamp > 0, true) +}) + +test('can post a two-phase transfer', async (): Promise => { + let flags = 0 + flags |= TransferFlags.post_pending_transfer + + const commit: Transfer = { + id: 3n, + debit_account_id: BigInt(0), + credit_account_id: BigInt(0), + amount: amount_max, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 2n,// must match the id of the pending transfer + timeout: 0, + ledger: 1, + code: 1, + flags: flags, + timestamp: 0n, // this will be set correctly by the TigerBeetle server + } + + const transfers_results = await client.createTransfers([commit]) + assert.deepStrictEqual(transfers_results.length, 1) + assert.ok(transfers_results[0].timestamp > 0) + assert.deepStrictEqual(transfers_results[0].status, CreateTransferStatus.created) + + const accounts = await client.lookupAccounts([accountA.id, accountB.id]) + assert.strictEqual(accounts.length, 2) + assert.strictEqual(accounts[0].credits_posted, 150n) + assert.strictEqual(accounts[0].credits_pending, 0n) + assert.strictEqual(accounts[0].debits_posted, 0n) + assert.strictEqual(accounts[0].debits_pending, 0n) + + assert.strictEqual(accounts[1].credits_posted, 0n) + assert.strictEqual(accounts[1].credits_pending, 0n) + assert.strictEqual(accounts[1].debits_posted, 150n) + assert.strictEqual(accounts[1].debits_pending, 0n) +}) + +test('can reject a two-phase transfer', async (): Promise => { + // Create a two-phase transfer: + const transfer: Transfer = { + id: 4n, + debit_account_id: accountB.id, + credit_account_id: accountA.id, + amount: 50n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 0n, + timeout: 1e9, + ledger: 1, + code: 1, + flags: TransferFlags.pending, + timestamp: 0n, // this will be set correctly by the TigerBeetle server + } + let transfers_results = await client.createTransfers([transfer]) + assert.deepStrictEqual(transfers_results.length, 1) + assert.ok(transfers_results[0].timestamp > 0) + assert.deepStrictEqual(transfers_results[0].status, CreateTransferStatus.created) + + // send in the reject + const reject: Transfer = { + id: 5n, + debit_account_id: BigInt(0), + credit_account_id: BigInt(0), + amount: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 4n, // must match the id of the pending transfer + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.void_pending_transfer, + timestamp: 0n, // this will be set correctly by the TigerBeetle server + } + + transfers_results = await client.createTransfers([reject]) + assert.deepStrictEqual(transfers_results.length, 1) + assert.ok(transfers_results[0].timestamp > 0) + assert.deepStrictEqual(transfers_results[0].status, CreateTransferStatus.created) + + const accounts = await client.lookupAccounts([accountA.id, accountB.id]) + assert.strictEqual(accounts.length, 2) + assert.strictEqual(accounts[0].credits_posted, 150n) + assert.strictEqual(accounts[0].credits_pending, 0n) + assert.strictEqual(accounts[0].debits_posted, 0n) + assert.strictEqual(accounts[0].debits_pending, 0n) + + assert.strictEqual(accounts[1].credits_posted, 0n) + assert.strictEqual(accounts[1].credits_pending, 0n) + assert.strictEqual(accounts[1].debits_posted, 150n) + assert.strictEqual(accounts[1].debits_pending, 0n) +}) + +test('can link transfers', async (): Promise => { + const transfer1: Transfer = { + id: 6n, + debit_account_id: accountB.id, + credit_account_id: accountA.id, + amount: 100n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 0n, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.linked, // points to transfer2 + timestamp: 0n, // will be set correctly by the TigerBeetle server + } + const transfer2: Transfer = { + id: 6n, + debit_account_id: accountB.id, + credit_account_id: accountA.id, + amount: 100n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 0n, + timeout: 0, + ledger: 1, + code: 1, + // Does not have linked flag as it is the end of the chain. + // This will also cause it to fail as this is now a duplicate with different flags + flags: 0, + timestamp: 0n, // will be set correctly by the TigerBeetle server + } + + const transfers_results = await client.createTransfers([transfer1, transfer2]) + assert.deepStrictEqual(transfers_results.length, 2) + assert.ok(transfers_results[0].timestamp > 0) + assert.deepStrictEqual(transfers_results[0].status, CreateTransferStatus.linked_event_failed) + assert.ok(transfers_results[1].timestamp > 0) + assert.deepStrictEqual(transfers_results[1].status, CreateTransferStatus.exists_with_different_flags) + + const accounts = await client.lookupAccounts([accountA.id, accountB.id]) + assert.strictEqual(accounts.length, 2) + assert.strictEqual(accounts[0].credits_posted, 150n) + assert.strictEqual(accounts[0].credits_pending, 0n) + assert.strictEqual(accounts[0].debits_posted, 0n) + assert.strictEqual(accounts[0].debits_pending, 0n) + + assert.strictEqual(accounts[1].credits_posted, 0n) + assert.strictEqual(accounts[1].credits_pending, 0n) + assert.strictEqual(accounts[1].debits_posted, 150n) + assert.strictEqual(accounts[1].debits_pending, 0n) +}) + +test('cannot void an expired transfer', async (): Promise => { + // Create a two-phase transfer: + const transfer: Transfer = { + id: 6n, + debit_account_id: accountB.id, + credit_account_id: accountA.id, + amount: 50n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 0n, + timeout: 1, + ledger: 1, + code: 1, + flags: TransferFlags.pending, + timestamp: 0n, // this will be set correctly by the TigerBeetle server + } + let transfers_results = await client.createTransfers([transfer]) + assert.deepStrictEqual(transfers_results.length, 1) + assert.ok(transfers_results[0].timestamp > 0) + assert.deepStrictEqual(transfers_results[0].status, CreateTransferStatus.created) + + let accounts = await client.lookupAccounts([accountA.id, accountB.id]) + assert.strictEqual(accounts.length, 2) + assert.strictEqual(accounts[0].credits_posted, 150n) + assert.strictEqual(accounts[0].credits_pending, 50n) + assert.strictEqual(accounts[0].debits_posted, 0n) + assert.strictEqual(accounts[0].debits_pending, 0n) + + assert.strictEqual(accounts[1].credits_posted, 0n) + assert.strictEqual(accounts[1].credits_pending, 0n) + assert.strictEqual(accounts[1].debits_posted, 150n) + assert.strictEqual(accounts[1].debits_pending, 50n) + + // We need to wait 1s for the server to expire the transfer, however the + // server can pulse the expiry operation anytime after the timeout, + // so adding an extra delay to avoid flaky tests. + const extra_wait_time = 500; + await sleep_ms((transfer.timeout * 1000) + extra_wait_time); + + // Looking up the accounts again for the updated balance. + accounts = await client.lookupAccounts([accountA.id, accountB.id]) + assert.strictEqual(accounts.length, 2) + assert.strictEqual(accounts[0].credits_posted, 150n) + assert.strictEqual(accounts[0].credits_pending, 0n) + assert.strictEqual(accounts[0].debits_posted, 0n) + assert.strictEqual(accounts[0].debits_pending, 0n) + + assert.strictEqual(accounts[1].credits_posted, 0n) + assert.strictEqual(accounts[1].credits_pending, 0n) + assert.strictEqual(accounts[1].debits_posted, 150n) + assert.strictEqual(accounts[1].debits_pending, 0n) + + // send in the reject + const reject: Transfer = { + id: 7n, + debit_account_id: BigInt(0), + credit_account_id: BigInt(0), + amount: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 6n, // must match the id of the pending transfer + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.void_pending_transfer, + timestamp: 0n, // this will be set correctly by the TigerBeetle server + } + + transfers_results = await client.createTransfers([reject]) + assert.deepStrictEqual(transfers_results.length, 1) + assert.ok(transfers_results[0].timestamp > 0) + assert.deepStrictEqual(transfers_results[0].status, CreateTransferStatus.pending_transfer_expired) +}) + +test('can close accounts', async (): Promise => { + const closing_transfer: Transfer = { + id: id(), + debit_account_id: accountB.id, + credit_account_id: accountA.id, + amount: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 0n, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.closing_debit | TransferFlags.closing_credit | TransferFlags.pending, + timestamp: 0n, // will be set correctly by the TigerBeetle server + } + let transfers_results = await client.createTransfers([closing_transfer]) + assert.deepStrictEqual(transfers_results.length, 1) + assert.ok(transfers_results[0].timestamp > 0) + assert.deepStrictEqual(transfers_results[0].status, CreateTransferStatus.created) + + let accounts = await client.lookupAccounts([accountA.id, accountB.id]) + assert.strictEqual(accounts.length, 2) + assert.ok(accountA.flags != accounts[0].flags) + assert.ok((accounts[0].flags & AccountFlags.closed) != 0) + + assert.ok(accountB.flags != accounts[1].flags) + assert.ok((accounts[1].flags & AccountFlags.closed) != 0) + + const voiding_transfer: Transfer = { + id: id(), + debit_account_id: accountB.id, + credit_account_id: accountA.id, + amount: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.void_pending_transfer, + pending_id: closing_transfer.id, + timestamp: 0n, // will be set correctly by the TigerBeetle server + } + + transfers_results = await client.createTransfers([voiding_transfer]) + assert.deepStrictEqual(transfers_results.length, 1) + assert.ok(transfers_results[0].timestamp > 0) + assert.deepStrictEqual(transfers_results[0].status, CreateTransferStatus.created) + + accounts = await client.lookupAccounts([accountA.id, accountB.id]) + assert.strictEqual(accounts.length, 2) + assert.strictEqual(accountA.flags, accounts[0].flags) + assert.ok((accounts[0].flags & AccountFlags.closed) == 0) + + assert.strictEqual(accountB.flags, accounts[1].flags) + assert.ok((accounts[1].flags & AccountFlags.closed) == 0) +}) + +test('can get account transfers', async (): Promise => { + const accountC: Account = { + id: 21n, + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 718, + flags: AccountFlags.history, + timestamp: 0n + } + const account_results = await client.createAccounts([accountC]) + assert.deepStrictEqual(account_results.length, 1) + assert.ok(account_results[0].timestamp > 0) + assert.deepStrictEqual(account_results[0].status, CreateAccountStatus.created) + + const transfers_created : Transfer[] = []; + // Create transfers where the new account is either the debit or credit account: + for (let i=0; i<10;i++) { + transfers_created.push({ + id: BigInt(i + 10000), + debit_account_id: i % 2 == 0 ? accountC.id : accountA.id, + credit_account_id: i % 2 == 0 ? accountB.id : accountC.id, + amount: 100n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 0n, + timeout: 0, + ledger: 1, + code: 1, + flags: 0, + timestamp: 0n, + }); + } + + const transfers_results = await client.createTransfers(transfers_created) + assert.deepStrictEqual(transfers_results.length, transfers_created.length) + for (const result of transfers_results) { + assert.ok(result.timestamp > 0) + assert.deepStrictEqual(result.status, CreateTransferStatus.created) + } + + // Query all transfers for accountC: + let filter: AccountFilter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits, + } + let transfers = await client.getAccountTransfers(filter) + let account_balances = await client.getAccountBalances(filter) + assert.strictEqual(transfers.length, transfers_created.length) + assert.strictEqual(account_balances.length, transfers.length) + + let timestamp = 0n; + let i = 0; + for (const transfer of transfers) { + assert.ok(timestamp < transfer.timestamp); + timestamp = transfer.timestamp; + + assert.ok(account_balances[i].timestamp == transfer.timestamp); + i++; + } + + // Query only the debit transfers for accountC, descending: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: AccountFilterFlags.debits | AccountFilterFlags.reversed, + } + transfers = await client.getAccountTransfers(filter) + account_balances = await client.getAccountBalances(filter) + + assert.strictEqual(transfers.length, transfers_created.length / 2) + assert.strictEqual(account_balances.length, transfers.length) + + timestamp = 1n << 64n; + i = 0; + for (const transfer of transfers) { + assert.ok(transfer.timestamp < timestamp); + timestamp = transfer.timestamp; + + assert.ok(account_balances[i].timestamp == transfer.timestamp); + i++; + } + + // Query only the credit transfers for accountC, descending: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: AccountFilterFlags.credits | AccountFilterFlags.reversed, + } + transfers = await client.getAccountTransfers(filter) + account_balances = await client.getAccountBalances(filter) + + assert.strictEqual(transfers.length, transfers_created.length / 2) + assert.strictEqual(account_balances.length, transfers.length) + + timestamp = 1n << 64n; + i = 0; + for (const transfer of transfers) { + assert.ok(transfer.timestamp < timestamp); + timestamp = transfer.timestamp; + + assert.ok(account_balances[i].timestamp == transfer.timestamp); + i++; + } + + // Query the first 5 transfers for accountC: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: transfers_created.length / 2, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits, + } + transfers = await client.getAccountTransfers(filter) + account_balances = await client.getAccountBalances(filter) + + assert.strictEqual(transfers.length, transfers_created.length / 2) + assert.strictEqual(account_balances.length, transfers.length) + + timestamp = 0n; + i = 0; + for (const transfer of transfers) { + assert.ok(timestamp < transfer.timestamp); + timestamp = transfer.timestamp; + + assert.ok(account_balances[i].timestamp == transfer.timestamp); + i++; + } + + // Query the next 5 transfers for accountC, with pagination: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: timestamp + 1n, + timestamp_max: 0n, + limit: transfers_created.length / 2, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits, + } + transfers = await client.getAccountTransfers(filter) + account_balances = await client.getAccountBalances(filter) + + assert.strictEqual(transfers.length, transfers_created.length / 2) + assert.strictEqual(account_balances.length, transfers.length) + + i = 0; + for (const transfer of transfers) { + assert.ok(timestamp < transfer.timestamp); + timestamp = transfer.timestamp; + + assert.ok(account_balances[i].timestamp == transfer.timestamp); + i++; + } + + // Query again, no more transfers should be found: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: timestamp + 1n, + timestamp_max: 0n, + limit: transfers_created.length / 2, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits, + } + transfers = await client.getAccountTransfers(filter) + account_balances = await client.getAccountBalances(filter) + + assert.deepStrictEqual(transfers, []) + assert.strictEqual(account_balances.length, transfers.length) + + // Query the first 5 transfers for accountC ORDER BY DESC: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: transfers_created.length / 2, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits | AccountFilterFlags.reversed, + } + transfers = await client.getAccountTransfers(filter) + account_balances = await client.getAccountBalances(filter) + + assert.strictEqual(transfers.length, transfers_created.length / 2) + assert.strictEqual(account_balances.length, transfers.length) + + timestamp = 1n << 64n; + i = 0; + for (const transfer of transfers) { + assert.ok(timestamp > transfer.timestamp); + timestamp = transfer.timestamp; + + assert.ok(account_balances[i].timestamp == transfer.timestamp); + i++; + } + + // Query the next 5 transfers for accountC, with pagination: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: timestamp - 1n, + limit: transfers_created.length / 2, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits | AccountFilterFlags.reversed, + } + transfers = await client.getAccountTransfers(filter) + account_balances = await client.getAccountBalances(filter) + + assert.strictEqual(transfers.length, transfers_created.length / 2) + assert.strictEqual(account_balances.length, transfers.length) + + i = 0; + for (const transfer of transfers) { + assert.ok(timestamp > transfer.timestamp); + timestamp = transfer.timestamp; + + assert.ok(account_balances[i].timestamp == transfer.timestamp); + i++; + } + + // Query again, no more transfers should be found: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: timestamp - 1n, + limit: transfers_created.length / 2, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits | AccountFilterFlags.reversed, + } + transfers = await client.getAccountTransfers(filter) + account_balances = await client.getAccountBalances(filter) + + assert.deepStrictEqual(transfers, []) + assert.strictEqual(account_balances.length, transfers.length) + + // Invalid account: + filter = { + account_id: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits, + } + assert.deepStrictEqual((await client.getAccountTransfers(filter)), []) + assert.deepStrictEqual((await client.getAccountBalances(filter)), []) + + // Invalid timestamp min: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: (1n << 64n) - 1n, // ulong max value + timestamp_max: 0n, + limit: BATCH_MAX, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits, + } + assert.deepStrictEqual((await client.getAccountTransfers(filter)), []) + assert.deepStrictEqual((await client.getAccountBalances(filter)), []) + + // Invalid timestamp max: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: (1n << 64n) - 1n, // ulong max value + limit: BATCH_MAX, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits, + } + assert.deepStrictEqual((await client.getAccountTransfers(filter)), []) + assert.deepStrictEqual((await client.getAccountBalances(filter)), []) + + // Invalid timestamp range: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: (1n << 64n) - 2n, // ulong max - 1 + timestamp_max: 1n, + limit: BATCH_MAX, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits, + } + assert.deepStrictEqual((await client.getAccountTransfers(filter)), []) + assert.deepStrictEqual((await client.getAccountBalances(filter)), []) + + // Zero limit: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: 0, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits, + } + assert.deepStrictEqual((await client.getAccountTransfers(filter)), []) + assert.deepStrictEqual((await client.getAccountBalances(filter)), []) + + // TooMuchData + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: 10_000, + flags: AccountFilterFlags.credits | AccountFilterFlags.debits, + } + assert.rejects(async() => await client.getAccountTransfers(filter), (err) => { + assert.ok(err instanceof RequestError) + assert.strictEqual(err.code, ErrorCodes.ERR_TOO_MUCH_DATA) + return true + }) + assert.rejects(async() => await client.getAccountBalances(filter), (err) => { + assert.ok(err instanceof RequestError) + assert.strictEqual(err.code, ErrorCodes.ERR_TOO_MUCH_DATA) + return true + }) + + // Empty flags: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: AccountFilterFlags.none, + } + assert.deepStrictEqual((await client.getAccountTransfers(filter)), []) + assert.deepStrictEqual((await client.getAccountBalances(filter)), []) + + // Invalid flags: + filter = { + account_id: accountC.id, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: 0xFFFF, + } + assert.deepStrictEqual((await client.getAccountTransfers(filter)), []) + assert.deepStrictEqual((await client.getAccountBalances(filter)), []) + +}) + +test('can query accounts', async (): Promise => { + { + const accounts : Account[] = []; + // Create transfers: + for (let i=0; i<10;i++) { + accounts.push({ + id: id(), + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: i % 2 == 0 ? 1000n : 2000n, + user_data_64: i % 2 == 0 ? 100n : 200n, + user_data_32: i % 2 == 0 ? 10 : 20, + ledger: 1, + code: 999, + flags: AccountFlags.none, + reserved: 0, + timestamp: 0n, + }) + } + + const account_results = await client.createAccounts(accounts) + assert.deepStrictEqual(account_results.length, accounts.length) + for (const result of account_results) { + assert.ok(result.timestamp > 0) + assert.deepStrictEqual(result.status, CreateAccountStatus.created) + } + } + + { + // Querying accounts where: + // `user_data_128=1000 AND user_data_64=100 AND user_data_32=10 + // AND code=999 AND ledger=1 ORDER BY timestamp ASC`. + const filter: QueryFilter = { + user_data_128: 1000n, + user_data_64: 100n, + user_data_32: 10, + ledger: 1, + code: 999, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: QueryFilterFlags.none, + } + const query: Account[] = await client.queryAccounts(filter) + assert.strictEqual(query.length, 5) + + let timestamp = 0n; + for (const account of query) { + assert.ok(timestamp < account.timestamp); + timestamp = account.timestamp; + + assert.strictEqual(account.user_data_128, filter.user_data_128); + assert.strictEqual(account.user_data_64, filter.user_data_64); + assert.strictEqual(account.user_data_32, filter.user_data_32); + assert.strictEqual(account.ledger, filter.ledger); + assert.strictEqual(account.code, filter.code); + } + } + + { + // Querying accounts where: + // `user_data_128=2000 AND user_data_64=200 AND user_data_32=20 + // AND code=999 AND ledger=1 ORDER BY timestamp DESC`. + const filter: QueryFilter = { + user_data_128: 2000n, + user_data_64: 200n, + user_data_32: 20, + ledger: 1, + code: 999, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: QueryFilterFlags.reversed, + } + const query: Account[] = await client.queryAccounts(filter) + assert.strictEqual(query.length, 5) + + let timestamp = 1n << 64n; + for (const account of query) { + assert.ok(timestamp > account.timestamp); + timestamp = account.timestamp; + + assert.strictEqual(account.user_data_128, filter.user_data_128); + assert.strictEqual(account.user_data_64, filter.user_data_64); + assert.strictEqual(account.user_data_32, filter.user_data_32); + assert.strictEqual(account.ledger, filter.ledger); + assert.strictEqual(account.code, filter.code); + } + } + + { + // Querying accounts where: + // `code=999 ORDER BY timestamp ASC` + const filter: QueryFilter = { + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + ledger: 0, + code: 999, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: QueryFilterFlags.none, + } + const query: Account[] = await client.queryAccounts(filter) + assert.strictEqual(query.length, 10) + + let timestamp = 0n; + for (const account of query) { + assert.ok(timestamp < account.timestamp); + timestamp = account.timestamp; + + assert.strictEqual(account.code, filter.code); + } + } + + { + // Querying accounts where: + // `code=999 ORDER BY timestamp DESC LIMIT 5`. + const filter: QueryFilter = { + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + ledger: 0, + code: 999, + timestamp_min: 0n, + timestamp_max: 0n, + limit: 5, + flags: QueryFilterFlags.reversed, + } + + // First 5 items: + let query: Account[] = await client.queryAccounts(filter) + assert.strictEqual(query.length, 5) + + let timestamp = 1n << 64n; + for (const account of query) { + assert.ok(timestamp > account.timestamp); + timestamp = account.timestamp; + + assert.strictEqual(account.code, filter.code); + } + + // Next 5 items: + filter.timestamp_max = timestamp - 1n + query = await client.queryAccounts(filter) + assert.strictEqual(query.length, 5) + + for (const account of query) { + assert.ok(timestamp > account.timestamp); + timestamp = account.timestamp; + + assert.strictEqual(account.code, filter.code); + } + + // No more results: + filter.timestamp_max = timestamp - 1n + query = await client.queryAccounts(filter) + assert.strictEqual(query.length, 0) + } + + { + // Not found: + const filter: QueryFilter = { + user_data_128: 0n, + user_data_64: 200n, + user_data_32: 10, + ledger: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: QueryFilterFlags.none, + } + const query: Account[] = await client.queryAccounts(filter) + assert.strictEqual(query.length, 0) + } +}) + +test('can query transfers', async (): Promise => { + { + const account: Account = { + id: id(), + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 718, + flags: AccountFlags.none, + timestamp: 0n + } + const account_results = await client.createAccounts([account]) + assert.deepStrictEqual(account_results.length, 1) + assert.ok(account_results[0].timestamp > 0) + assert.deepStrictEqual(account_results[0].status, CreateAccountStatus.created) + + const transfers_created : Transfer[] = []; + // Create transfers: + for (let i=0; i<10;i++) { + transfers_created.push({ + id: id(), + debit_account_id: i % 2 == 0 ? account.id : accountA.id, + credit_account_id: i % 2 == 0 ? accountB.id : account.id, + amount: 100n, + user_data_128: i % 2 == 0 ? 1000n : 2000n, + user_data_64: i % 2 == 0 ? 100n : 200n, + user_data_32: i % 2 == 0 ? 10 : 20, + pending_id: 0n, + timeout: 0, + ledger: 1, + code: 999, + flags: 0, + timestamp: 0n, + }) + } + + const transfers_results = await client.createTransfers(transfers_created) + assert.deepStrictEqual(transfers_results.length, transfers_created.length) + for (const result of transfers_results) { + assert.ok(result.timestamp > 0) + assert.deepStrictEqual(result.status, CreateTransferStatus.created) + } + } + + { + // Querying transfers where: + // `user_data_128=1000 AND user_data_64=100 AND user_data_32=10 + // AND code=999 AND ledger=1 ORDER BY timestamp ASC`. + const filter: QueryFilter = { + user_data_128: 1000n, + user_data_64: 100n, + user_data_32: 10, + ledger: 1, + code: 999, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: QueryFilterFlags.none, + } + const query: Transfer[] = await client.queryTransfers(filter) + assert.strictEqual(query.length, 5) + + let timestamp = 0n; + for (const transfer of query) { + assert.ok(timestamp < transfer.timestamp); + timestamp = transfer.timestamp; + + assert.strictEqual(transfer.user_data_128, filter.user_data_128); + assert.strictEqual(transfer.user_data_64, filter.user_data_64); + assert.strictEqual(transfer.user_data_32, filter.user_data_32); + assert.strictEqual(transfer.ledger, filter.ledger); + assert.strictEqual(transfer.code, filter.code); + } + } + + { + // Querying transfers where: + // `user_data_128=2000 AND user_data_64=200 AND user_data_32=20 + // AND code=999 AND ledger=1 ORDER BY timestamp DESC`. + const filter: QueryFilter = { + user_data_128: 2000n, + user_data_64: 200n, + user_data_32: 20, + ledger: 1, + code: 999, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: QueryFilterFlags.reversed, + } + const query: Transfer[] = await client.queryTransfers(filter) + assert.strictEqual(query.length, 5) + + let timestamp = 1n << 64n; + for (const transfer of query) { + assert.ok(timestamp > transfer.timestamp); + timestamp = transfer.timestamp; + + assert.strictEqual(transfer.user_data_128, filter.user_data_128); + assert.strictEqual(transfer.user_data_64, filter.user_data_64); + assert.strictEqual(transfer.user_data_32, filter.user_data_32); + assert.strictEqual(transfer.ledger, filter.ledger); + assert.strictEqual(transfer.code, filter.code); + } + } + + { + // Querying transfers where: + // `code=999 ORDER BY timestamp ASC` + const filter: QueryFilter = { + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + ledger: 0, + code: 999, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: QueryFilterFlags.none, + } + const query: Transfer[] = await client.queryTransfers(filter) + assert.strictEqual(query.length, 10) + + let timestamp = 0n; + for (const transfer of query) { + assert.ok(timestamp < transfer.timestamp); + timestamp = transfer.timestamp; + + assert.strictEqual(transfer.code, filter.code); + } + } + + { + // Querying transfers where: + // `code=999 ORDER BY timestamp DESC LIMIT 5`. + const filter: QueryFilter = { + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + ledger: 0, + code: 999, + timestamp_min: 0n, + timestamp_max: 0n, + limit: 5, + flags: QueryFilterFlags.reversed, + } + + // First 5 items: + let query: Transfer[] = await client.queryTransfers(filter) + assert.strictEqual(query.length, 5) + + let timestamp = 1n << 64n; + for (const transfer of query) { + assert.ok(timestamp > transfer.timestamp); + timestamp = transfer.timestamp; + + assert.strictEqual(transfer.code, filter.code); + } + + // Next 5 items: + filter.timestamp_max = timestamp - 1n + query = await client.queryTransfers(filter) + assert.strictEqual(query.length, 5) + + for (const transfer of query) { + assert.ok(timestamp > transfer.timestamp); + timestamp = transfer.timestamp; + + assert.strictEqual(transfer.code, filter.code); + } + + // No more results: + filter.timestamp_max = timestamp - 1n + query = await client.queryTransfers(filter) + assert.strictEqual(query.length, 0) + } + + { + // Not found: + const filter: QueryFilter = { + user_data_128: 0n, + user_data_64: 200n, + user_data_32: 10, + ledger: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: BATCH_MAX, + flags: QueryFilterFlags.none, + } + const query: Transfer[] = await client.queryTransfers(filter) + assert.strictEqual(query.length, 0) + } +}) + +test('query with invalid filter', async (): Promise => { + // Invalid timestamp min: + var filter: QueryFilter = { + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + ledger: 0, + code: 0, + timestamp_min: (1n << 64n) - 1n, // ulong max value + timestamp_max: 0n, + limit: BATCH_MAX, + flags: QueryFilterFlags.none, + } + assert.deepStrictEqual((await client.queryAccounts(filter)), []) + assert.deepStrictEqual((await client.queryTransfers(filter)), []) + + // Invalid timestamp max: + filter = { + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + ledger: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: (1n << 64n) - 1n, // ulong max value, + limit: BATCH_MAX, + flags: QueryFilterFlags.none, + } + assert.deepStrictEqual((await client.queryAccounts(filter)), []) + assert.deepStrictEqual((await client.queryTransfers(filter)), []) + + // Invalid timestamp range: + filter = { + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + ledger: 0, + code: 0, + timestamp_min: (1n << 64n) - 2n, // ulong max - 1 + timestamp_max: 1n, + limit: BATCH_MAX, + flags: QueryFilterFlags.none, + } + assert.deepStrictEqual((await client.queryAccounts(filter)), []) + assert.deepStrictEqual((await client.queryTransfers(filter)), []) + + // Zero limit: + filter = { + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + ledger: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: 0, + flags: QueryFilterFlags.none, + } + assert.deepStrictEqual((await client.queryAccounts(filter)), []) + assert.deepStrictEqual((await client.queryTransfers(filter)), []) + + // TooMuchData + filter = { + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + ledger: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: 10_000, + flags: QueryFilterFlags.none, + } + assert.rejects(async() => await client.queryAccounts(filter), (err) => { + assert.ok(err instanceof RequestError) + assert.strictEqual(err.code, ErrorCodes.ERR_TOO_MUCH_DATA) + return true + }) + assert.rejects(async() => await client.queryTransfers(filter), (err) => { + assert.ok(err instanceof RequestError) + assert.strictEqual(err.code, ErrorCodes.ERR_TOO_MUCH_DATA) + return true + }) + + // Invalid flags: + filter = { + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + ledger: 0, + code: 0, + timestamp_min: 0n, + timestamp_max: 0n, + limit: 0, + flags: 0xFFFF, + } + assert.deepStrictEqual((await client.queryAccounts(filter)), []) + assert.deepStrictEqual((await client.queryTransfers(filter)), []) +}) + +test('can import accounts and transfers', async (): Promise => { + const accountTmp: Account = { + id: id(), + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 718, + flags: 0, + timestamp: 0n // this will be set correctly by the TigerBeetle server + } + let account_results = await client.createAccounts([accountTmp]) + assert.deepStrictEqual(account_results.length, 1) + assert.ok(account_results[0].timestamp > 0) + assert.deepStrictEqual(account_results[0].status, CreateAccountStatus.created) + + let accountLookup = await client.lookupAccounts([accountTmp.id]) + assert.strictEqual(accountLookup.length, 1) + const timestampMax = accountLookup[0].timestamp + + // Wait 10 ms so we can use the account's timestamp as the reference for past time + // after the last object inserted. + await sleep_ms(10); + + const accountA: Account = { + id: id(), + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 718, + flags: AccountFlags.imported, + timestamp: timestampMax + 1n // user-defined timestamp + } + const accountB: Account = { + id: id(), + debits_pending: 0n, + debits_posted: 0n, + credits_pending: 0n, + credits_posted: 0n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + reserved: 0, + ledger: 1, + code: 718, + flags: AccountFlags.imported, + timestamp: timestampMax + 2n // user-defined timestamp + } + account_results = await client.createAccounts([accountA, accountB]) + assert.deepStrictEqual(account_results.length, 2) + assert.ok(account_results[0].timestamp > 0) + assert.deepStrictEqual(account_results[0].status, CreateAccountStatus.created) + assert.ok(account_results[1].timestamp > 0) + assert.deepStrictEqual(account_results[1].status, CreateAccountStatus.created) + + accountLookup = await client.lookupAccounts([accountA.id, accountB.id]) + assert.strictEqual(accountLookup.length, 2) + assert.strictEqual(accountLookup[0].timestamp, accountA.timestamp) + assert.strictEqual(accountLookup[1].timestamp, accountB.timestamp) + + const transfer: Transfer = { + id: id(), + debit_account_id: accountA.id, + credit_account_id: accountB.id, + amount: 100n, + user_data_128: 0n, + user_data_64: 0n, + user_data_32: 0, + pending_id: 0n, + timeout: 0, + ledger: 1, + code: 1, + flags: TransferFlags.imported, + timestamp: timestampMax + 3n, // user-defined timestamp. + } + + const transfers_results = await client.createTransfers([transfer]) + assert.deepStrictEqual(transfers_results.length, 1) + assert.ok(transfers_results[0].timestamp > 0) + assert.deepStrictEqual(transfers_results[0].status, CreateTransferStatus.created) + + const transfers = await client.lookupTransfers([transfer.id]) + assert.strictEqual(transfers.length, 1) + assert.strictEqual(transfers[0].timestamp, timestampMax + 3n) +}) + +test('accept zero-length create_accounts', async (): Promise => { + const account_results = await client.createAccounts([]) + assert.deepStrictEqual(account_results.length, 0) +}) + +test('accept zero-length create_transfers', async (): Promise => { + const transfers_results = await client.createTransfers([]) + assert.deepStrictEqual(transfers_results.length, 0) +}) + +test('accept zero-length lookup_accounts', async (): Promise => { + const accounts = await client.lookupAccounts([]) + assert.deepStrictEqual(accounts, []) +}) + +test('accept zero-length lookup_transfers', async (): Promise => { + const transfers = await client.lookupTransfers([]) + assert.deepStrictEqual(transfers, []) +}) + +test("destroy client in-flight", async (): Promise => { + const client_count = 5; + const action_count = 50; + + const clients = range(client_count).map(() => + createClient({ + cluster_id: 0n, + replica_addresses: REPLICA_ADDRESSES, + }) + ); + + const ids: Array = []; + const actions = range(action_count).map(() => async () => { + await sleep_ms(Math.random() < 0.2 ? 0 : Math.random()); + const client = clients[random_index(clients)]; + if (Math.random() < 0.1) { + client.destroy(); + return; + } + if (Math.random() < 0.7) { + const id_new = id(); + ids.push(id_new); + try { + await client.createAccounts([{ ...accountA, id: id_new }]); + } catch (err) { + assert.ok(err instanceof RequestError); + assert.strictEqual(err.code, ErrorCodes.ERR_CLIENT_CLOSED); + } + return; + } + try { + const id_lookup = (Math.random() < 0.2 || ids.length == 0) + ? BigInt(Math.floor(Math.random() * 10000)) + : ids[random_index(ids)]; + await client.lookupAccounts([id_lookup]); + } catch (err) { + assert.ok(err instanceof RequestError); + assert.strictEqual(err.code, ErrorCodes.ERR_CLIENT_CLOSED); + } + }); + + await Promise.all(actions.map((f) => f())); + for (const client of clients) client.destroy(); +}); + +async function main () { + const start = new Date().getTime() + try { + for (let i = 0; i < tests.length; i++) { + await tests[i].fn().then(() => { + console.log(tests[i].name + ": PASSED") + }).catch(error => { + console.log(tests[i].name + ": FAILED") + throw error + }) + } + const end = new Date().getTime() + console.log('Time taken (s):', (end - start)/1000) + } finally { + await client.destroy() + } +} + +main().catch((error: AssertionError) => { + console.log('operator:', error.operator) + console.log('stack:', error.stack) + process.exit(-1); +}) diff --git a/ocam/src/clients/node/src/translate.zig b/ocam/src/clients/node/src/translate.zig new file mode 100644 index 00000000..bf1d252b --- /dev/null +++ b/ocam/src/clients/node/src/translate.zig @@ -0,0 +1,513 @@ +const std = @import("std"); +const assert = std.debug.assert; +const c = @import("c.zig").c; + +pub fn register_function( + env: c.napi_env, + exports: c.napi_value, + comptime name: [:0]const u8, + function: *const fn (env: c.napi_env, info: c.napi_callback_info) callconv(.c) c.napi_value, +) !void { + var napi_function: c.napi_value = undefined; + if (c.napi_create_function(env, null, 0, function, null, &napi_function) != c.napi_ok) { + return throw(env, .{ + .message = "Failed to create function " ++ name ++ "().", + }); + } + + if (c.napi_set_named_property( + env, + exports, + @as([*c]const u8, @ptrCast(name)), + napi_function, + ) != c.napi_ok) { + return throw(env, .{ + .message = "Failed to add " ++ name ++ "() to exports.", + }); + } +} + +pub const Error = error{ExceptionThrown}; +pub fn throw(env: c.napi_env, comptime options: struct { + message: [:0]const u8, +}) Error { + const result = c.napi_throw_error( + env, + null, + options.message, + ); + switch (result) { + c.napi_ok, c.napi_pending_exception => {}, + else => unreachable, + } + + return Error.ExceptionThrown; +} + +pub fn throw_typed_error( + env: c.napi_env, + ctor_ref: c.napi_ref, + code: [:0]const u8, +) Error { + var string: c.napi_value = undefined; + var ctor: c.napi_value = undefined; + var exception: c.napi_value = undefined; + if (c.napi_get_reference_value( + env, + ctor_ref, + &ctor, + ) != c.napi_ok) { + return throw(env, .{ .message = "Failed to get the constructor reference." }); + } + assert(ctor != null); + + if (c.napi_create_string_utf8( + env, + code, + code.len, + &string, + ) != c.napi_ok) { + return throw(env, .{ .message = "Failed to create string utf8." }); + } + if (c.napi_new_instance( + env, + ctor, + 1, + &[_]c.napi_value{string}, + &exception, + ) != c.napi_ok) { + return throw(env, .{ .message = "Failed to create new instance." }); + } + + // Asserting the exception got the right type. + var is_instance_of: bool = false; + assert(c.napi_instanceof( + env, + exception, + ctor, + &is_instance_of, + ) == c.napi_ok); + assert(is_instance_of); + + if (c.napi_throw(env, exception) != c.napi_ok) { + return throw(env, .{ .message = "Failed to throw typed error" }); + } + + return Error.ExceptionThrown; +} + +pub fn capture_null(env: c.napi_env) !c.napi_value { + var result: c.napi_value = undefined; + if (c.napi_get_null(env, &result) != c.napi_ok) { + return throw(env, .{ + .message = "Failed to capture the value of \"null\".", + }); + } + + return result; +} + +pub fn extract_args(env: c.napi_env, info: c.napi_callback_info, comptime args: struct { + count: usize, + function: []const u8, +}) ![args.count]c.napi_value { + var argc = args.count; + var argv: [args.count]c.napi_value = undefined; + if (c.napi_get_cb_info(env, info, &argc, &argv, null, null) != c.napi_ok) { + return throw(env, .{ + .message = std.fmt.comptimePrint("Failed to get args for {s}()\x00", .{args.function}), + }); + } + + if (argc != args.count) { + return throw(env, .{ + .message = std.fmt.comptimePrint( + "Function {s}() requires exactly {} arguments.\x00", + .{ + args.function, + args.count, + }, + ), + }); + } + + return argv; +} + +pub fn create_external(env: c.napi_env, context: *anyopaque) !c.napi_value { + var result: c.napi_value = null; + if (c.napi_create_external(env, context, null, null, &result) != c.napi_ok) { + return throw(env, .{ + .message = "Failed to create external for client context.", + }); + } + + return result; +} + +pub fn value_external( + env: c.napi_env, + value: c.napi_value, + comptime error_message: [:0]const u8, +) !?*anyopaque { + var result: ?*anyopaque = undefined; + if (c.napi_get_value_external(env, value, &result) != c.napi_ok) { + return throw(env, .{ .message = error_message }); + } + + return result; +} + +pub fn slice_from_object( + env: c.napi_env, + object: c.napi_value, + comptime key: [:0]const u8, +) ![]const u8 { + var property: c.napi_value = undefined; + if (c.napi_get_named_property(env, object, key, &property) != c.napi_ok) { + return throw(env, .{ + .message = key ++ " must be defined", + }); + } + + return slice_from_value(env, property, key); +} + +pub fn get_object_property( + env: c.napi_env, + object: c.napi_value, + comptime key: [:0]const u8, +) !c.napi_value { + var result: c.napi_value = undefined; + if (c.napi_get_named_property(env, object, key, &result) != c.napi_ok) { + return throw(env, .{ + .message = key ++ " must be defined", + }); + } + return result; +} + +pub fn slice_from_value( + env: c.napi_env, + value: c.napi_value, + comptime key: [:0]const u8, +) ![]u8 { + var is_buffer: bool = undefined; + assert(c.napi_is_buffer(env, value, &is_buffer) == c.napi_ok); + + if (!is_buffer) return throw(env, .{ + .message = key ++ " must be a buffer", + }); + + var data: ?*anyopaque = null; + var data_length: usize = undefined; + assert(c.napi_get_buffer_info(env, value, &data, &data_length) == c.napi_ok); + + if (data_length < 1) return throw(env, .{ + .message = key ++ " must not be empty", + }); + + return @as([*]u8, @ptrCast(data.?))[0..data_length]; +} + +pub fn u128_from_object(env: c.napi_env, object: c.napi_value, comptime key: [:0]const u8) !u128 { + var property: c.napi_value = undefined; + if (c.napi_get_named_property(env, object, key, &property) != c.napi_ok) { + return throw(env, .{ + .message = key ++ " must be defined", + }); + } + + return u128_from_value(env, property, key); +} + +pub fn u64_from_object(env: c.napi_env, object: c.napi_value, comptime key: [:0]const u8) !u64 { + var property: c.napi_value = undefined; + if (c.napi_get_named_property(env, object, key, &property) != c.napi_ok) { + return throw(env, .{ + .message = key ++ " must be defined", + }); + } + + return u64_from_value(env, property, key); +} + +pub fn u32_from_object(env: c.napi_env, object: c.napi_value, comptime key: [:0]const u8) !u32 { + var property: c.napi_value = undefined; + if (c.napi_get_named_property(env, object, key, &property) != c.napi_ok) { + return throw(env, .{ + .message = key ++ " must be defined", + }); + } + + return u32_from_value(env, property, key); +} + +pub fn u16_from_object(env: c.napi_env, object: c.napi_value, comptime key: [:0]const u8) !u16 { + const result = try u32_from_object(env, object, key); + if (result > std.math.maxInt(u16)) { + return throw(env, .{ + .message = key ++ " must be a u16.", + }); + } + + return @as(u16, @intCast(result)); +} + +pub fn u128_from_value(env: c.napi_env, value: c.napi_value, comptime name: [:0]const u8) !u128 { + // A BigInt's value (using ^ to mean exponent) is + // (words[0] * (2^64)^0 + words[1] * (2^64)^1 + ...). + + // V8 says that the words are little endian. If we were on a big endian machine + // we would need to convert, but big endian is not supported by tigerbeetle. + var result: u128 = 0; + var sign_bit: c_int = undefined; + const words: *[2]u64 = @ptrCast(&result); + var word_count: usize = 2; + switch (c.napi_get_value_bigint_words(env, value, &sign_bit, &word_count, words)) { + c.napi_ok => {}, + c.napi_bigint_expected => return throw(env, .{ + .message = name ++ " must be a BigInt", + }), + else => unreachable, + } + if (sign_bit != 0) return throw(env, .{ + .message = name ++ " must be positive", + }); + if (word_count > 2) return throw(env, .{ + .message = name ++ " must fit in 128 bits", + }); + + return result; +} + +pub fn u64_from_value(env: c.napi_env, value: c.napi_value, comptime name: [:0]const u8) !u64 { + var result: u64 = undefined; + var lossless: bool = undefined; + switch (c.napi_get_value_bigint_uint64(env, value, &result, &lossless)) { + c.napi_ok => {}, + c.napi_bigint_expected => return throw(env, .{ + .message = name ++ " must be an unsigned 64-bit BigInt", + }), + else => unreachable, + } + if (!lossless) return throw(env, .{ + .message = name ++ " conversion was lossy", + }); + + return result; +} + +pub fn u32_from_value(env: c.napi_env, value: c.napi_value, comptime name: [:0]const u8) !u32 { + var result: u32 = undefined; + // TODO Check whether this will coerce signed numbers to a u32: + // In that case we need to use the appropriate napi method to do more type checking here. + // We want to make sure this is: unsigned, and an integer. + switch (c.napi_get_value_uint32(env, value, &result)) { + c.napi_ok => {}, + c.napi_number_expected => return throw(env, .{ + .message = name ++ " must be a number", + }), + else => unreachable, + } + return result; +} + +pub fn u128_into_object( + env: c.napi_env, + object: c.napi_value, + comptime key: [:0]const u8, + value: u128, + comptime error_message: [:0]const u8, +) !void { + // A BigInt's value (using ^ to mean exponent) is + // (words[0] * (2^64)^0 + words[1] * (2^64)^1 + ...). + + // V8 says that the words are little endian. If we were on a big endian machine + // we would need to convert, but big endian is not supported by tigerbeetle. + var bigint: c.napi_value = undefined; + if (c.napi_create_bigint_words( + env, + 0, + 2, + @as(*const [2]u64, @ptrCast(&value)), + &bigint, + ) != c.napi_ok) { + return throw(env, .{ .message = error_message }); + } + + if (c.napi_set_named_property( + env, + object, + @ptrCast(key), + bigint, + ) != c.napi_ok) { + return throw(env, .{ .message = error_message }); + } +} + +pub fn u64_into_object( + env: c.napi_env, + object: c.napi_value, + comptime key: [:0]const u8, + value: u64, + comptime error_message: [:0]const u8, +) !void { + var result: c.napi_value = undefined; + if (c.napi_create_bigint_uint64(env, value, &result) != c.napi_ok) { + return throw(env, .{ .message = error_message }); + } + + if (c.napi_set_named_property( + env, + object, + @ptrCast(key), + result, + ) != c.napi_ok) { + return throw(env, .{ .message = error_message }); + } +} + +pub fn u32_into_object( + env: c.napi_env, + object: c.napi_value, + comptime key: [:0]const u8, + value: u32, + comptime error_message: [:0]const u8, +) !void { + var result: c.napi_value = undefined; + if (c.napi_create_uint32(env, value, &result) != c.napi_ok) { + return throw(env, .{ .message = error_message }); + } + + if (c.napi_set_named_property(env, object, @ptrCast(key), result) != c.napi_ok) { + return throw(env, .{ .message = error_message }); + } +} + +pub fn u16_into_object( + env: c.napi_env, + object: c.napi_value, + comptime key: [:0]const u8, + value: u16, + comptime error_message: [:0]const u8, +) !void { + try u32_into_object(env, object, key, value, error_message); +} + +pub fn create_object(env: c.napi_env, comptime error_message: [:0]const u8) !c.napi_value { + var result: c.napi_value = undefined; + if (c.napi_create_object(env, &result) != c.napi_ok) { + return throw(env, .{ .message = error_message }); + } + + return result; +} + +pub fn create_array( + env: c.napi_env, + length: u32, + comptime error_message: [:0]const u8, +) !c.napi_value { + var result: c.napi_value = undefined; + if (c.napi_create_array_with_length(env, length, &result) != c.napi_ok) { + return throw(env, .{ .message = error_message }); + } + + return result; +} + +pub fn set_array_element( + env: c.napi_env, + array: c.napi_value, + index: u32, + value: c.napi_value, + comptime error_message: [:0]const u8, +) !void { + if (c.napi_set_element(env, array, index, value) != c.napi_ok) { + return throw(env, .{ .message = error_message }); + } +} + +pub fn array_element(env: c.napi_env, array: c.napi_value, index: u32) !c.napi_value { + var element: c.napi_value = undefined; + if (c.napi_get_element(env, array, index, &element) != c.napi_ok) { + return throw(env, .{ .message = "Failed to get array element." }); + } + + return element; +} + +pub fn array_length(env: c.napi_env, array: c.napi_value) !u32 { + var is_array: bool = undefined; + assert(c.napi_is_array(env, array, &is_array) == c.napi_ok); + if (!is_array) return throw(env, .{ + .message = "Batch must be an Array.", + }); + + var length: u32 = undefined; + assert(c.napi_get_array_length(env, array, &length) == c.napi_ok); + + return length; +} + +pub fn create_reference( + env: c.napi_env, + object: c.napi_value, + reference_type: enum { strong, weak }, + reference_out: *c.napi_ref, + comptime error_message: [:0]const u8, +) !void { + const initial_ref_count: u32 = switch (reference_type) { + .weak => 0, + .strong => 1, + }; + if (c.napi_create_reference( + env, + object, + initial_ref_count, + reference_out, + ) != c.napi_ok) { + return throw(env, .{ .message = error_message }); + } +} + +pub fn delete_reference(env: c.napi_env, reference: c.napi_ref) !void { + if (c.napi_delete_reference(env, reference) != c.napi_ok) { + return throw(env, .{ + .message = "Failed to delete callback reference.", + }); + } +} + +pub fn call_function( + env: c.napi_env, + this: c.napi_value, + callback: c.napi_value, + args: []c.napi_value, +) !c.napi_value { + var result: c.napi_value = undefined; + switch (c.napi_call_function(env, this, callback, args.len, args.ptr, &result)) { + c.napi_ok => {}, + // the user's callback may throw a JS exception or call other functions that do so. We + // therefore don't throw another error. + c.napi_pending_exception => {}, + else => return throw(env, .{ + .message = "Failed to invoke results callback.", + }), + } + return result; +} + +pub fn reference_value( + env: c.napi_env, + callback_reference: c.napi_ref, + comptime error_message: [:0]const u8, +) !c.napi_value { + var result: c.napi_value = undefined; + if (c.napi_get_reference_value(env, callback_reference, &result) != c.napi_ok) { + return throw(env, .{ .message = error_message }); + } + + return result; +} diff --git a/ocam/src/clients/node/tsconfig.json b/ocam/src/clients/node/tsconfig.json new file mode 100644 index 00000000..4fb28de5 --- /dev/null +++ b/ocam/src/clients/node/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "commonjs", + "esModuleInterop": true, + "declaration": true, + "noImplicitAny": true, + "removeComments": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "sourceMap": true, + "inlineSources": true, + "skipLibCheck": true, + "strictNullChecks": true, + "outDir": "./dist" + }, + "files": ["src/index.ts", "src/test.ts", "src/benchmark.ts"] +} diff --git a/ocam/src/clients/python/.gitignore b/ocam/src/clients/python/.gitignore new file mode 100644 index 00000000..71724639 --- /dev/null +++ b/ocam/src/clients/python/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +dist/ +lib/ +.ruff_cache/ +.pytest_cache/ diff --git a/ocam/src/clients/python/README.md b/ocam/src/clients/python/README.md new file mode 100644 index 00000000..803fadcb --- /dev/null +++ b/ocam/src/clients/python/README.md @@ -0,0 +1,804 @@ + +# tigerbeetle-python + +The TigerBeetle client for Python. + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Python (or PyPy, etc) >= `3.7` + +## Setup + +First, create a directory for your project and `cd` into the directory. + +Then, install the TigerBeetle client: + +```console +pip install tigerbeetle +``` + +Now, create `main.py` and copy this into it: + +```python +import os + +import tigerbeetle as tb + +print("Import OK!") + +# To enable debug logging, via Python's built in logging module: +# logging.basicConfig(level=logging.DEBUG) +# tb.configure_logging(debug=True) +``` + +Finally, build and run: + +```console +python3 main.py +``` + +Now that all prerequisites and dependencies are correctly set +up, let's dig into using TigerBeetle. + +## Sample projects + +This document is primarily a reference guide to +the client. Below are various sample projects demonstrating +features of TigerBeetle. + +* [Basic](/src/clients/python/samples/basic/): Create two accounts and transfer an amount between them. +* [Two-Phase Transfer](/src/clients/python/samples/two-phase/): Create two accounts and start a pending transfer between +them, then post the transfer. +* [Many Two-Phase Transfers](/src/clients/python/samples/two-phase-many/): Create two accounts and start a number of pending transfers +between them, posting and voiding alternating transfers. + +## Creating a Client + +A client is created with a cluster ID and replica +addresses for all replicas in the cluster. The cluster +ID and replica addresses are both chosen by the system that +starts the TigerBeetle cluster. + +Clients are thread-safe and a single instance should be shared +between multiple concurrent tasks. This allows events to be +[automatically batched](https://docs.tigerbeetle.com/coding/requests/#batching-events). + +Multiple clients are useful when connecting to more than +one TigerBeetle cluster. + +In this example the cluster ID is `0` and there is one +replica. The address is read from the `TB_ADDRESS` +environment variable and defaults to port `3000`. + +```python +with tb.ClientSync(cluster_id=0, replica_addresses=os.getenv("TB_ADDRESS", "3000")) as client: + # Use the client. + pass + +# Alternatively: +async with tb.ClientAsync(cluster_id=0, replica_addresses=os.getenv("TB_ADDRESS", "3000")) as client: + # Use the client, async! + pass +``` + +The following are valid addresses: +* `3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1:3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1` (interpreted as `127.0.0.1:3001`, `3001` is the default port) + +## Creating Accounts + +See details for account fields in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account). + +```python +account = tb.Account( + id=tb.id(), # TigerBeetle time-based ID. + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=718, + flags=0, + timestamp=0, +) + +account_results = client.create_accounts([account]) +# Results handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +### Account Flags + +The account flags value is a bitfield. See details for +these flags in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account#flags). + +To toggle behavior for an account, combine enum values stored in the +`AccountFlags` object (it's an `enum.IntFlag`) with bitwise-or: + +* `AccountFlags.linked` +* `AccountFlags.debits_must_not_exceed_credits` +* `AccountFlags.credits_must_not_exceed_credits` +* `AccountFlags.history` + + +For example, to link two accounts where the first account +additionally has the `debits_must_not_exceed_credits` constraint: + +```python +account0 = tb.Account( + id=100, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=1, + timestamp=0, + flags=tb.AccountFlags.LINKED | tb.AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS, +) +account1 = tb.Account( + id=101, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=1, + timestamp=0, + flags=tb.AccountFlags.HISTORY, +) + +account_results = client.create_accounts([account0, account1]) +# Results handling omitted. +``` + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each account in the request batch: +- Successfully created accounts with the status + [`created`](https://docs.tigerbeetle.com/reference/requests/create_accounts#created) + return the timestamp assigned to the `Account` object. +- Already existing accounts with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists) + return the timestamp of the original existing object. +- Failed accounts return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_accounts reference](https://docs.tigerbeetle.com/reference/requests/create_accounts#status). + +```python +account0 = tb.Account( + id=102, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=1, + timestamp=0, + flags=0, +) +account1 = tb.Account( + id=103, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=1, + timestamp=0, + flags=0, +) +account2 = tb.Account( + id=104, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=1, + timestamp=0, + flags=0, +) + +account_results = client.create_accounts([account0, account1, account2]) +for i, result in enumerate(account_results): + if result.status == tb.CreateAccountStatus.CREATED: + print(f"Batch account at {i} successfully created with timestamp {result.timestamp}.") + elif result.status == tb.CreateAccountStatus.EXISTS: + print(f"Batch account at {i} already exists with timestamp {result.timestamp}.") + else: + print(f"Batch account at {i} failed to create: {result.status}.") +``` + +To handle errors you can compare the result code returned +from `client.create_accounts` with enum values in the +`CreateAccountStatus` object. + +## Account Lookup + +Account lookup is batched, like account creation. Pass +in all IDs to fetch. The account for each matched ID is returned. + +If no account matches an ID, no object is returned for +that account. So the order of accounts in the response is +not necessarily the same as the order of IDs in the +request. You can refer to the ID field in the response to +distinguish accounts. + +```python +accounts = client.lookup_accounts([100, 101]) +``` + +## Create Transfers + +This creates a journal entry between two accounts. + +See details for transfer fields in the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer). + +```python +transfers = [tb.Transfer( + id=tb.id(), # TigerBeetle time-based ID. + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=0, + timestamp=0, +)] + +transfers_results = client.create_transfers(transfers) +# Results handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each transfer in the request batch: +- Successfully created transfers with the result + [`created`](https://docs.tigerbeetle.com/reference/requests/create_transfers#created) + return the timestamp assigned to the `Transfer` object. +- Already existing transfers with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists) + return the timestamp of the original existing object. +- Failed transfers return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_transfers reference](https://docs.tigerbeetle.com/reference/requests/create_transfers#status). + +```python +batch = [tb.Transfer( + id=1, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=0, + timestamp=0, +), + tb.Transfer( + id=2, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=0, + timestamp=0, +), + tb.Transfer( + id=3, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=0, + timestamp=0, +)] + +transfers_results = client.create_transfers(batch) +for i, result in enumerate(transfers_results): + if result.status == tb.CreateTransferStatus.CREATED: + print(f"Batch transfer at {i} successfully created with timestamp {result.timestamp}.") + elif result.status == tb.CreateTransferStatus.EXISTS: + print(f"Batch transfer at {i} already exists with timestamp {result.timestamp}.") + else: + print(f"Batch transfer at {i} failed to create: {result.status}.") +``` + +To handle errors you can compare the result code returned +from `client.create_transfers` with enum values in the +`CreateTransferStatus` object. + +## Batching + +TigerBeetle performance is maximized when you batch +API requests. + +A client instance shared across multiple threads/tasks can automatically +batch concurrent requests, but the application must still send as many events +as possible in a single call. + +For example, if you insert 1 million transfers sequentially, one at a time, +the insert rate will be a *fraction* of the potential, because the client will +wait for a reply between each one. +Instead, **always batch as much as you can**. + +The maximum batch size is set in the TigerBeetle server. The default is 8189. + +```python +batch = [] # Array of transfer to create. +BATCH_SIZE = 8189 #FIXME +for i in range(0, len(batch), BATCH_SIZE): + transfers_results = client.create_transfers( + batch[i:min(len(batch), i + BATCH_SIZE)], + ) + # Results handling omitted. +``` + +### Queues and Workers + +If you are making requests to TigerBeetle from workers +pulling jobs from a queue, you can batch requests to +TigerBeetle by having the worker act on multiple jobs from +the queue at once rather than one at a time. i.e. pulling +multiple jobs from the queue rather than just one. + +## Transfer Flags + +The transfer `flags` value is a bitfield. See details for these flags in +the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer#flags). + +To toggle behavior for a transfer, combine enum values stored in the +`TransferFlags` object (it's an `enum.IntFlag`) with bitwise-or: + +* `TransferFlags.linked` +* `TransferFlags.pending` +* `TransferFlags.post_pending_transfer` +* `TransferFlags.void_pending_transfer` + +For example, to link `transfer0` and `transfer1`: + +```python +transfer0 = tb.Transfer( + id=4, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=tb.TransferFlags.LINKED, + timestamp=0, +) +transfer1 = tb.Transfer( + id=5, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=0, + timestamp=0, +) + +# Create the transfer +transfers_results = client.create_transfers([transfer0, transfer1]) +# Results handling omitted. +``` + +### Two-Phase Transfers + +Two-phase transfers are supported natively by toggling the appropriate +flag. TigerBeetle will then adjust the `credits_pending` and +`debits_pending` fields of the appropriate accounts. A corresponding +post pending transfer then needs to be sent to post or void the +transfer. + +#### Post a Pending Transfer + +With `flags` set to `post_pending_transfer`, +TigerBeetle will post the transfer. TigerBeetle will atomically roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and apply them to the `debits_posted` and +`credits_posted` balances. + +```python +transfer0 = tb.Transfer( + id=6, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=tb.TransferFlags.PENDING, + timestamp=0, +) + +transfers_results = client.create_transfers([transfer0]) +# Results handling omitted. + +transfer1 = tb.Transfer( + id=7, + debit_account_id=102, + credit_account_id=103, + # Post the entire pending amount. + amount=tb.AMOUNT_MAX, + pending_id=6, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=tb.TransferFlags.POST_PENDING_TRANSFER, + timestamp=0, +) + +transfers_results = client.create_transfers([transfer1]) +# Results handling omitted. +``` + +#### Void a Pending Transfer + +In contrast, with `flags` set to `void_pending_transfer`, +TigerBeetle will void the transfer. TigerBeetle will roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and **not** apply them to the `debits_posted` and +`credits_posted` balances. + +```python +transfer0 = tb.Transfer( + id=8, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=tb.TransferFlags.PENDING, + timestamp=0, +) + +transfers_results = client.create_transfers([transfer0]) +# Results handling omitted. + +transfer1 = tb.Transfer( + id=9, + debit_account_id=102, + credit_account_id=103, + amount=0, + pending_id=8, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=tb.TransferFlags.VOID_PENDING_TRANSFER, + timestamp=0, +) + +transfers_results = client.create_transfers([transfer1]) +# Results handling omitted. +``` + +## Transfer Lookup + +NOTE: While transfer lookup exists, it is not a flexible query API. We +are developing query APIs and there will be new methods for querying +transfers in the future. + +Transfer lookup is batched, like transfer creation. Pass in all `id`s to +fetch, and matched transfers are returned. + +If no transfer matches an `id`, no object is returned for that +transfer. So the order of transfers in the response is not necessarily +the same as the order of `id`s in the request. You can refer to the +`id` field in the response to distinguish transfers. + +```python +transfers = client.lookup_transfers([1, 2]) +``` + +## Get Account Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the transfers involving a given account, allowing basic filter and pagination +capabilities. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```python +filter = tb.AccountFilter( + account_id=2, + user_data_128=0, # No filter by UserData. + user_data_64=0, + user_data_32=0, + code=0, # No filter by Code. + timestamp_min=0, # No filter by Timestamp. + timestamp_max=0, # No filter by Timestamp. + limit=10, # Limit to ten transfers at most. + flags=tb.AccountFilterFlags.DEBITS | # Include transfer from the debit side. + tb.AccountFilterFlags.CREDITS | # Include transfer from the credit side. + tb.AccountFilterFlags.REVERSED, # Sort by timestamp in reverse-chronological order. +) + +account_transfers = client.get_account_transfers(filter) +``` + +## Get Account Balances + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the point-in-time balances of a given account, allowing basic filter and +pagination capabilities. + +Only accounts created with the flag +[`history`](https://docs.tigerbeetle.com/reference/account#flagshistory) set retain +[historical balances](https://docs.tigerbeetle.com/reference/requests/get_account_balances). + +The balances in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```python +filter = tb.AccountFilter( + account_id=2, + user_data_128=0, # No filter by UserData. + user_data_64=0, + user_data_32=0, + code=0, # No filter by Code. + timestamp_min=0, # No filter by Timestamp. + timestamp_max=0, # No filter by Timestamp. + limit=10, # Limit to ten balances at most. + flags=tb.AccountFilterFlags.DEBITS | # Include transfer from the debit side. + tb.AccountFilterFlags.CREDITS | # Include transfer from the credit side. + tb.AccountFilterFlags.REVERSED, # Sort by timestamp in reverse-chronological order. +) + +account_balances = client.get_account_balances(filter) +``` + +## Query Accounts + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query accounts by the intersection of some fields and by timestamp range. + +The accounts in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```python +query_filter = tb.QueryFilter( + user_data_128=1000, # Filter by UserData. + user_data_64=100, + user_data_32=10, + code=1, # Filter by Code. + ledger=0, # No filter by Ledger. + timestamp_min=0, # No filter by Timestamp. + timestamp_max=0, # No filter by Timestamp. + limit=10, # Limit to ten accounts at most. + flags=tb.QueryFilterFlags.REVERSED, # Sort by timestamp in reverse-chronological order. +) + +query_accounts = client.query_accounts(query_filter) +``` + +## Query Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query transfers by the intersection of some fields and by timestamp range. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```python +query_filter = tb.QueryFilter( + user_data_128=1000, # Filter by UserData. + user_data_64=100, + user_data_32=10, + code=1, # Filter by Code. + ledger=0, # No filter by Ledger. + timestamp_min=0, # No filter by Timestamp. + timestamp_max=0, # No filter by Timestamp. + limit=10, # Limit to ten transfers at most. + flags=tb.QueryFilterFlags.REVERSED, # Sort by timestamp in reverse-chronological order. +) + +query_transfers = client.query_transfers(query_filter) +``` + +## Linked Events + +When the `linked` flag is specified for an account when creating accounts or +a transfer when creating transfers, it links that event with the next event in the +batch, to create a chain of events, of arbitrary length, which all +succeed or fail together. The tail of a chain is denoted by the first +event without this flag. The last event in a batch may therefore never +have the `linked` flag set as this would leave a chain +open-ended. Multiple chains or individual events may coexist within a +batch to succeed or fail independently. + +Events within a chain are executed within order, or are rolled back on +error, so that the effect of each event in the chain is visible to the +next, and so that the chain is either visible or invisible as a unit +to subsequent events after the chain. The event that was the first to +break the chain will have a unique error result. Other events in the +chain will have their error result set to `linked_event_failed`. + +```python +batch = [] # List of tb.Transfers to create. +linkedFlag = 0 +linkedFlag |= tb.TransferFlags.LINKED + +# An individual transfer (successful): +batch.append(tb.Transfer(id=1)) + +# A chain of 4 transfers (the last transfer in the chain closes the chain with linked=false): +batch.append(tb.Transfer(id=2, flags=linkedFlag)) # Commit/rollback. +batch.append(tb.Transfer(id=3, flags=linkedFlag)) # Commit/rollback. +batch.append(tb.Transfer(id=2, flags=linkedFlag)) # Fail with exists +batch.append(tb.Transfer(id=4, flags=0)) # Fail without committing. + +# An individual transfer (successful): +# This should not see any effect from the failed chain above. +batch.append(tb.Transfer(id=2, flags=0 )) + +# A chain of 2 transfers (the first transfer fails the chain): +batch.append(tb.Transfer(id=2, flags=linkedFlag)) +batch.append(tb.Transfer(id=3, flags=0)) + +# A chain of 2 transfers (successful): +batch.append(tb.Transfer(id=3, flags=linkedFlag)) +batch.append(tb.Transfer(id=4, flags=0)) + +transfers_results = client.create_transfers(batch) +# Results handling omitted. +``` + +## Imported Events + +When the `imported` flag is specified for an account when creating accounts or +a transfer when creating transfers, it allows importing historical events with +a user-defined timestamp. + +The entire batch of events must be set with the flag `imported`. + +It's recommended to submit the whole batch as a `linked` chain of events, ensuring that +if any event fails, none of them are committed, preserving the last timestamp unchanged. +This approach gives the application a chance to correct failed imported events, re-submitting +the batch again with the same user-defined timestamps. + +```python +# External source of time. +historical_timestamp = 0 +# Events loaded from an external source. +historical_accounts = [] # Loaded from an external source. +historical_transfers = [] # Loaded from an external source. + +# First, load and import all accounts with their timestamps from the historical source. +accounts = [] +for index, account in enumerate(historical_accounts): + # Set a unique and strictly increasing timestamp. + historical_timestamp += 1 + account.timestamp = historical_timestamp + # Set the account as `imported`. + account.flags = tb.AccountFlags.IMPORTED + # To ensure atomicity, the entire batch (except the last event in the chain) + # must be `linked`. + if index < len(historical_accounts) - 1: + account.flags |= tb.AccountFlags.LINKED + + accounts.append(account) + +account_results = client.create_accounts(accounts) +# Results handling omitted. + +# The, load and import all transfers with their timestamps from the historical source. +transfers = [] +for index, transfer in enumerate(historical_transfers): + # Set a unique and strictly increasing timestamp. + historical_timestamp += 1 + transfer.timestamp = historical_timestamp + # Set the account as `imported`. + transfer.flags = tb.TransferFlags.IMPORTED + # To ensure atomicity, the entire batch (except the last event in the chain) + # must be `linked`. + if index < len(historical_transfers) - 1: + transfer.flags |= tb.AccountFlags.LINKED + + transfers.append(transfer) + +transfers_results = client.create_transfers(transfers) +# Results handling omitted. + +# Since it is a linked chain, in case of any error the entire batch is rolled back and can be retried +# with the same historical timestamps without regressing the cluster timestamp. +``` + +## Timeouts And Cancellation + +The Client retries indefinitely and doesn't impose any per-request timeout. Cancellation is +provided as a mechanism, and the specific cancellation policy is left to the +application. A Client instance can be closed at any time. On close, all in-flight +requests are canceled and return an error to the caller. Even if an error is returned, +a request might still be processed by the TigerBeetle server. +[Reliable transaction submission](https://docs.tigerbeetle.com/coding/reliable-transaction-submission/) +explains how to make transfers retry-proof using IDs for end-to-end idempotency. diff --git a/ocam/src/clients/python/ci.zig b/ocam/src/clients/python/ci.zig new file mode 100644 index 00000000..4fdf15f8 --- /dev/null +++ b/ocam/src/clients/python/ci.zig @@ -0,0 +1,176 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const log = std.log; +const assert = std.debug.assert; + +const stdx = @import("stdx"); +const Shell = stdx.Shell; +const TmpTigerBeetle = @import("../../testing/tmp_tigerbeetle.zig"); +const wheel = @import("wheel.zig"); + +pub fn tests(shell: *Shell, gpa: std.mem.Allocator) !void { + assert(shell.file_exists("pyproject.toml")); + + // Integration tests. + + // Build the native libraries. + try shell.exec_zig("build clients:python -Drelease", .{}); + + // Only to test the build process - the samples below run directly from the src/ directory. + try wheel.make(shell, "0.0.1", stdx.InstantUnix.now(), "tigerbeetle-0.0.1-py3-none-any.whl"); + + const path_relative = try std.fs.path.join(shell.arena.allocator(), &.{ + "src", + @src().file, + }); + const python_path_relative = try std.fs.path.join(shell.arena.allocator(), &.{ + std.fs.path.dirname(path_relative).?, + "src", + }); + + const python_path = try shell.project_root.realpathAlloc( + shell.arena.allocator(), + python_path_relative, + ); + + try shell.env.put("PYTHONPATH", python_path); + + { + log.info("running pytest", .{}); + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + const tigerbeetle_exe = comptime "tigerbeetle" ++ builtin.target.exeFileExt(); + const tigerbeetle_path = try shell.project_root.realpathAlloc( + shell.arena.allocator(), + tigerbeetle_exe, + ); + try shell.env.put("TIGERBEETLE_BINARY", tigerbeetle_path); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + try shell.exec("python3 -m pytest tests/", .{}); + } + + inline for ([_][]const u8{ "basic", "two-phase", "two-phase-many", "walkthrough" }) |sample| { + log.info("testing sample '{s}'", .{sample}); + + try shell.pushd("./samples/" ++ sample); + defer shell.popd(); + + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + try shell.exec("python3 main.py", .{}); + } + + // We are checking type annotations of the entire package. + try shell.exec("python3 -m mypy . --strict", .{}); +} + +pub fn validate_release_package(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, +}) !void { + const PyPIPackage = struct { + urls: []const struct { + filename: []const u8, + url: []const u8, + }, + }; + + const response_body = try shell.http_get(try shell.fmt( + "https://pypi.org/pypi/tigerbeetle/{s}/json", + .{options.release}, + ), .{}); + const pypi_package = try std.json.parseFromSliceLeaky( + PyPIPackage, + shell.arena.allocator(), + response_body, + .{ .ignore_unknown_fields = true }, + ); + + assert(pypi_package.urls.len == 1); + + const wheel_size_max = 8 * stdx.MiB; + const wheel_filename = try shell.fmt("tigerbeetle-{s}-py3-none-any.whl", .{options.release}); + assert(std.mem.eql(u8, pypi_package.urls[0].filename, wheel_filename)); + const wheel_url = pypi_package.urls[0].url; + + const wheel_published = try shell.http_get( + wheel_url, + .{ .response_body_size_max = wheel_size_max }, + ); + const wheel_local = try shell.cwd.readFileAlloc( + gpa, + try shell.fmt("zig-out/dist/python/{s}", .{wheel_filename}), + wheel_size_max, + ); + defer gpa.free(wheel_local); + + if (!std.mem.eql(u8, wheel_published, wheel_local)) { + std.debug.panic("tigerbeetle python package doesn't match local build", .{}); + } +} + +pub fn validate_release_sample(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, + tigerbeetle: []const u8, +}) !void { + const tmp_dir = try shell.create_tmp_dir(); + defer shell.cwd.deleteTree(tmp_dir) catch {}; + + try shell.exec("python3 -m venv {tmp_dir}", .{ .tmp_dir = tmp_dir }); + + for (0..9) |_| { + if (shell.exec("{tmp_dir}/bin/pip install tigerbeetle=={release}", .{ + .tmp_dir = tmp_dir, + .release = options.release, + })) { + break; + } else |_| { + log.warn("waiting for 5 minutes for the {s} version to appear in PyPi", .{ + options.release, + }); + std.time.sleep(5 * std.time.ns_per_min); + } + } else { + shell.exec("{tmp_dir}/bin/pip install tigerbeetle=={release}", .{ + .tmp_dir = tmp_dir, + .release = options.release, + }) catch |err| { + log.err("package is not available in PyPi", .{}); + return err; + }; + } + + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + .prebuilt = options.tigerbeetle, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + + try Shell.copy_path( + shell.project_root, + "src/clients/python/samples/basic/main.py", + shell.cwd, + "main.py", + ); + try shell.exec("{tmp_dir}/bin/python3 main.py", .{ .tmp_dir = tmp_dir }); +} + +pub fn release_published_latest(shell: *Shell) ![]const u8 { + const output = try shell.exec_stdout("python3 -m pip index versions tigerbeetle", .{}); + const version_start = std.mem.indexOf(u8, output, "(").? + 1; + const version_end = std.mem.indexOf(u8, output, ")").?; + + return output[version_start..version_end]; +} diff --git a/ocam/src/clients/python/docs.zig b/ocam/src/clients/python/docs.zig new file mode 100644 index 00000000..2cb924d9 --- /dev/null +++ b/ocam/src/clients/python/docs.zig @@ -0,0 +1,63 @@ +const Docs = @import("../docs_types.zig").Docs; + +pub const PythonDocs = Docs{ + .directory = "python", + + .markdown_name = "python", + .extension = "py", + .proper_name = "Python", + + .test_source_path = "", + + .name = "tigerbeetle-python", + .description = + \\The TigerBeetle client for Python. + , + .prerequisites = + \\* Python (or PyPy, etc) >= `3.7` + , + + .project_file = "", + .project_file_name = "", + .test_file_name = "main", + + .install_commands = "pip install tigerbeetle", + .run_commands = "python3 main.py", + + .examples = "", + + .client_object_documentation = "", + .create_accounts_documentation = "", + .account_flags_documentation = + \\To toggle behavior for an account, combine enum values stored in the + \\`AccountFlags` object (it's an `enum.IntFlag`) with bitwise-or: + \\ + \\* `AccountFlags.linked` + \\* `AccountFlags.debits_must_not_exceed_credits` + \\* `AccountFlags.credits_must_not_exceed_credits` + \\* `AccountFlags.history` + \\ + , + + .create_accounts_errors_documentation = + \\To handle errors you can compare the result code returned + \\from `client.create_accounts` with enum values in the + \\`CreateAccountStatus` object. + , + .create_transfers_documentation = "", + .create_transfers_errors_documentation = + \\To handle errors you can compare the result code returned + \\from `client.create_transfers` with enum values in the + \\`CreateTransferStatus` object. + , + + .transfer_flags_documentation = + \\To toggle behavior for a transfer, combine enum values stored in the + \\`TransferFlags` object (it's an `enum.IntFlag`) with bitwise-or: + \\ + \\* `TransferFlags.linked` + \\* `TransferFlags.pending` + \\* `TransferFlags.post_pending_transfer` + \\* `TransferFlags.void_pending_transfer` + , +}; diff --git a/ocam/src/clients/python/pyproject.toml b/ocam/src/clients/python/pyproject.toml new file mode 100644 index 00000000..b4e9914f --- /dev/null +++ b/ocam/src/clients/python/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "tigerbeetle" +version = "0.0.1" +description = "The TigerBeetle client for Python." +readme = "README.md" +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Topic :: Database :: Front-Ends", + "Development Status :: 5 - Production/Stable" +] + +[project.urls] +Homepage = "https://github.com/tigerbeetle/tigerbeetle" +Issues = "https://github.com/tigerbeetle/tigerbeetle/issues" + + +[tool.ruff] +line-length = 100 + +[tool.mypy] +strict = true +exclude = [ + "samples/", + "tests/", +] diff --git a/ocam/src/clients/python/python_bindings.zig b/ocam/src/clients/python/python_bindings.zig new file mode 100644 index 00000000..e4183e3f --- /dev/null +++ b/ocam/src/clients/python/python_bindings.zig @@ -0,0 +1,608 @@ +const std = @import("std"); +const vsr = @import("vsr"); +const exports = vsr.tb_client.exports; +const assert = std.debug.assert; +const stdx = vsr.stdx; + +const tb = vsr.tigerbeetle; + +/// VSR type mappings: these will always be the same regardless of state machine. +const mappings_vsr = .{ + .{ exports.tb_operation, "Operation" }, + .{ exports.tb_packet_status, "PacketStatus" }, + .{ exports.tb_packet_t, "Packet" }, + .{ exports.tb_client_t, "Client" }, + .{ exports.tb_init_status, "InitStatus" }, + .{ exports.tb_client_status, "ClientStatus" }, + .{ exports.tb_log_level, "LogLevel" }, + .{ exports.tb_register_log_callback_status, "RegisterLogCallbackStatus" }, +}; + +/// State machine specific mappings: in future, these should be pulled automatically from the state +/// machine. +const mappings_state_machine = .{ + .{ tb.AccountFlags, "AccountFlags" }, + .{ tb.TransferFlags, "TransferFlags" }, + .{ tb.AccountFilterFlags, "AccountFilterFlags" }, + .{ tb.QueryFilterFlags, "QueryFilterFlags" }, + .{ tb.Account, "Account" }, + .{ tb.Transfer, "Transfer" }, + .{ tb.CreateAccountStatus, "CreateAccountStatus" }, + .{ tb.CreateTransferStatus, "CreateTransferStatus" }, + .{ tb.CreateAccountResult, "CreateAccountResult" }, + .{ tb.CreateTransferResult, "CreateTransferResult" }, + .{ tb.AccountFilter, "AccountFilter" }, + .{ tb.AccountBalance, "AccountBalance" }, + .{ tb.QueryFilter, "QueryFilter" }, +}; + +const mappings_all = mappings_vsr ++ mappings_state_machine; + +const Buffer = struct { + inner: std.ArrayList(u8), + + pub fn init(allocator: std.mem.Allocator) Buffer { + return .{ + .inner = std.ArrayList(u8).init(allocator), + }; + } + + pub fn print(self: *Buffer, comptime format: []const u8, args: anytype) void { + self.inner.writer().print(format, args) catch unreachable; + } +}; + +fn mapping_name_from_type(mappings: anytype, Type: type) ?[]const u8 { + comptime for (mappings) |mapping| { + const ZigType, const python_name = mapping; + + if (Type == ZigType) { + return python_name; + } + }; + return null; +} + +/// Resolves a Zig Type into a string representing the name of a corresponding Python ctype. This +/// resolves both VSR and state machine specific mappings, as both are needed when interfacing via +/// FFI. +fn zig_to_ctype(comptime Type: type) []const u8 { + switch (@typeInfo(Type)) { + .array => |info| { + return std.fmt.comptimePrint("{s} * {d}", .{ + comptime zig_to_ctype(info.child), + info.len, + }); + }, + .@"enum" => |info| return zig_to_ctype(info.tag_type), + .@"struct" => return zig_to_ctype(std.meta.Int(.unsigned, @bitSizeOf(Type))), + .bool => return "ctypes.c_bool", + .int => |info| { + assert(info.signedness == .unsigned); + return switch (info.bits) { + 8 => "ctypes.c_uint8", + 16 => "ctypes.c_uint16", + 32 => "ctypes.c_uint32", + 64 => "ctypes.c_uint64", + 128 => "c_uint128", + else => @compileError("invalid int type"), + }; + }, + .optional => |info| switch (@typeInfo(info.child)) { + .pointer => return zig_to_ctype(info.child), + else => @compileError("Unsupported optional type: " ++ @typeName(Type)), + }, + .pointer => |info| { + assert(info.size == .one); + assert(!info.is_allowzero); + + if (Type == *anyopaque) { + return "ctypes.c_void_p"; + } + + return comptime "ctypes.POINTER(C" ++ + mapping_name_from_type(mappings_all, info.child).? ++ + ")"; + }, + .void => return "None", + else => @compileError("Unhandled type: " ++ @typeName(Type)), + } +} + +/// Resolves a Zig Type into a string representing the name of a corresponding Python dataclass. +/// Unlike zig_to_ctype, this only resolves state machine specific mappings: VSR mappings are +/// internal to the client, and not exposed to calling code. +fn zig_to_python(comptime Type: type) []const u8 { + switch (@typeInfo(Type)) { + .@"enum" => return comptime mapping_name_from_type(mappings_state_machine, Type).?, + .array => |info| { + return std.fmt.comptimePrint("{s}[{d}]", .{ + comptime zig_to_python(info.child), + info.len, + }); + }, + .@"struct" => return comptime mapping_name_from_type(mappings_state_machine, Type).?, + .bool => return "bool", + .int => |info| { + assert(info.signedness == .unsigned); + return switch (info.bits) { + 8 => "int", + 16 => "int", + 32 => "int", + 64 => "int", + 128 => "int", + else => @compileError("invalid int type"), + }; + }, + .void => return "None", + else => @compileError("Unhandled type: " ++ @typeName(Type)), + } +} + +fn emit_enum( + buffer: *Buffer, + comptime Type: type, + comptime type_info: anytype, + comptime python_name: []const u8, + comptime skip_fields: []const []const u8, +) !void { + if (@typeInfo(Type) == .@"enum") { + buffer.print("class {s}(enum.IntEnum):\n", .{python_name}); + } else { + // Packed structs. + assert(@typeInfo(Type) == .@"struct" and @typeInfo(Type).@"struct".layout == .@"packed"); + + buffer.print("class {s}(enum.IntFlag):\n", .{python_name}); + buffer.print(" NONE = 0\n", .{}); + } + + inline for (type_info.fields, 0..) |field, i| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + comptime var skip = false; + inline for (skip_fields) |sf| { + skip = skip or comptime std.mem.eql(u8, sf, field.name); + } + + if (!skip) { + const field_name = stdx.to_case(field.name, .UPPER_CASE); + if (@typeInfo(Type) == .@"enum") { + const int_value = @intFromEnum(@field(Type, field.name)); + buffer.print(" {s} = {s}\n", .{ + field_name, + if (int_value == std.math.maxInt(@TypeOf(int_value))) + std.fmt.comptimePrint("0x{X}", .{int_value}) + else + std.fmt.comptimePrint("{}", .{int_value}), + }); + } else { + // Packed structs. + buffer.print(" {s} = 1 << {}\n", .{ + field_name, + i, + }); + } + } + } + + buffer.print("\n\n", .{}); +} + +fn emit_struct_ctypes( + buffer: *Buffer, + comptime type_info: anytype, + comptime python_name: []const u8, + generate_ctypes_to_python: bool, +) !void { + buffer.print( + \\class C{[type_name]s}(ctypes.Structure): + \\ @classmethod + \\ def from_param(cls, obj: Any) -> Self: + \\ + , .{ + .type_name = python_name, + }); + + inline for (type_info.fields) |field| { + const field_type_info = @typeInfo(field.type); + + // Emit a bounds check for all integer types that aren't using the custom c_uint128 class. + // That has an explicit check built in, but the standard Python ctypes ones (eg, + // ctypes.c_uint64) don't and will happily overflow otherwise. + if (comptime !std.mem.eql(u8, field.name, "reserved") and field_type_info == .int) { + buffer.print(" validate_uint(bits={[int_bits]}, name=\"{[field_name]s}\", " ++ + "number=obj.{[field_name]s})\n", .{ + .field_name = field.name, + .int_bits = field_type_info.int.bits, + }); + } + } + + buffer.print(" return cls(\n", .{}); + + inline for (type_info.fields) |field| { + const field_type_info = @typeInfo(field.type); + const field_is_u128 = field_type_info == .int and field_type_info.int.bits == 128; + const convert_prefix = if (field_is_u128) "c_uint128.from_param(" else ""; + const convert_suffix = if (field_is_u128) ")" else ""; + + if (comptime !std.mem.eql(u8, field.name, "reserved")) { + buffer.print(" {[field_name]s}={[convert_prefix]s}" ++ + "obj.{[field_name]s}{[convert_suffix]s},\n", .{ + .field_name = field.name, + .convert_prefix = convert_prefix, + .convert_suffix = convert_suffix, + }); + } + } + buffer.print(" )\n\n", .{}); + + if (generate_ctypes_to_python) { + buffer.print( + \\ + \\ def to_python(self) -> {[type_name]s}: + \\ return {[type_name]s}( + \\ + , .{ + .type_name = python_name, + }); + + inline for (type_info.fields) |field| { + if (comptime !std.mem.eql(u8, field.name, "reserved")) { + buffer.print(" {s}={s},\n", .{ + field.name, + convert_ctypes_to_python("self." ++ field.name, field.type), + }); + } + } + buffer.print(" )\n\n", .{}); + } + + buffer.print("C{s}._fields_ = [ # noqa: SLF001\n", .{python_name}); + + inline for (type_info.fields) |field| { + buffer.print(" (\"{s}\", {s}),", .{ + field.name, + zig_to_ctype(field.type), + }); + + buffer.print("\n", .{}); + } + + buffer.print("]\n\n\n", .{}); +} + +fn convert_ctypes_to_python(comptime name: []const u8, comptime Type: type) []const u8 { + inline for (mappings_state_machine) |type_mapping| { + const ZigType, const python_name = type_mapping; + + if (ZigType == Type) { + return python_name ++ "(" ++ name ++ ")"; + } + } + if (@typeInfo(Type) == .int and @typeInfo(Type).int.bits == 128) { + return name ++ ".to_python()"; + } + + return name; +} + +fn emit_struct_dataclass( + buffer: *Buffer, + comptime type_info: anytype, + comptime python_name: []const u8, + has_default_initialization: bool, +) !void { + buffer.print("@dataclass\n", .{}); + buffer.print("class {s}:\n", .{python_name}); + + inline for (type_info.fields) |field| { + const field_type_info = @typeInfo(field.type); + if (comptime !std.mem.eql(u8, field.name, "reserved")) { + const python_type = zig_to_python(field.type); + buffer.print(" {[name]s}: {[python_type]s}", .{ + .name = field.name, + .python_type = python_type, + }); + + if (has_default_initialization) { + buffer.print(" = ", .{}); + if (field_type_info == .@"struct" and + field_type_info.@"struct".layout == .@"packed") + { + // Flags: + buffer.print("{s}.NONE", .{python_type}); + } else { + if (field_type_info == .@"enum") { + // Enums - initialized with the default value. + buffer.print("{s}.{s}", .{ + python_type, + stdx.to_case(@tagName(@as(field.type, @enumFromInt(0))), .UPPER_CASE), + }); + } else { + // Simple integer types: + buffer.print("0", .{}); + } + } + } + buffer.print("\n", .{}); + } + } + + buffer.print("\n\n", .{}); +} + +fn ctype_type_name(comptime Type: type) []const u8 { + if (Type == u128) { + return "c_uint128"; + } + + return comptime "C" ++ mapping_name_from_type(mappings_all, Type).?; +} + +fn emit_method( + buffer: *Buffer, + comptime operation: tb.Operation, + options: struct { is_async: bool }, +) void { + const event_type = comptime if (operation.is_batchable()) + "list[" ++ zig_to_python(operation.EventType()) ++ "]" + else + zig_to_python(operation.EventType()); + + const result_type = + comptime "list[" ++ zig_to_python(operation.ResultType()) ++ "]"; + + // For ergonomics, the client allows calling things like .query_accounts(filter) even + // though the _submit function requires a list for everything. Wrap them here. + const event_name_or_list = comptime if (!operation.is_batchable()) + "[" ++ event_name(operation) ++ "]" + else + event_name(operation); + + // NB: _submit is loosely annotated, the operations define interfaces for the Python developer. + buffer.print( + \\ {[prefix_fn]s}def {[fn_name]s}(self, {[event_name]s}: {[event_type]s}) -> {[result_type]s}: + \\ return {[prefix_call]s}self._submit( # type: ignore[no-any-return] + \\ Operation.{[uppercase_name]s}, + \\ {[event_name_or_list]s}, + \\ {[event_type_c]s}, + \\ {[result_type_c]s}, + \\ ) + \\ + \\ + , + .{ + .prefix_fn = if (options.is_async) "async " else "", + .fn_name = @tagName(operation), + .event_name = event_name(operation), + .event_type = event_type, + .result_type = result_type, + .event_name_or_list = event_name_or_list, + .prefix_call = if (options.is_async) "await " else "", + .uppercase_name = stdx.to_case(@tagName(operation), .UPPER_CASE), + .event_type_c = ctype_type_name(operation.EventType()), + .result_type_c = ctype_type_name(operation.ResultType()), + }, + ); +} + +pub fn main() !void { + @setEvalBranchQuota(100_000); + + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var buffer = Buffer.init(allocator); + buffer.print( + \\######################################################### + \\## This file was auto-generated by python_bindings.zig ## + \\## Do not manually modify. ## + \\######################################################### + \\from __future__ import annotations + \\ + \\import ctypes + \\import enum + \\import sys + \\from dataclasses import dataclass + \\from collections.abc import Callable # noqa: TCH003 + \\from typing import Any + \\if sys.version_info >= (3, 11): + \\ from typing import Self + \\else: + \\ from typing_extensions import Self + \\ + \\from .lib import c_uint128, tbclient, validate_uint + \\ + \\# Use slots=True if the version of Python is new enough (3.10+) to support it. + \\if sys.version_info >= (3, 10): + \\ # mypy: ignore assignment (3.10+) and unused-ignore (pre 3.10) + \\ dataclass = dataclass(slots=True) # type: ignore[assignment, unused-ignore] + \\ + \\ + \\ + , .{}); + + // Emit enum and direct declarations. + inline for (mappings_all) |type_mapping| { + const ZigType, const python_name = type_mapping; + + switch (@typeInfo(ZigType)) { + .@"struct" => |info| switch (info.layout) { + .auto => @compileError("Invalid C struct type: " ++ @typeName(ZigType)), + .@"packed" => try emit_enum(&buffer, ZigType, info, python_name, &.{"padding"}), + .@"extern" => continue, + }, + .@"enum" => |info| { + comptime var skip: []const []const u8 = &.{}; + if (ZigType == exports.tb_operation) { + skip = &.{ "reserved", "root", "register" }; + } + + try emit_enum(&buffer, ZigType, info, python_name, skip); + }, + else => buffer.print("{s} = {s}\n\n", .{ + python_name, + zig_to_ctype(ZigType), + }), + } + } + + // Emit dataclass declarations + inline for (mappings_state_machine) |type_mapping| { + const ZigType, const python_name = type_mapping; + const has_default_initialization = switch (ZigType) { + tb.AccountFilter, + tb.QueryFilter, + tb.CreateAccountResult, + tb.CreateTransferResult, + tb.AccountBalance, + => false, + else => true, + }; + + // Enums, non-extern structs and everything else have been emitted by the first pass. + switch (@typeInfo(ZigType)) { + .@"struct" => |info| switch (info.layout) { + .@"extern" => try emit_struct_dataclass( + &buffer, + info, + python_name, + has_default_initialization, + ), + else => {}, + }, + else => {}, + } + } + + // Emit ctype struct and enum type declarations. + inline for (mappings_all) |type_mapping| { + const ZigType, const python_name = type_mapping; + + // VSR ctype structs don't have a corresponding Python dataclass - so don't generate the + // `def to_python(self):` method for them. + const generate_ctypes_to_python = comptime mapping_name_from_type( + mappings_state_machine, + ZigType, + ) != null; + + switch (@typeInfo(ZigType)) { + .@"struct" => |info| switch (info.layout) { + .auto => @compileError("Invalid C struct type: " ++ @typeName(ZigType)), + .@"packed" => continue, + .@"extern" => try emit_struct_ctypes( + &buffer, + info, + python_name, + generate_ctypes_to_python, + ), + }, + else => continue, + } + } + + // Emit function declarations corresponding to the underlying libtbclient exported functions. + // TODO: use `std.meta.declaractions` and generate with pub + export functions. + buffer.print( + \\# Don't be tempted to use c_char_p for bytes_ptr - it's for null terminated strings only. + \\OnCompletion = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(CPacket), + \\ ctypes.c_uint64, ctypes.c_void_p, ctypes.c_uint32) + \\LogHandler = ctypes.CFUNCTYPE(None, ctypes.c_uint, ctypes.c_void_p, ctypes.c_uint) + \\ + \\class InitParameters(ctypes.Structure): + \\ _fields_ = [("cluster_id", c_uint128), ("client_id", c_uint128), + \\ ("addresses_ptr", ctypes.c_void_p), ("addresses_len", ctypes.c_uint64)] + \\ + \\# Initialize a new TigerBeetle client which connects to the addresses provided and + \\# completes submitted packets by invoking the callback with the given context. + \\tb_client_init = tbclient.tb_client_init + \\tb_client_init.restype = InitStatus + \\tb_client_init.argtypes = [ctypes.POINTER(CClient), ctypes.POINTER(ctypes.c_uint8 * 16), + \\ ctypes.c_char_p, ctypes.c_uint32, ctypes.c_void_p, + \\ OnCompletion] + \\ + \\# Initialize a new TigerBeetle client which echos back any data submitted. + \\tb_client_init_echo = tbclient.tb_client_init_echo + \\tb_client_init_echo.restype = InitStatus + \\tb_client_init_echo.argtypes = [ctypes.POINTER(CClient), ctypes.POINTER(ctypes.c_uint8 * 16), + \\ ctypes.c_char_p, ctypes.c_uint32, ctypes.c_void_p, + \\ OnCompletion] + \\ + \\# Returns the cluster_id and addresses passed in to either tb_client_init or + \\# tb_client_init_echo. + \\tb_client_init_parameters = tbclient.tb_client_init_parameters + \\tb_client_init_parameters.restype = ClientStatus + \\tb_client_init_parameters.argtypes = [ctypes.POINTER(CClient), + \\ ctypes.POINTER(InitParameters)] + \\ + \\# Closes the client, causing any previously submitted packets to be completed with + \\# `TB_PACKET_CLIENT_SHUTDOWN` before freeing any allocated client resources from init. + \\# It is undefined behavior to use any functions on the client once deinit is called. + \\tb_client_deinit = tbclient.tb_client_deinit + \\tb_client_deinit.restype = ClientStatus + \\tb_client_deinit.argtypes = [ctypes.POINTER(CClient)] + \\ + \\# Submit a packet with its operation, data, and data_size fields set. + \\# Once completed, `on_completion` will be invoked with `on_completion_ctx` and the given + \\# packet on the `tb_client` thread (separate from caller's thread). + \\tb_client_submit = tbclient.tb_client_submit + \\tb_client_submit.restype = ClientStatus + \\tb_client_submit.argtypes = [ctypes.POINTER(CClient), ctypes.POINTER(CPacket)] + \\ + \\tb_client_register_log_callback = tbclient.tb_client_register_log_callback + \\tb_client_register_log_callback.restype = RegisterLogCallbackStatus + \\# Need to pass in None to clear - ctypes will error if argtypes is set. + \\# tb_client_register_log_callback.argtypes = [LogHandler, ctypes.c_bool] + \\ + \\ + \\ + , .{}); + + inline for (.{ true, false }) |is_async| { + const prefix_class = if (is_async) "Async" else ""; + + // This is annotated loosely, the operations calling it will contain their + // own annotations so the interface is clear to Python as well. + buffer.print( + \\class {s}StateMachineMixin: + \\ _submit: Callable[[Operation, Any, Any, Any], Any] + \\ + , .{prefix_class}); + + const operations: []const tb.Operation = &.{ + .create_accounts, + .create_transfers, + .lookup_accounts, + .lookup_transfers, + .get_account_transfers, + .get_account_balances, + .query_accounts, + .query_transfers, + }; + inline for (operations) |operation| { + emit_method(&buffer, operation, .{ .is_async = is_async }); + } + + buffer.print("\n\n", .{}); + } + + try std.io.getStdOut().writeAll(buffer.inner.items); +} + +/// Used by client code generation to make clearer APIs: the name of the Event parameter, +/// when used as a variable. +/// Inline function so that `operation` can be known at comptime. +fn event_name(comptime operation: tb.Operation) []const u8 { + return switch (operation) { + .create_accounts => "accounts", + .create_transfers => "transfers", + .lookup_accounts => "accounts", + .lookup_transfers => "transfers", + .get_account_transfers => "filter", + .get_account_balances => "filter", + .query_accounts => "query_filter", + .query_transfers => "query_filter", + else => comptime unreachable, + }; +} diff --git a/ocam/src/clients/python/samples/basic/README.md b/ocam/src/clients/python/samples/basic/README.md new file mode 100644 index 00000000..97aa8b74 --- /dev/null +++ b/ocam/src/clients/python/samples/basic/README.md @@ -0,0 +1,61 @@ + +# Basic Python Sample + +Code for this sample is in [./main.py](./main.py). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Python (or PyPy, etc) >= `3.7` + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/python/samples/basic`. + +Then, install the TigerBeetle client: + +```console +pip install tigerbeetle +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +python3 main.py +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create a transfer + +Then it transfers `10` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate account balances + +Then it fetches both accounts, checks they both exist, and +checks that **account `1`** has: + * `debits_posted = 10` + * and `credits_posted = 0` + +And that **account `2`** has: + * `debits_posted= 0` + * and `credits_posted = 10` diff --git a/ocam/src/clients/python/samples/basic/main.py b/ocam/src/clients/python/samples/basic/main.py new file mode 100644 index 00000000..dc49e11c --- /dev/null +++ b/ocam/src/clients/python/samples/basic/main.py @@ -0,0 +1,51 @@ +import os + +import tigerbeetle as tb + +with tb.ClientSync(cluster_id=0, replica_addresses=os.getenv("TB_ADDRESS", "3000")) as client: + account_results = client.create_accounts([ + tb.Account( + id=1, + ledger=1, + code=1, + ), + tb.Account( + id=2, + ledger=1, + code=1, + ), + ]) + + print(account_results) + assert len(account_results) == 2 + assert account_results[0].status == tb.CreateAccountStatus.CREATED + assert account_results[1].status == tb.CreateAccountStatus.CREATED + + transfers_results = client.create_transfers([ + tb.Transfer( + id=1, + debit_account_id=1, + credit_account_id=2, + amount=10, + ledger=1, + code=1, + ), + ]) + + print(transfers_results) + assert len(transfers_results) == 1 + assert transfers_results[0].status == tb.CreateTransferStatus.CREATED + + accounts = client.lookup_accounts([1, 2]) + assert len(accounts) == 2 + for account in accounts: + if account.id == 1: + assert account.debits_posted == 10 + assert account.credits_posted == 0 + elif account.id == 2: + assert account.debits_posted == 0 + assert account.credits_posted == 10 + else: + raise Exception("Unexpected account: " + account) + + print("ok") diff --git a/ocam/src/clients/python/samples/two-phase-many/README.md b/ocam/src/clients/python/samples/two-phase-many/README.md new file mode 100644 index 00000000..f0bd4d4b --- /dev/null +++ b/ocam/src/clients/python/samples/two-phase-many/README.md @@ -0,0 +1,91 @@ + +# Many Two-Phase Transfers Python Sample + +Code for this sample is in [./main.py](./main.py). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Python (or PyPy, etc) >= `3.7` + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/python/samples/two-phase-many`. + +Then, install the TigerBeetle client: + +```console +pip install tigerbeetle +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +python3 main.py +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfers + +Then it begins 5 pending transfers of amounts `100` to +`500`, incrementing by `100` for each transfer. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 1500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 1500` + +(This is because a pending transfer only affects **pending** +credits and debits on accounts, not **posted** credits and +debits.) + +## 4. Post and void alternating transfers + +Then it alternatively posts and voids each transfer, +checking account balances after each transfer. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +solely *posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 900` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 900` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/python/samples/two-phase-many/main.py b/ocam/src/clients/python/samples/two-phase-many/main.py new file mode 100644 index 00000000..b0cf1771 --- /dev/null +++ b/ocam/src/clients/python/samples/two-phase-many/main.py @@ -0,0 +1,268 @@ +import os + +import tigerbeetle as tb + +with tb.ClientSync(cluster_id=0, replica_addresses=os.getenv("TB_ADDRESS", "3000")) as client: + # Create two accounts. + account_results = client.create_accounts([ + tb.Account( + id=1, + ledger=1, + code=1, + ), + tb.Account( + id=2, + ledger=1, + code=1, + ), + ]) + + print(account_results) + assert len(account_results) == 2 + assert account_results[0].status == tb.CreateAccountStatus.CREATED + assert account_results[1].status == tb.CreateAccountStatus.CREATED + + # Start five pending transfers. + transfers = [ + tb.Transfer( + id=1, + debit_account_id=1, + credit_account_id=2, + amount=100, + ledger=1, + code=1, + flags=tb.TransferFlags.PENDING, + ), + tb.Transfer( + id=2, + debit_account_id=1, + credit_account_id=2, + amount=200, + ledger=1, + code=1, + flags=tb.TransferFlags.PENDING, + ), + tb.Transfer( + id=3, + debit_account_id=1, + credit_account_id=2, + amount=300, + ledger=1, + code=1, + flags=tb.TransferFlags.PENDING, + ), + tb.Transfer( + id=4, + debit_account_id=1, + credit_account_id=2, + amount=400, + ledger=1, + code=1, + flags=tb.TransferFlags.PENDING, + ), + tb.Transfer( + id=5, + debit_account_id=1, + credit_account_id=2, + amount=500, + ledger=1, + code=1, + flags=tb.TransferFlags.PENDING, + ), + ] + transfers_results = client.create_transfers(transfers) + print(transfers_results) + assert len(transfers_results) == len(transfers) + for result in transfers_results: + assert result.status == tb.CreateTransferStatus.CREATED + + # Validate accounts pending and posted debits/credits before + # finishing the two-phase transfer. + accounts = client.lookup_accounts([1, 2]) + assert len(accounts) == 2 + for account in accounts: + if account.id == 1: + assert account.debits_posted == 0 + assert account.credits_posted == 0 + assert account.debits_pending == 1500 + assert account.credits_pending == 0 + elif account.id == 2: + assert account.debits_posted == 0 + assert account.credits_posted == 0 + assert account.debits_pending == 0 + assert account.credits_pending == 1500 + else: + raise Exception("Unexpected account: " + account) + + + # Create a 6th transfer posting the 1st transfer. + transfers_results = client.create_transfers([ + tb.Transfer( + id=6, + debit_account_id=1, + credit_account_id=2, + amount=100, + pending_id=1, + ledger=1, + code=1, + flags=tb.TransferFlags.POST_PENDING_TRANSFER, + ) + ]) + print(transfers_results) + assert len(transfers_results) == 1 + assert transfers_results[0].status == tb.CreateTransferStatus.CREATED + + # Validate account balances after posting 1st pending transfer. + accounts = client.lookup_accounts([1, 2]) + assert len(accounts) == 2 + for account in accounts: + if account.id == 1: + assert account.debits_posted == 100 + assert account.credits_posted == 0 + assert account.debits_pending == 1400 + assert account.credits_pending == 0 + elif account.id == 2: + assert account.debits_posted == 0 + assert account.credits_posted == 100 + assert account.debits_pending == 0 + assert account.credits_pending == 1400 + else: + raise Exception("Unexpected account: " + account) + + # Create a 7th transfer voiding the 2d transfer. + transfers_results = client.create_transfers([ + tb.Transfer( + id=7, + debit_account_id=1, + credit_account_id=2, + amount=200, + pending_id=2, + ledger=1, + code=1, + flags=tb.TransferFlags.VOID_PENDING_TRANSFER, + ) + ]) + print(transfers_results) + assert len(transfers_results) == 1 + assert transfers_results[0].status == tb.CreateTransferStatus.CREATED + + # Validate account balances after voiding 2d pending transfer. + accounts = client.lookup_accounts([1, 2]) + assert len(accounts) == 2 + for account in accounts: + if account.id == 1: + assert account.debits_posted == 100 + assert account.credits_posted == 0 + assert account.debits_pending == 1200 + assert account.credits_pending == 0 + elif account.id == 2: + assert account.debits_posted == 0 + assert account.credits_posted == 100 + assert account.debits_pending == 0 + assert account.credits_pending == 1200 + else: + raise Exception("Unexpected account: " + account) + + # Create a 8th transfer posting the 3rd transfer. + transfers_results = client.create_transfers([ + tb.Transfer( + id=8, + debit_account_id=1, + credit_account_id=2, + amount=300, + pending_id=3, + ledger=1, + code=1, + flags=tb.TransferFlags.POST_PENDING_TRANSFER, + ) + ]) + print(transfers_results) + assert len(transfers_results) == 1 + assert transfers_results[0].status == tb.CreateTransferStatus.CREATED + + # Validate account balances after posting 3rd pending transfer. + accounts = client.lookup_accounts([1, 2]) + assert len(accounts) == 2 + for account in accounts: + if account.id == 1: + assert account.debits_posted == 400 + assert account.credits_posted == 0 + assert account.debits_pending == 900 + assert account.credits_pending == 0 + elif account.id == 2: + assert account.debits_posted == 0 + assert account.credits_posted == 400 + assert account.debits_pending == 0 + assert account.credits_pending == 900 + else: + raise Exception("Unexpected account: " + account) + + # Create a 9th transfer voiding the 4th transfer. + transfer_results = client.create_transfers([ + tb.Transfer( + id=9, + debit_account_id=1, + credit_account_id=2, + amount=400, + pending_id=4, + ledger=1, + code=1, + flags=tb.TransferFlags.VOID_PENDING_TRANSFER, + ) + ]) + print(transfers_results) + assert len(transfers_results) == 1 + assert transfers_results[0].status == tb.CreateTransferStatus.CREATED + + # Validate account balances after voiding 4th pending transfer. + accounts = client.lookup_accounts([1, 2]) + assert len(accounts) == 2 + for account in accounts: + if account.id == 1: + assert account.debits_posted == 400 + assert account.credits_posted == 0 + assert account.debits_pending == 500 + assert account.credits_pending == 0 + elif account.id == 2: + assert account.debits_posted == 0 + assert account.credits_posted == 400 + assert account.debits_pending == 0 + assert account.credits_pending == 500 + else: + raise Exception("Unexpected account: " + account) + + # Create a 10th transfer posting the 5th transfer. + transfers_results = client.create_transfers([ + tb.Transfer( + id=10, + debit_account_id=1, + credit_account_id=2, + amount=500, + pending_id=5, + ledger=1, + code=1, + flags=tb.TransferFlags.POST_PENDING_TRANSFER, + ) + ]) + print(transfers_results) + assert len(transfers_results) == 1 + assert transfers_results[0].status == tb.CreateTransferStatus.CREATED + + # Validate account balances after posting 5th pending transfer. + accounts = client.lookup_accounts([1, 2]) + assert len(accounts) == 2 + for account in accounts: + if account.id == 1: + assert account.debits_posted == 900 + assert account.credits_posted == 0 + assert account.debits_pending == 0 + assert account.credits_pending == 0 + elif account.id == 2: + assert account.debits_posted == 0 + assert account.credits_posted == 900 + assert account.debits_pending == 0 + assert account.credits_pending == 0 + else: + raise Exception("Unexpected account: " + account) + + print('ok') diff --git a/ocam/src/clients/python/samples/two-phase/README.md b/ocam/src/clients/python/samples/two-phase/README.md new file mode 100644 index 00000000..bdbcd1a5 --- /dev/null +++ b/ocam/src/clients/python/samples/two-phase/README.md @@ -0,0 +1,100 @@ + +# Two-Phase Transfer Python Sample + +Code for this sample is in [./main.py](./main.py). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Python (or PyPy, etc) >= `3.7` + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/python/samples/two-phase`. + +Then, install the TigerBeetle client: + +```console +pip install tigerbeetle +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +python3 main.py +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfer + +Then it begins a +pending transfer of `500` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 500` + +(This is because a pending +transfer only affects **pending** credits and debits on accounts, +not **posted** credits and debits.) + +## 4. Post pending transfer + +Then it creates a second transfer that marks the first +transfer as posted. + +## 5. Fetch and validate transfers + +Then it fetches both transfers, validates +that the two transfers exist, validates that the first +transfer had (and still has) a `pending` flag, and validates +that the second transfer had (and still has) a +`post_pending_transfer` flag. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +*posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 500` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 500` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/python/samples/two-phase/main.py b/ocam/src/clients/python/samples/two-phase/main.py new file mode 100644 index 00000000..2f1717f2 --- /dev/null +++ b/ocam/src/clients/python/samples/two-phase/main.py @@ -0,0 +1,106 @@ +import os + +import tigerbeetle as tb + +with tb.ClientSync(cluster_id=0, replica_addresses=os.getenv("TB_ADDRESS", "3000")) as client: + # Create two accounts. + account_results = client.create_accounts([ + tb.Account( + id=1, + ledger=1, + code=1, + ), + tb.Account( + id=2, + ledger=1, + code=1, + ), + ]) + + print(account_results) + assert len(account_results) == 2 + assert account_results[0].status == tb.CreateAccountStatus.CREATED + assert account_results[1].status == tb.CreateAccountStatus.CREATED + + # Start a pending transfer + transfers_results = client.create_transfers([ + tb.Transfer( + id=1, + debit_account_id=1, + credit_account_id=2, + amount=500, + ledger=1, + code=1, + flags=tb.TransferFlags.PENDING, + ) + ]) + + print(transfers_results) + assert len(transfers_results) == 1 + assert transfers_results[0].timestamp > 0 + assert transfers_results[0].status == tb.CreateTransferStatus.CREATED + + # Validate accounts pending and posted debits/credits before finishing the two-phase transfer + accounts = client.lookup_accounts([1, 2]) + assert len(accounts) == 2 + for account in accounts: + if account.id == 1: + assert account.debits_posted == 0 + assert account.credits_posted == 0 + assert account.debits_pending == 500 + assert account.credits_pending == 0 + elif account.id == 2: + assert account.debits_posted == 0 + assert account.credits_posted == 0 + assert account.debits_pending == 0 + assert account.credits_pending == 500 + else: + raise Exception("Unexpected account: " + account) + + # Create a second transfer simply posting the first transfer + transfers_results = client.create_transfers([ + tb.Transfer( + id=2, + debit_account_id=1, + credit_account_id=2, + amount=500, + pending_id=1, + ledger=1, + code=1, + flags=tb.TransferFlags.POST_PENDING_TRANSFER, + ), + ]) + print(transfers_results) + assert len(transfers_results) == 1 + assert transfers_results[0].status == tb.CreateTransferStatus.CREATED + + # Validate the contents of all transfers + transfers = client.lookup_transfers([1, 2]) + assert len(transfers) == 2 + for transfer in transfers: + if transfer.id == 1: + assert transfer.flags & tb.TransferFlags.PENDING == tb.TransferFlags.PENDING + elif transfer.id == 2: + assert transfer.flags & tb.TransferFlags.POST_PENDING_TRANSFER == tb.TransferFlags.POST_PENDING_TRANSFER + else: + raise Exception("Unexpected transfer: " + transfer) + + + # Validate accounts pending and posted debits/credits after finishing the two-phase transfer + accounts = client.lookup_accounts([1, 2]) + assert len(accounts) == 2 + for account in accounts: + if account.id == 1: + assert account.debits_posted == 500 + assert account.credits_posted == 0 + assert account.debits_pending == 0 + assert account.credits_pending == 0 + elif account.id == 2: + assert account.debits_posted == 0 + assert account.credits_posted == 500 + assert account.debits_pending == 0 + assert account.credits_pending == 0 + else: + raise Exception("Unexpected account: " + account) + + print('ok') diff --git a/ocam/src/clients/python/samples/walkthrough/README.md b/ocam/src/clients/python/samples/walkthrough/README.md new file mode 100644 index 00000000..b657597b --- /dev/null +++ b/ocam/src/clients/python/samples/walkthrough/README.md @@ -0,0 +1 @@ +Code from the [top-level README.md](../../README.md) collected into a single runnable project. diff --git a/ocam/src/clients/python/samples/walkthrough/main.py b/ocam/src/clients/python/samples/walkthrough/main.py new file mode 100644 index 00000000..a215ab73 --- /dev/null +++ b/ocam/src/clients/python/samples/walkthrough/main.py @@ -0,0 +1,548 @@ +# section:imports +import os + +import tigerbeetle as tb + +print("Import OK!") + +# To enable debug logging, via Python's built in logging module: +# logging.basicConfig(level=logging.DEBUG) +# tb.configure_logging(debug=True) +# endsection:imports + +# Need to wrap in an async function for the async with to be valid Python. +async def example_init(): + # section:client + with tb.ClientSync(cluster_id=0, replica_addresses=os.getenv("TB_ADDRESS", "3000")) as client: + # Use the client. + pass + + # Alternatively: + async with tb.ClientAsync(cluster_id=0, replica_addresses=os.getenv("TB_ADDRESS", "3000")) as client: + # Use the client, async! + pass + # endsection:client + +# The examples currently throws because the batch is actually invalid (most of fields are +# undefined). Ideally, we prepare a correct batch here while keeping the syntax compact, +# for the example, but for the time being lets prioritize a readable example and just +# swallow the error. + +with tb.ClientSync(cluster_id=0, replica_addresses=os.getenv("TB_ADDRESS", "3000")) as client: + try: + # section:create-accounts + account = tb.Account( + id=tb.id(), # TigerBeetle time-based ID. + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=718, + flags=0, + timestamp=0, + ) + + account_results = client.create_accounts([account]) + # Results handling omitted. + # endsection:create-accounts + except: + raise + + try: + # section:account-flags + account0 = tb.Account( + id=100, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=1, + timestamp=0, + flags=tb.AccountFlags.LINKED | tb.AccountFlags.DEBITS_MUST_NOT_EXCEED_CREDITS, + ) + account1 = tb.Account( + id=101, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=1, + timestamp=0, + flags=tb.AccountFlags.HISTORY, + ) + + account_results = client.create_accounts([account0, account1]) + # Results handling omitted. + # endsection:account-flags + except: + raise + + try: + # section:create-accounts-errors + account0 = tb.Account( + id=102, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=1, + timestamp=0, + flags=0, + ) + account1 = tb.Account( + id=103, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=1, + timestamp=0, + flags=0, + ) + account2 = tb.Account( + id=104, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=1, + timestamp=0, + flags=0, + ) + + account_results = client.create_accounts([account0, account1, account2]) + for i, result in enumerate(account_results): + if result.status == tb.CreateAccountStatus.CREATED: + print(f"Batch account at {i} successfully created with timestamp {result.timestamp}.") + elif result.status == tb.CreateAccountStatus.EXISTS: + print(f"Batch account at {i} already exists with timestamp {result.timestamp}.") + else: + print(f"Batch account at {i} failed to create: {result.status}.") + # endsection:create-accounts-errors + except: + raise + + try: + # section:lookup-accounts + accounts = client.lookup_accounts([100, 101]) + # endsection:lookup-accounts + except: + raise + + try: + # section:create-transfers + transfers = [tb.Transfer( + id=tb.id(), # TigerBeetle time-based ID. + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=0, + timestamp=0, + )] + + transfers_results = client.create_transfers(transfers) + # Results handling omitted. + # endsection:create-transfers + except: + raise + + try: + # section:create-transfers-errors + batch = [tb.Transfer( + id=1, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=0, + timestamp=0, + ), + tb.Transfer( + id=2, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=0, + timestamp=0, + ), + tb.Transfer( + id=3, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=0, + timestamp=0, + )] + + transfers_results = client.create_transfers(batch) + for i, result in enumerate(transfers_results): + if result.status == tb.CreateTransferStatus.CREATED: + print(f"Batch transfer at {i} successfully created with timestamp {result.timestamp}.") + elif result.status == tb.CreateTransferStatus.EXISTS: + print(f"Batch transfer at {i} already exists with timestamp {result.timestamp}.") + else: + print(f"Batch transfer at {i} failed to create: {result.status}.") + # endsection:create-transfers-errors + except: + raise + + try: + # section:batch + batch = [] # Array of transfer to create. + BATCH_SIZE = 8189 #FIXME + for i in range(0, len(batch), BATCH_SIZE): + transfers_results = client.create_transfers( + batch[i:min(len(batch), i + BATCH_SIZE)], + ) + # Results handling omitted. + # endsection:batch + except: + raise + + try: + # section:transfer-flags-link + transfer0 = tb.Transfer( + id=4, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=tb.TransferFlags.LINKED, + timestamp=0, + ) + transfer1 = tb.Transfer( + id=5, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=0, + timestamp=0, + ) + + # Create the transfer + transfers_results = client.create_transfers([transfer0, transfer1]) + # Results handling omitted. + # endsection:transfer-flags-link + except: + raise + + try: + # section:transfer-flags-post + transfer0 = tb.Transfer( + id=6, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=tb.TransferFlags.PENDING, + timestamp=0, + ) + + transfers_results = client.create_transfers([transfer0]) + # Results handling omitted. + + transfer1 = tb.Transfer( + id=7, + debit_account_id=102, + credit_account_id=103, + # Post the entire pending amount. + amount=tb.AMOUNT_MAX, + pending_id=6, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=tb.TransferFlags.POST_PENDING_TRANSFER, + timestamp=0, + ) + + transfers_results = client.create_transfers([transfer1]) + # Results handling omitted. + # endsection:transfer-flags-post + except: + raise + + try: + # section:transfer-flags-void + transfer0 = tb.Transfer( + id=8, + debit_account_id=102, + credit_account_id=103, + amount=10, + pending_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=tb.TransferFlags.PENDING, + timestamp=0, + ) + + transfers_results = client.create_transfers([transfer0]) + # Results handling omitted. + + transfer1 = tb.Transfer( + id=9, + debit_account_id=102, + credit_account_id=103, + amount=0, + pending_id=8, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=720, + flags=tb.TransferFlags.VOID_PENDING_TRANSFER, + timestamp=0, + ) + + transfers_results = client.create_transfers([transfer1]) + # Results handling omitted. + # endsection:transfer-flags-void + except: + raise + + try: + # section:lookup-transfers + transfers = client.lookup_transfers([1, 2]) + # endsection:lookup-transfers + except: + raise + + try: + # section:get-account-transfers + filter = tb.AccountFilter( + account_id=2, + user_data_128=0, # No filter by UserData. + user_data_64=0, + user_data_32=0, + code=0, # No filter by Code. + timestamp_min=0, # No filter by Timestamp. + timestamp_max=0, # No filter by Timestamp. + limit=10, # Limit to ten transfers at most. + flags=tb.AccountFilterFlags.DEBITS | # Include transfer from the debit side. + tb.AccountFilterFlags.CREDITS | # Include transfer from the credit side. + tb.AccountFilterFlags.REVERSED, # Sort by timestamp in reverse-chronological order. + ) + + account_transfers = client.get_account_transfers(filter) + # endsection:get-account-transfers + except: + raise + + try: + # section:get-account-balances + filter = tb.AccountFilter( + account_id=2, + user_data_128=0, # No filter by UserData. + user_data_64=0, + user_data_32=0, + code=0, # No filter by Code. + timestamp_min=0, # No filter by Timestamp. + timestamp_max=0, # No filter by Timestamp. + limit=10, # Limit to ten balances at most. + flags=tb.AccountFilterFlags.DEBITS | # Include transfer from the debit side. + tb.AccountFilterFlags.CREDITS | # Include transfer from the credit side. + tb.AccountFilterFlags.REVERSED, # Sort by timestamp in reverse-chronological order. + ) + + account_balances = client.get_account_balances(filter) + # endsection:get-account-balances + except: + raise + + try: + # section:query-accounts + query_filter = tb.QueryFilter( + user_data_128=1000, # Filter by UserData. + user_data_64=100, + user_data_32=10, + code=1, # Filter by Code. + ledger=0, # No filter by Ledger. + timestamp_min=0, # No filter by Timestamp. + timestamp_max=0, # No filter by Timestamp. + limit=10, # Limit to ten accounts at most. + flags=tb.QueryFilterFlags.REVERSED, # Sort by timestamp in reverse-chronological order. + ) + + query_accounts = client.query_accounts(query_filter) + # endsection:query-accounts + except: + raise + + try: + # section:query-transfers + query_filter = tb.QueryFilter( + user_data_128=1000, # Filter by UserData. + user_data_64=100, + user_data_32=10, + code=1, # Filter by Code. + ledger=0, # No filter by Ledger. + timestamp_min=0, # No filter by Timestamp. + timestamp_max=0, # No filter by Timestamp. + limit=10, # Limit to ten transfers at most. + flags=tb.QueryFilterFlags.REVERSED, # Sort by timestamp in reverse-chronological order. + ) + + query_transfers = client.query_transfers(query_filter) + # endsection:query-transfers + except: + raise + + try: + # section:linked-events + batch = [] # List of tb.Transfers to create. + linkedFlag = 0 + linkedFlag |= tb.TransferFlags.LINKED + + # An individual transfer (successful): + batch.append(tb.Transfer(id=1)) + + # A chain of 4 transfers (the last transfer in the chain closes the chain with linked=false): + batch.append(tb.Transfer(id=2, flags=linkedFlag)) # Commit/rollback. + batch.append(tb.Transfer(id=3, flags=linkedFlag)) # Commit/rollback. + batch.append(tb.Transfer(id=2, flags=linkedFlag)) # Fail with exists + batch.append(tb.Transfer(id=4, flags=0)) # Fail without committing. + + # An individual transfer (successful): + # This should not see any effect from the failed chain above. + batch.append(tb.Transfer(id=2, flags=0 )) + + # A chain of 2 transfers (the first transfer fails the chain): + batch.append(tb.Transfer(id=2, flags=linkedFlag)) + batch.append(tb.Transfer(id=3, flags=0)) + + # A chain of 2 transfers (successful): + batch.append(tb.Transfer(id=3, flags=linkedFlag)) + batch.append(tb.Transfer(id=4, flags=0)) + + transfers_results = client.create_transfers(batch) + # Results handling omitted. + # endsection:linked-events + except: + raise + + try: + # section:imported-events + # External source of time. + historical_timestamp = 0 + # Events loaded from an external source. + historical_accounts = [] # Loaded from an external source. + historical_transfers = [] # Loaded from an external source. + + # First, load and import all accounts with their timestamps from the historical source. + accounts = [] + for index, account in enumerate(historical_accounts): + # Set a unique and strictly increasing timestamp. + historical_timestamp += 1 + account.timestamp = historical_timestamp + # Set the account as `imported`. + account.flags = tb.AccountFlags.IMPORTED + # To ensure atomicity, the entire batch (except the last event in the chain) + # must be `linked`. + if index < len(historical_accounts) - 1: + account.flags |= tb.AccountFlags.LINKED + + accounts.append(account) + + account_results = client.create_accounts(accounts) + # Results handling omitted. + + # The, load and import all transfers with their timestamps from the historical source. + transfers = [] + for index, transfer in enumerate(historical_transfers): + # Set a unique and strictly increasing timestamp. + historical_timestamp += 1 + transfer.timestamp = historical_timestamp + # Set the account as `imported`. + transfer.flags = tb.TransferFlags.IMPORTED + # To ensure atomicity, the entire batch (except the last event in the chain) + # must be `linked`. + if index < len(historical_transfers) - 1: + transfer.flags |= tb.AccountFlags.LINKED + + transfers.append(transfer) + + transfers_results = client.create_transfers(transfers) + # Results handling omitted. + + # Since it is a linked chain, in case of any error the entire batch is rolled back and can be retried + # with the same historical timestamps without regressing the cluster timestamp. + # endsection:imported-events + except: + raise diff --git a/ocam/src/clients/python/src/tigerbeetle/__init__.py b/ocam/src/clients/python/src/tigerbeetle/__init__.py new file mode 100644 index 00000000..b847e645 --- /dev/null +++ b/ocam/src/clients/python/src/tigerbeetle/__init__.py @@ -0,0 +1,44 @@ +from .bindings import * # noqa +from .client import ClientAsync, ClientSync, id, AMOUNT_MAX, configure_logging +from .client import ClientClosedError, ClientEvictedError, ClientReleaseTooHighError, ClientReleaseTooLowError, TooMuchDataError # noqa +from .lib import IntegerOverflowError, NativeError + +# Explicitly declare public exports: +__all__ = [ + # from .client: + "ClientAsync", + "ClientSync", + "id", + "AMOUNT_MAX", + "configure_logging", + "ClientClosedError", + "ClientEvictedError", + "ClientReleaseTooHighError", + "ClientReleaseTooLowError", + "TooMuchDataError", + # from .lib: + "IntegerOverflowError", + "NativeError", + # from .bindings: + "Operation", + "InitStatus", + "ClientStatus", + "LogLevel", + "RegisterLogCallbackStatus", + "AccountFlags", + "TransferFlags", + "AccountFilterFlags", + "QueryFilterFlags", + "CreateAccountStatus", + "CreateTransferStatus", + "Account", + "Transfer", + "CreateAccountResult", + "CreateTransferResult", + "AccountFilter", + "AccountBalance", + "QueryFilter", + "InitParameters", + "AsyncStateMachineMixin", + "StateMachineMixin", +] diff --git a/ocam/src/clients/python/src/tigerbeetle/bindings.py b/ocam/src/clients/python/src/tigerbeetle/bindings.py new file mode 100644 index 00000000..801ea7ba --- /dev/null +++ b/ocam/src/clients/python/src/tigerbeetle/bindings.py @@ -0,0 +1,832 @@ +######################################################### +## This file was auto-generated by python_bindings.zig ## +## Do not manually modify. ## +######################################################### +from __future__ import annotations + +import ctypes +import enum +import sys +from dataclasses import dataclass +from collections.abc import Callable # noqa: TCH003 +from typing import Any +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + +from .lib import c_uint128, tbclient, validate_uint + +# Use slots=True if the version of Python is new enough (3.10+) to support it. +if sys.version_info >= (3, 10): + # mypy: ignore assignment (3.10+) and unused-ignore (pre 3.10) + dataclass = dataclass(slots=True) # type: ignore[assignment, unused-ignore] + + +class Operation(enum.IntEnum): + PULSE = 128 + GET_CHANGE_EVENTS = 137 + LOOKUP_ACCOUNTS = 140 + LOOKUP_TRANSFERS = 141 + GET_ACCOUNT_TRANSFERS = 142 + GET_ACCOUNT_BALANCES = 143 + QUERY_ACCOUNTS = 144 + QUERY_TRANSFERS = 145 + CREATE_ACCOUNTS = 146 + CREATE_TRANSFERS = 147 + + +class PacketStatus(enum.IntEnum): + OK = 0 + TOO_MUCH_DATA = 1 + CLIENT_EVICTED = 2 + CLIENT_RELEASE_TOO_LOW = 3 + CLIENT_RELEASE_TOO_HIGH = 4 + CLIENT_SHUTDOWN = 5 + INVALID_OPERATION = 6 + INVALID_DATA_SIZE = 7 + + +class InitStatus(enum.IntEnum): + SUCCESS = 0 + UNEXPECTED = 1 + OUT_OF_MEMORY = 2 + ADDRESS_INVALID = 3 + ADDRESS_LIMIT_EXCEEDED = 4 + SYSTEM_RESOURCES = 5 + NETWORK_SUBSYSTEM = 6 + + +class ClientStatus(enum.IntEnum): + OK = 0 + INVALID = 1 + + +class LogLevel(enum.IntEnum): + ERR = 0 + WARN = 1 + INFO = 2 + DEBUG = 3 + + +class RegisterLogCallbackStatus(enum.IntEnum): + SUCCESS = 0 + ALREADY_REGISTERED = 1 + NOT_REGISTERED = 2 + + +class AccountFlags(enum.IntFlag): + NONE = 0 + LINKED = 1 << 0 + DEBITS_MUST_NOT_EXCEED_CREDITS = 1 << 1 + CREDITS_MUST_NOT_EXCEED_DEBITS = 1 << 2 + HISTORY = 1 << 3 + IMPORTED = 1 << 4 + CLOSED = 1 << 5 + + +class TransferFlags(enum.IntFlag): + NONE = 0 + LINKED = 1 << 0 + PENDING = 1 << 1 + POST_PENDING_TRANSFER = 1 << 2 + VOID_PENDING_TRANSFER = 1 << 3 + BALANCING_DEBIT = 1 << 4 + BALANCING_CREDIT = 1 << 5 + CLOSING_DEBIT = 1 << 6 + CLOSING_CREDIT = 1 << 7 + IMPORTED = 1 << 8 + + +class AccountFilterFlags(enum.IntFlag): + NONE = 0 + DEBITS = 1 << 0 + CREDITS = 1 << 1 + REVERSED = 1 << 2 + + +class QueryFilterFlags(enum.IntFlag): + NONE = 0 + REVERSED = 1 << 0 + + +class CreateAccountStatus(enum.IntEnum): + CREATED = 0xFFFFFFFF + LINKED_EVENT_FAILED = 1 + LINKED_EVENT_CHAIN_OPEN = 2 + IMPORTED_EVENT_EXPECTED = 22 + IMPORTED_EVENT_NOT_EXPECTED = 23 + TIMESTAMP_MUST_BE_ZERO = 3 + IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE = 24 + IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE = 25 + RESERVED_FIELD = 4 + RESERVED_FLAG = 5 + ID_MUST_NOT_BE_ZERO = 6 + ID_MUST_NOT_BE_INT_MAX = 7 + EXISTS_WITH_DIFFERENT_FLAGS = 15 + EXISTS_WITH_DIFFERENT_USER_DATA_128 = 16 + EXISTS_WITH_DIFFERENT_USER_DATA_64 = 17 + EXISTS_WITH_DIFFERENT_USER_DATA_32 = 18 + EXISTS_WITH_DIFFERENT_LEDGER = 19 + EXISTS_WITH_DIFFERENT_CODE = 20 + EXISTS = 21 + FLAGS_ARE_MUTUALLY_EXCLUSIVE = 8 + DEBITS_PENDING_MUST_BE_ZERO = 9 + DEBITS_POSTED_MUST_BE_ZERO = 10 + CREDITS_PENDING_MUST_BE_ZERO = 11 + CREDITS_POSTED_MUST_BE_ZERO = 12 + LEDGER_MUST_NOT_BE_ZERO = 13 + CODE_MUST_NOT_BE_ZERO = 14 + IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS = 26 + + +class CreateTransferStatus(enum.IntEnum): + CREATED = 0xFFFFFFFF + LINKED_EVENT_FAILED = 1 + LINKED_EVENT_CHAIN_OPEN = 2 + IMPORTED_EVENT_EXPECTED = 56 + IMPORTED_EVENT_NOT_EXPECTED = 57 + TIMESTAMP_MUST_BE_ZERO = 3 + IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE = 58 + IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE = 59 + RESERVED_FLAG = 4 + ID_MUST_NOT_BE_ZERO = 5 + ID_MUST_NOT_BE_INT_MAX = 6 + EXISTS_WITH_DIFFERENT_FLAGS = 36 + EXISTS_WITH_DIFFERENT_PENDING_ID = 40 + EXISTS_WITH_DIFFERENT_TIMEOUT = 44 + EXISTS_WITH_DIFFERENT_DEBIT_ACCOUNT_ID = 37 + EXISTS_WITH_DIFFERENT_CREDIT_ACCOUNT_ID = 38 + EXISTS_WITH_DIFFERENT_AMOUNT = 39 + EXISTS_WITH_DIFFERENT_USER_DATA_128 = 41 + EXISTS_WITH_DIFFERENT_USER_DATA_64 = 42 + EXISTS_WITH_DIFFERENT_USER_DATA_32 = 43 + EXISTS_WITH_DIFFERENT_LEDGER = 67 + EXISTS_WITH_DIFFERENT_CODE = 45 + EXISTS = 46 + ID_ALREADY_FAILED = 68 + FLAGS_ARE_MUTUALLY_EXCLUSIVE = 7 + DEBIT_ACCOUNT_ID_MUST_NOT_BE_ZERO = 8 + DEBIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 9 + CREDIT_ACCOUNT_ID_MUST_NOT_BE_ZERO = 10 + CREDIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 11 + ACCOUNTS_MUST_BE_DIFFERENT = 12 + PENDING_ID_MUST_BE_ZERO = 13 + PENDING_ID_MUST_NOT_BE_ZERO = 14 + PENDING_ID_MUST_NOT_BE_INT_MAX = 15 + PENDING_ID_MUST_BE_DIFFERENT = 16 + TIMEOUT_RESERVED_FOR_PENDING_TRANSFER = 17 + CLOSING_TRANSFER_MUST_BE_PENDING = 64 + LEDGER_MUST_NOT_BE_ZERO = 19 + CODE_MUST_NOT_BE_ZERO = 20 + DEBIT_ACCOUNT_NOT_FOUND = 21 + CREDIT_ACCOUNT_NOT_FOUND = 22 + ACCOUNTS_MUST_HAVE_THE_SAME_LEDGER = 23 + TRANSFER_MUST_HAVE_THE_SAME_LEDGER_AS_ACCOUNTS = 24 + PENDING_TRANSFER_NOT_FOUND = 25 + PENDING_TRANSFER_NOT_PENDING = 26 + PENDING_TRANSFER_HAS_DIFFERENT_DEBIT_ACCOUNT_ID = 27 + PENDING_TRANSFER_HAS_DIFFERENT_CREDIT_ACCOUNT_ID = 28 + PENDING_TRANSFER_HAS_DIFFERENT_LEDGER = 29 + PENDING_TRANSFER_HAS_DIFFERENT_CODE = 30 + EXCEEDS_PENDING_TRANSFER_AMOUNT = 31 + PENDING_TRANSFER_HAS_DIFFERENT_AMOUNT = 32 + PENDING_TRANSFER_ALREADY_POSTED = 33 + PENDING_TRANSFER_ALREADY_VOIDED = 34 + PENDING_TRANSFER_EXPIRED = 35 + IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS = 60 + IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_DEBIT_ACCOUNT = 61 + IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_CREDIT_ACCOUNT = 62 + IMPORTED_EVENT_TIMEOUT_MUST_BE_ZERO = 63 + DEBIT_ACCOUNT_ALREADY_CLOSED = 65 + CREDIT_ACCOUNT_ALREADY_CLOSED = 66 + OVERFLOWS_DEBITS_PENDING = 47 + OVERFLOWS_CREDITS_PENDING = 48 + OVERFLOWS_DEBITS_POSTED = 49 + OVERFLOWS_CREDITS_POSTED = 50 + OVERFLOWS_DEBITS = 51 + OVERFLOWS_CREDITS = 52 + OVERFLOWS_TIMEOUT = 53 + EXCEEDS_CREDITS = 54 + EXCEEDS_DEBITS = 55 + + +@dataclass +class Account: + id: int = 0 + debits_pending: int = 0 + debits_posted: int = 0 + credits_pending: int = 0 + credits_posted: int = 0 + user_data_128: int = 0 + user_data_64: int = 0 + user_data_32: int = 0 + ledger: int = 0 + code: int = 0 + flags: AccountFlags = AccountFlags.NONE + timestamp: int = 0 + + +@dataclass +class Transfer: + id: int = 0 + debit_account_id: int = 0 + credit_account_id: int = 0 + amount: int = 0 + pending_id: int = 0 + user_data_128: int = 0 + user_data_64: int = 0 + user_data_32: int = 0 + timeout: int = 0 + ledger: int = 0 + code: int = 0 + flags: TransferFlags = TransferFlags.NONE + timestamp: int = 0 + + +@dataclass +class CreateAccountResult: + timestamp: int + status: CreateAccountStatus + + +@dataclass +class CreateTransferResult: + timestamp: int + status: CreateTransferStatus + + +@dataclass +class AccountFilter: + account_id: int + user_data_128: int + user_data_64: int + user_data_32: int + code: int + timestamp_min: int + timestamp_max: int + limit: int + flags: AccountFilterFlags + + +@dataclass +class AccountBalance: + debits_pending: int + debits_posted: int + credits_pending: int + credits_posted: int + timestamp: int + + +@dataclass +class QueryFilter: + user_data_128: int + user_data_64: int + user_data_32: int + ledger: int + code: int + timestamp_min: int + timestamp_max: int + limit: int + flags: QueryFilterFlags + + +class CPacket(ctypes.Structure): + @classmethod + def from_param(cls, obj: Any) -> Self: + validate_uint(bits=32, name="data_size", number=obj.data_size) + validate_uint(bits=16, name="user_tag", number=obj.user_tag) + validate_uint(bits=8, name="operation", number=obj.operation) + return cls( + user_data=obj.user_data, + data=obj.data, + data_size=obj.data_size, + user_tag=obj.user_tag, + operation=obj.operation, + status=obj.status, + opaque=obj.opaque, + ) + +CPacket._fields_ = [ # noqa: SLF001 + ("user_data", ctypes.c_void_p), + ("data", ctypes.c_void_p), + ("data_size", ctypes.c_uint32), + ("user_tag", ctypes.c_uint16), + ("operation", ctypes.c_uint8), + ("status", ctypes.c_uint8), + ("opaque", ctypes.c_uint8 * 64), +] + + +class CClient(ctypes.Structure): + @classmethod + def from_param(cls, obj: Any) -> Self: + return cls( + opaque=obj.opaque, + ) + +CClient._fields_ = [ # noqa: SLF001 + ("opaque", ctypes.c_uint64 * 4), +] + + +class CAccount(ctypes.Structure): + @classmethod + def from_param(cls, obj: Any) -> Self: + validate_uint(bits=128, name="id", number=obj.id) + validate_uint(bits=128, name="debits_pending", number=obj.debits_pending) + validate_uint(bits=128, name="debits_posted", number=obj.debits_posted) + validate_uint(bits=128, name="credits_pending", number=obj.credits_pending) + validate_uint(bits=128, name="credits_posted", number=obj.credits_posted) + validate_uint(bits=128, name="user_data_128", number=obj.user_data_128) + validate_uint(bits=64, name="user_data_64", number=obj.user_data_64) + validate_uint(bits=32, name="user_data_32", number=obj.user_data_32) + validate_uint(bits=32, name="ledger", number=obj.ledger) + validate_uint(bits=16, name="code", number=obj.code) + validate_uint(bits=64, name="timestamp", number=obj.timestamp) + return cls( + id=c_uint128.from_param(obj.id), + debits_pending=c_uint128.from_param(obj.debits_pending), + debits_posted=c_uint128.from_param(obj.debits_posted), + credits_pending=c_uint128.from_param(obj.credits_pending), + credits_posted=c_uint128.from_param(obj.credits_posted), + user_data_128=c_uint128.from_param(obj.user_data_128), + user_data_64=obj.user_data_64, + user_data_32=obj.user_data_32, + ledger=obj.ledger, + code=obj.code, + flags=obj.flags, + timestamp=obj.timestamp, + ) + + + def to_python(self) -> Account: + return Account( + id=self.id.to_python(), + debits_pending=self.debits_pending.to_python(), + debits_posted=self.debits_posted.to_python(), + credits_pending=self.credits_pending.to_python(), + credits_posted=self.credits_posted.to_python(), + user_data_128=self.user_data_128.to_python(), + user_data_64=self.user_data_64, + user_data_32=self.user_data_32, + ledger=self.ledger, + code=self.code, + flags=AccountFlags(self.flags), + timestamp=self.timestamp, + ) + +CAccount._fields_ = [ # noqa: SLF001 + ("id", c_uint128), + ("debits_pending", c_uint128), + ("debits_posted", c_uint128), + ("credits_pending", c_uint128), + ("credits_posted", c_uint128), + ("user_data_128", c_uint128), + ("user_data_64", ctypes.c_uint64), + ("user_data_32", ctypes.c_uint32), + ("reserved", ctypes.c_uint32), + ("ledger", ctypes.c_uint32), + ("code", ctypes.c_uint16), + ("flags", ctypes.c_uint16), + ("timestamp", ctypes.c_uint64), +] + + +class CTransfer(ctypes.Structure): + @classmethod + def from_param(cls, obj: Any) -> Self: + validate_uint(bits=128, name="id", number=obj.id) + validate_uint(bits=128, name="debit_account_id", number=obj.debit_account_id) + validate_uint(bits=128, name="credit_account_id", number=obj.credit_account_id) + validate_uint(bits=128, name="amount", number=obj.amount) + validate_uint(bits=128, name="pending_id", number=obj.pending_id) + validate_uint(bits=128, name="user_data_128", number=obj.user_data_128) + validate_uint(bits=64, name="user_data_64", number=obj.user_data_64) + validate_uint(bits=32, name="user_data_32", number=obj.user_data_32) + validate_uint(bits=32, name="timeout", number=obj.timeout) + validate_uint(bits=32, name="ledger", number=obj.ledger) + validate_uint(bits=16, name="code", number=obj.code) + validate_uint(bits=64, name="timestamp", number=obj.timestamp) + return cls( + id=c_uint128.from_param(obj.id), + debit_account_id=c_uint128.from_param(obj.debit_account_id), + credit_account_id=c_uint128.from_param(obj.credit_account_id), + amount=c_uint128.from_param(obj.amount), + pending_id=c_uint128.from_param(obj.pending_id), + user_data_128=c_uint128.from_param(obj.user_data_128), + user_data_64=obj.user_data_64, + user_data_32=obj.user_data_32, + timeout=obj.timeout, + ledger=obj.ledger, + code=obj.code, + flags=obj.flags, + timestamp=obj.timestamp, + ) + + + def to_python(self) -> Transfer: + return Transfer( + id=self.id.to_python(), + debit_account_id=self.debit_account_id.to_python(), + credit_account_id=self.credit_account_id.to_python(), + amount=self.amount.to_python(), + pending_id=self.pending_id.to_python(), + user_data_128=self.user_data_128.to_python(), + user_data_64=self.user_data_64, + user_data_32=self.user_data_32, + timeout=self.timeout, + ledger=self.ledger, + code=self.code, + flags=TransferFlags(self.flags), + timestamp=self.timestamp, + ) + +CTransfer._fields_ = [ # noqa: SLF001 + ("id", c_uint128), + ("debit_account_id", c_uint128), + ("credit_account_id", c_uint128), + ("amount", c_uint128), + ("pending_id", c_uint128), + ("user_data_128", c_uint128), + ("user_data_64", ctypes.c_uint64), + ("user_data_32", ctypes.c_uint32), + ("timeout", ctypes.c_uint32), + ("ledger", ctypes.c_uint32), + ("code", ctypes.c_uint16), + ("flags", ctypes.c_uint16), + ("timestamp", ctypes.c_uint64), +] + + +class CCreateAccountResult(ctypes.Structure): + @classmethod + def from_param(cls, obj: Any) -> Self: + validate_uint(bits=64, name="timestamp", number=obj.timestamp) + return cls( + timestamp=obj.timestamp, + status=obj.status, + ) + + + def to_python(self) -> CreateAccountResult: + return CreateAccountResult( + timestamp=self.timestamp, + status=CreateAccountStatus(self.status), + ) + +CCreateAccountResult._fields_ = [ # noqa: SLF001 + ("timestamp", ctypes.c_uint64), + ("status", ctypes.c_uint32), + ("reserved", ctypes.c_uint32), +] + + +class CCreateTransferResult(ctypes.Structure): + @classmethod + def from_param(cls, obj: Any) -> Self: + validate_uint(bits=64, name="timestamp", number=obj.timestamp) + return cls( + timestamp=obj.timestamp, + status=obj.status, + ) + + + def to_python(self) -> CreateTransferResult: + return CreateTransferResult( + timestamp=self.timestamp, + status=CreateTransferStatus(self.status), + ) + +CCreateTransferResult._fields_ = [ # noqa: SLF001 + ("timestamp", ctypes.c_uint64), + ("status", ctypes.c_uint32), + ("reserved", ctypes.c_uint32), +] + + +class CAccountFilter(ctypes.Structure): + @classmethod + def from_param(cls, obj: Any) -> Self: + validate_uint(bits=128, name="account_id", number=obj.account_id) + validate_uint(bits=128, name="user_data_128", number=obj.user_data_128) + validate_uint(bits=64, name="user_data_64", number=obj.user_data_64) + validate_uint(bits=32, name="user_data_32", number=obj.user_data_32) + validate_uint(bits=16, name="code", number=obj.code) + validate_uint(bits=64, name="timestamp_min", number=obj.timestamp_min) + validate_uint(bits=64, name="timestamp_max", number=obj.timestamp_max) + validate_uint(bits=32, name="limit", number=obj.limit) + return cls( + account_id=c_uint128.from_param(obj.account_id), + user_data_128=c_uint128.from_param(obj.user_data_128), + user_data_64=obj.user_data_64, + user_data_32=obj.user_data_32, + code=obj.code, + timestamp_min=obj.timestamp_min, + timestamp_max=obj.timestamp_max, + limit=obj.limit, + flags=obj.flags, + ) + + + def to_python(self) -> AccountFilter: + return AccountFilter( + account_id=self.account_id.to_python(), + user_data_128=self.user_data_128.to_python(), + user_data_64=self.user_data_64, + user_data_32=self.user_data_32, + code=self.code, + timestamp_min=self.timestamp_min, + timestamp_max=self.timestamp_max, + limit=self.limit, + flags=AccountFilterFlags(self.flags), + ) + +CAccountFilter._fields_ = [ # noqa: SLF001 + ("account_id", c_uint128), + ("user_data_128", c_uint128), + ("user_data_64", ctypes.c_uint64), + ("user_data_32", ctypes.c_uint32), + ("code", ctypes.c_uint16), + ("reserved", ctypes.c_uint8 * 58), + ("timestamp_min", ctypes.c_uint64), + ("timestamp_max", ctypes.c_uint64), + ("limit", ctypes.c_uint32), + ("flags", ctypes.c_uint32), +] + + +class CAccountBalance(ctypes.Structure): + @classmethod + def from_param(cls, obj: Any) -> Self: + validate_uint(bits=128, name="debits_pending", number=obj.debits_pending) + validate_uint(bits=128, name="debits_posted", number=obj.debits_posted) + validate_uint(bits=128, name="credits_pending", number=obj.credits_pending) + validate_uint(bits=128, name="credits_posted", number=obj.credits_posted) + validate_uint(bits=64, name="timestamp", number=obj.timestamp) + return cls( + debits_pending=c_uint128.from_param(obj.debits_pending), + debits_posted=c_uint128.from_param(obj.debits_posted), + credits_pending=c_uint128.from_param(obj.credits_pending), + credits_posted=c_uint128.from_param(obj.credits_posted), + timestamp=obj.timestamp, + ) + + + def to_python(self) -> AccountBalance: + return AccountBalance( + debits_pending=self.debits_pending.to_python(), + debits_posted=self.debits_posted.to_python(), + credits_pending=self.credits_pending.to_python(), + credits_posted=self.credits_posted.to_python(), + timestamp=self.timestamp, + ) + +CAccountBalance._fields_ = [ # noqa: SLF001 + ("debits_pending", c_uint128), + ("debits_posted", c_uint128), + ("credits_pending", c_uint128), + ("credits_posted", c_uint128), + ("timestamp", ctypes.c_uint64), + ("reserved", ctypes.c_uint8 * 56), +] + + +class CQueryFilter(ctypes.Structure): + @classmethod + def from_param(cls, obj: Any) -> Self: + validate_uint(bits=128, name="user_data_128", number=obj.user_data_128) + validate_uint(bits=64, name="user_data_64", number=obj.user_data_64) + validate_uint(bits=32, name="user_data_32", number=obj.user_data_32) + validate_uint(bits=32, name="ledger", number=obj.ledger) + validate_uint(bits=16, name="code", number=obj.code) + validate_uint(bits=64, name="timestamp_min", number=obj.timestamp_min) + validate_uint(bits=64, name="timestamp_max", number=obj.timestamp_max) + validate_uint(bits=32, name="limit", number=obj.limit) + return cls( + user_data_128=c_uint128.from_param(obj.user_data_128), + user_data_64=obj.user_data_64, + user_data_32=obj.user_data_32, + ledger=obj.ledger, + code=obj.code, + timestamp_min=obj.timestamp_min, + timestamp_max=obj.timestamp_max, + limit=obj.limit, + flags=obj.flags, + ) + + + def to_python(self) -> QueryFilter: + return QueryFilter( + user_data_128=self.user_data_128.to_python(), + user_data_64=self.user_data_64, + user_data_32=self.user_data_32, + ledger=self.ledger, + code=self.code, + timestamp_min=self.timestamp_min, + timestamp_max=self.timestamp_max, + limit=self.limit, + flags=QueryFilterFlags(self.flags), + ) + +CQueryFilter._fields_ = [ # noqa: SLF001 + ("user_data_128", c_uint128), + ("user_data_64", ctypes.c_uint64), + ("user_data_32", ctypes.c_uint32), + ("ledger", ctypes.c_uint32), + ("code", ctypes.c_uint16), + ("reserved", ctypes.c_uint8 * 6), + ("timestamp_min", ctypes.c_uint64), + ("timestamp_max", ctypes.c_uint64), + ("limit", ctypes.c_uint32), + ("flags", ctypes.c_uint32), +] + + +# Don't be tempted to use c_char_p for bytes_ptr - it's for null terminated strings only. +OnCompletion = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(CPacket), + ctypes.c_uint64, ctypes.c_void_p, ctypes.c_uint32) +LogHandler = ctypes.CFUNCTYPE(None, ctypes.c_uint, ctypes.c_void_p, ctypes.c_uint) + +class InitParameters(ctypes.Structure): + _fields_ = [("cluster_id", c_uint128), ("client_id", c_uint128), + ("addresses_ptr", ctypes.c_void_p), ("addresses_len", ctypes.c_uint64)] + +# Initialize a new TigerBeetle client which connects to the addresses provided and +# completes submitted packets by invoking the callback with the given context. +tb_client_init = tbclient.tb_client_init +tb_client_init.restype = InitStatus +tb_client_init.argtypes = [ctypes.POINTER(CClient), ctypes.POINTER(ctypes.c_uint8 * 16), + ctypes.c_char_p, ctypes.c_uint32, ctypes.c_void_p, + OnCompletion] + +# Initialize a new TigerBeetle client which echos back any data submitted. +tb_client_init_echo = tbclient.tb_client_init_echo +tb_client_init_echo.restype = InitStatus +tb_client_init_echo.argtypes = [ctypes.POINTER(CClient), ctypes.POINTER(ctypes.c_uint8 * 16), + ctypes.c_char_p, ctypes.c_uint32, ctypes.c_void_p, + OnCompletion] + +# Returns the cluster_id and addresses passed in to either tb_client_init or +# tb_client_init_echo. +tb_client_init_parameters = tbclient.tb_client_init_parameters +tb_client_init_parameters.restype = ClientStatus +tb_client_init_parameters.argtypes = [ctypes.POINTER(CClient), + ctypes.POINTER(InitParameters)] + +# Closes the client, causing any previously submitted packets to be completed with +# `TB_PACKET_CLIENT_SHUTDOWN` before freeing any allocated client resources from init. +# It is undefined behavior to use any functions on the client once deinit is called. +tb_client_deinit = tbclient.tb_client_deinit +tb_client_deinit.restype = ClientStatus +tb_client_deinit.argtypes = [ctypes.POINTER(CClient)] + +# Submit a packet with its operation, data, and data_size fields set. +# Once completed, `on_completion` will be invoked with `on_completion_ctx` and the given +# packet on the `tb_client` thread (separate from caller's thread). +tb_client_submit = tbclient.tb_client_submit +tb_client_submit.restype = ClientStatus +tb_client_submit.argtypes = [ctypes.POINTER(CClient), ctypes.POINTER(CPacket)] + +tb_client_register_log_callback = tbclient.tb_client_register_log_callback +tb_client_register_log_callback.restype = RegisterLogCallbackStatus +# Need to pass in None to clear - ctypes will error if argtypes is set. +# tb_client_register_log_callback.argtypes = [LogHandler, ctypes.c_bool] + + +class AsyncStateMachineMixin: + _submit: Callable[[Operation, Any, Any, Any], Any] + async def create_accounts(self, accounts: list[Account]) -> list[CreateAccountResult]: + return await self._submit( # type: ignore[no-any-return] + Operation.CREATE_ACCOUNTS, + accounts, + CAccount, + CCreateAccountResult, + ) + + async def create_transfers(self, transfers: list[Transfer]) -> list[CreateTransferResult]: + return await self._submit( # type: ignore[no-any-return] + Operation.CREATE_TRANSFERS, + transfers, + CTransfer, + CCreateTransferResult, + ) + + async def lookup_accounts(self, accounts: list[int]) -> list[Account]: + return await self._submit( # type: ignore[no-any-return] + Operation.LOOKUP_ACCOUNTS, + accounts, + c_uint128, + CAccount, + ) + + async def lookup_transfers(self, transfers: list[int]) -> list[Transfer]: + return await self._submit( # type: ignore[no-any-return] + Operation.LOOKUP_TRANSFERS, + transfers, + c_uint128, + CTransfer, + ) + + async def get_account_transfers(self, filter: AccountFilter) -> list[Transfer]: + return await self._submit( # type: ignore[no-any-return] + Operation.GET_ACCOUNT_TRANSFERS, + [filter], + CAccountFilter, + CTransfer, + ) + + async def get_account_balances(self, filter: AccountFilter) -> list[AccountBalance]: + return await self._submit( # type: ignore[no-any-return] + Operation.GET_ACCOUNT_BALANCES, + [filter], + CAccountFilter, + CAccountBalance, + ) + + async def query_accounts(self, query_filter: QueryFilter) -> list[Account]: + return await self._submit( # type: ignore[no-any-return] + Operation.QUERY_ACCOUNTS, + [query_filter], + CQueryFilter, + CAccount, + ) + + async def query_transfers(self, query_filter: QueryFilter) -> list[Transfer]: + return await self._submit( # type: ignore[no-any-return] + Operation.QUERY_TRANSFERS, + [query_filter], + CQueryFilter, + CTransfer, + ) + + + +class StateMachineMixin: + _submit: Callable[[Operation, Any, Any, Any], Any] + def create_accounts(self, accounts: list[Account]) -> list[CreateAccountResult]: + return self._submit( # type: ignore[no-any-return] + Operation.CREATE_ACCOUNTS, + accounts, + CAccount, + CCreateAccountResult, + ) + + def create_transfers(self, transfers: list[Transfer]) -> list[CreateTransferResult]: + return self._submit( # type: ignore[no-any-return] + Operation.CREATE_TRANSFERS, + transfers, + CTransfer, + CCreateTransferResult, + ) + + def lookup_accounts(self, accounts: list[int]) -> list[Account]: + return self._submit( # type: ignore[no-any-return] + Operation.LOOKUP_ACCOUNTS, + accounts, + c_uint128, + CAccount, + ) + + def lookup_transfers(self, transfers: list[int]) -> list[Transfer]: + return self._submit( # type: ignore[no-any-return] + Operation.LOOKUP_TRANSFERS, + transfers, + c_uint128, + CTransfer, + ) + + def get_account_transfers(self, filter: AccountFilter) -> list[Transfer]: + return self._submit( # type: ignore[no-any-return] + Operation.GET_ACCOUNT_TRANSFERS, + [filter], + CAccountFilter, + CTransfer, + ) + + def get_account_balances(self, filter: AccountFilter) -> list[AccountBalance]: + return self._submit( # type: ignore[no-any-return] + Operation.GET_ACCOUNT_BALANCES, + [filter], + CAccountFilter, + CAccountBalance, + ) + + def query_accounts(self, query_filter: QueryFilter) -> list[Account]: + return self._submit( # type: ignore[no-any-return] + Operation.QUERY_ACCOUNTS, + [query_filter], + CQueryFilter, + CAccount, + ) + + def query_transfers(self, query_filter: QueryFilter) -> list[Transfer]: + return self._submit( # type: ignore[no-any-return] + Operation.QUERY_TRANSFERS, + [query_filter], + CQueryFilter, + CTransfer, + ) + + + diff --git a/ocam/src/clients/python/src/tigerbeetle/client.py b/ocam/src/clients/python/src/tigerbeetle/client.py new file mode 100644 index 00000000..4b87cd2a --- /dev/null +++ b/ocam/src/clients/python/src/tigerbeetle/client.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import asyncio +import ctypes +import logging +import os +import sys +import threading +import time +from collections.abc import Callable # noqa: TCH003 +from dataclasses import dataclass +from typing import Any +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + +from . import bindings +from .lib import tb_assert, c_uint128 + +logger = logging.getLogger("tigerbeetle") + + +class AtomicInteger: + def __init__(self, value: int = 0) -> None: + self._value = value + self._lock = threading.Lock() + + def increment(self) -> int: + with self._lock: + self._value += 1 + return self._value + + +@dataclass +class CompletionContextSync: + event: threading.Event + + +@dataclass +class CompletionContextAsync: + loop: asyncio.AbstractEventLoop + event: asyncio.Event + + +@dataclass +class InflightPacket: + packet: bindings.CPacket + response: Any + operation: bindings.Operation + c_event_type: Any + c_result_type: Any + on_completion: Callable[[Self], None] | None + on_completion_context: CompletionContextSync | CompletionContextAsync | None + +class _IDGenerator: + """ + Generator for Universally Unique and Sortable Identifiers as a 128-bit integers, based on ULIDs. + + Keeps a monotonically increasing millisecond timestamp between calls to `.generate()`. + """ + _last_time_ms: int + _last_random: int + + def __init__(self) -> None: + self._last_time_ms = time.time_ns() // (1000 * 1000) + self._last_random = int.from_bytes(os.urandom(10), 'little') + assert self._last_time_ms < (1 << 48) + assert self._last_random < (1 << 80) + + def generate(self) -> int: + time_ms = time.time_ns() // (1000 * 1000) + + # Ensure time_ms monotonically increases. + if time_ms <= self._last_time_ms: + time_ms = self._last_time_ms + else: + self._last_time_ms = time_ms + self._last_random = int.from_bytes(os.urandom(10), 'little') + + self._last_random += 1 + if self._last_random == 2 ** 80: + time_ms += 1 + self._last_time_ms = time_ms + self._last_random = 0 + if time_ms == 1 << 48: + raise Exception('Timestamp bits overflow on monotonic increment') + + return (time_ms << 80) | self._last_random + +# Module-level singleton instance. +_id_generator = _IDGenerator() + + +def id() -> int: + """ + Generates a Universally Unique and Sortable Identifier as a 128-bit integer. Based on ULIDs. + """ + return _id_generator.generate() + + +AMOUNT_MAX = (2 ** 128) - 1 + + +class InitError(Exception): + pass + +class ClientClosedError(Exception): + def __init__(self) -> None: + super().__init__("Client was closed.") + +class TooMuchDataError(Exception): + def __init__(self) -> None: + super().__init__("Too much data was sent or requested in this batch.") + +class ClientEvictedError(Exception): + def __init__(self) -> None: + super().__init__("Client was evicted.") + +class ClientReleaseTooLowError(Exception): + def __init__(self) -> None: + super().__init__("Client was evicted: release too old.") + +class ClientReleaseTooHighError(Exception): + def __init__(self) -> None: + super().__init__("Client was evicted: release too new.") + +class Client: + _clients: dict[int, Any] = {} + _counter = AtomicInteger() + + def __init__(self, cluster_id: int, replica_addresses: str) -> None: + self._client_key = Client._counter.increment() + self._client = bindings.CClient() + + self._inflight_packets: dict[int, InflightPacket] = {} + + # ctypes needs a reference to keep this alive through the FFI call. Having it as a temporary + # within the call _does not_ work. + cluster_id_u128 = c_uint128.from_param(cluster_id) + init_status = bindings.tb_client_init( + ctypes.byref(self._client), + ctypes.cast( + ctypes.byref(cluster_id_u128), ctypes.POINTER(ctypes.c_uint8 * 16) + ), + replica_addresses.encode("ascii"), + len(replica_addresses), + self._client_key, + self._c_on_completion + ) + if init_status != bindings.InitStatus.SUCCESS: + raise InitError(init_status) + + Client._clients[self._client_key] = self + + + def _acquire_packet(self, operation: bindings.Operation, operations: Any, + c_event_type: Any, c_result_type: Any) -> InflightPacket: + packet = bindings.CPacket() + packet.next = None + packet.user_data = Client._counter.increment() + packet.user_tag = 0 + packet.operation = operation + packet.status = bindings.PacketStatus.OK + + operations_array_type = c_event_type * len(operations) + operations_array = operations_array_type(*map(c_event_type.from_param, operations)) + + packet.data_size = ctypes.sizeof(operations_array) + packet.data = ctypes.cast(operations_array, ctypes.c_void_p) + + return InflightPacket( + packet=packet, + response=None, + on_completion=None, + on_completion_context=None, + operation=operation, + c_event_type=c_event_type, + c_result_type=c_result_type) + + @staticmethod + @bindings.OnCompletion # type: ignore[misc] + def _c_on_completion(completion_ctx: int, packet: Any, timestamp: int, bytes_ptr: Any, len_: int) -> None: + """ + Invoked in a separate thread + """ + self: Client = Client._clients[completion_ctx] + + packet = ctypes.cast(packet, ctypes.POINTER(bindings.CPacket)) + inflight_packet = self._inflight_packets[packet[0].user_data] + if inflight_packet.on_completion is None: + # Can't use tb_assert here, as mypy complains later that it might be None. + raise TypeError("inflight_packet.on_completion not set") + + if packet[0].status == bindings.PacketStatus.OK.value: + c_result_type = inflight_packet.c_result_type + tb_assert(len_ % ctypes.sizeof(c_result_type) == 0) + + # The memory referenced in bytes_ptr is only valid for the duration of this callback. Copy + # it to a fresh, Python owned buffer and do the conversion from the raw C type to the Python + # dataclass. + results_slice = ctypes.cast( + bytes_ptr, + ctypes.POINTER(c_result_type) + )[0:(len_ // ctypes.sizeof(c_result_type))] + results = [result.to_python() for result in results_slice] + + inflight_packet.response = results + + elif packet[0].status == bindings.PacketStatus.TOO_MUCH_DATA.value: + inflight_packet.response = TooMuchDataError() + + elif packet[0].status == bindings.PacketStatus.CLIENT_EVICTED.value: + inflight_packet.response = ClientEvictedError() + + elif packet[0].status == bindings.PacketStatus.CLIENT_RELEASE_TOO_LOW.value: + inflight_packet.response = ClientReleaseTooLowError() + + elif packet[0].status == bindings.PacketStatus.CLIENT_RELEASE_TOO_HIGH.value: + inflight_packet.response = ClientReleaseTooHighError() + + elif packet[0].status == bindings.PacketStatus.CLIENT_SHUTDOWN.value: + inflight_packet.response = ClientClosedError() + + else: + # INVALID_OPERATION and INVALID_DATA_SIZE are unexpected. + inflight_packet.response = Exception("Unexpected PacketStatus {status}") + + inflight_packet.on_completion(inflight_packet) + + +class ClientSync(Client, bindings.StateMachineMixin): + def _on_completion(self, inflight_packet: InflightPacket) -> None: + if not isinstance(inflight_packet.on_completion_context, CompletionContextSync): + raise TypeError(repr(inflight_packet.on_completion_context)) + inflight_packet.on_completion_context.event.set() + + def _submit(self, operation: bindings.Operation, operations: list[Any], + c_event_type: Any, c_result_type: Any) -> Any: + inflight_packet = self._acquire_packet(operation, operations, c_event_type, c_result_type) + self._inflight_packets[inflight_packet.packet.user_data] = inflight_packet + + inflight_packet.on_completion = self._on_completion + inflight_packet.on_completion_context = CompletionContextSync(event=threading.Event()) + + client_state = bindings.tb_client_submit(ctypes.byref(self._client), ctypes.byref(inflight_packet.packet)) + if client_state == bindings.ClientStatus.OK: + inflight_packet.on_completion_context.event.wait() + + del self._inflight_packets[inflight_packet.packet.user_data] + + if client_state == bindings.ClientStatus.INVALID: + raise ClientClosedError() + + if isinstance(inflight_packet.response, Exception): + raise inflight_packet.response + + return inflight_packet.response + + def close(self) -> None: + tb_assert(self._client is not None) + bindings.tb_client_deinit(ctypes.byref(self._client)) + + tb_assert(len(self._inflight_packets) == 0) + del Client._clients[self._client_key] + + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + self.close() + + +class ClientAsync(Client, bindings.AsyncStateMachineMixin): + def _on_completion(self, inflight_packet: InflightPacket) -> None: + """ + Called by Client._c_on_completion, which itself is called from a different thread. Use + `call_soon_threadsafe` to return to the thread of the event loop the request was invoked + from, so _trigger_event() can trigger the async event and allow the client to progress. + """ + if not isinstance(inflight_packet.on_completion_context, CompletionContextAsync): + raise TypeError(repr(inflight_packet.on_completion_context)) + inflight_packet.on_completion_context.loop.call_soon_threadsafe( + self._trigger_event, + inflight_packet + ) + + def _trigger_event(self, inflight_packet:InflightPacket) -> None: + if not isinstance(inflight_packet.on_completion_context, CompletionContextAsync): + raise TypeError(repr(inflight_packet.on_completion_context)) + inflight_packet.on_completion_context.event.set() + + async def _submit(self, operation: bindings.Operation, operations: Any, + c_event_type: Any, c_result_type: Any) -> Any: + inflight_packet = self._acquire_packet(operation, operations, c_event_type, c_result_type) + self._inflight_packets[inflight_packet.packet.user_data] = inflight_packet + + inflight_packet.on_completion = self._on_completion + inflight_packet.on_completion_context = CompletionContextAsync( + loop=asyncio.get_event_loop(), + event=asyncio.Event() + ) + + client_state = bindings.tb_client_submit(ctypes.byref(self._client), ctypes.byref(inflight_packet.packet)) + if client_state == bindings.ClientStatus.OK: + await inflight_packet.on_completion_context.event.wait() + + del self._inflight_packets[inflight_packet.packet.user_data] + + if client_state == bindings.ClientStatus.INVALID: + raise ClientClosedError() + + if isinstance(inflight_packet.response, Exception): + raise inflight_packet.response + + return inflight_packet.response + + async def close(self) -> None: + tb_assert(self._client is not None) + bindings.tb_client_deinit(ctypes.byref(self._client)) + + # tb_client_deinit internally clears any inflight requests, and calls their callbacks, so + # the client needs to stick around until that's done. + # + # This isn't a problem for ClientSync, but ClientAsync invokes the callbacks using + # call_soon_threadsafe(), so wait for the event to fire on all pending packets. + all_events = [] + for packet in self._inflight_packets.values(): + if not isinstance(packet.on_completion_context, CompletionContextAsync): + raise AssertionError() + all_events.append(packet.on_completion_context.event.wait()) + + await asyncio.gather(*all_events) + + tb_assert(len(self._inflight_packets) == 0) + del Client._clients[self._client_key] + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + await self.close() + + +@bindings.LogHandler # type: ignore[misc] +def log_handler(level_zig: bindings.LogLevel, message_ptr: Any, message_len: int) -> None: + level_python = { + bindings.LogLevel.ERR: logging.ERROR, + bindings.LogLevel.WARN: logging.WARNING, + bindings.LogLevel.INFO: logging.INFO, + bindings.LogLevel.DEBUG: logging.DEBUG, + }[level_zig] + logger.log(level_python, ctypes.string_at(message_ptr, message_len).decode("utf-8")) + +tb_assert(bindings.tb_client_register_log_callback(log_handler, True) == + bindings.RegisterLogCallbackStatus.SUCCESS) + + +def configure_logging( + *, + debug: bool, + handler: Callable[[bindings.LogLevel, Any, int], None] = log_handler, +) -> None: + # First disable the existing log handler, before enabling the new one. + tb_assert(bindings.tb_client_register_log_callback(None, debug) == + bindings.RegisterLogCallbackStatus.SUCCESS) + + tb_assert(bindings.tb_client_register_log_callback(handler, debug) == + bindings.RegisterLogCallbackStatus.SUCCESS) diff --git a/ocam/src/clients/python/src/tigerbeetle/lib.py b/ocam/src/clients/python/src/tigerbeetle/lib.py new file mode 100644 index 00000000..92334921 --- /dev/null +++ b/ocam/src/clients/python/src/tigerbeetle/lib.py @@ -0,0 +1,90 @@ +import ctypes +import platform +import sys +from pathlib import Path +from typing import Any +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + + +class NativeError(Exception): + pass + + +class IntegerOverflowError(ValueError): + pass + + +def _load_tbclient() -> ctypes.CDLL: + prefix = "" + arch = "" + system = "" + linux_libc = "" + suffix = "" + + platform_machine = platform.machine().lower() + + if platform_machine == "x86_64" or platform_machine == "amd64": + arch = "x86_64" + elif platform_machine == "aarch64" or platform_machine == "arm64": + arch = "aarch64" + else: + raise NativeError("Unsupported machine: " + platform.machine()) + + if platform.system() == "Linux": + prefix = "lib" + system = "linux" + suffix = ".so" + libc = platform.libc_ver()[0] + if libc == "glibc": + linux_libc = "-gnu.2.27" + elif libc == "musl": + linux_libc = "-musl" + else: + raise NativeError("Unsupported libc: " + libc) + elif platform.system() == "Darwin": + prefix = "lib" + system = "macos" + suffix = ".dylib" + elif platform.system() == "Windows": + system = "windows" + suffix = ".dll" + else: + raise NativeError("Unsupported system: " + platform.system()) + + source_path = Path(__file__) + source_dir = source_path.parent + library_path = ( + source_dir / "lib" / f"{arch}-{system}{linux_libc}" / f"{prefix}tb_client{suffix}" + ) + return ctypes.CDLL(str(library_path)) + + +def validate_uint(*, bits: int, name: str, number: int) -> None: + if number > 2**bits - 1: + raise IntegerOverflowError(f"{name}=={number} is too large to fit in {bits} bits") + if number < 0: + raise IntegerOverflowError(f"{name}=={number} cannot be negative") + + +class c_uint128(ctypes.Structure): # noqa: N801 + _fields_ = [("_low", ctypes.c_uint64), ("_high", ctypes.c_uint64)] # noqa: RUF012 + + @classmethod + def from_param(cls, obj: int) -> Self: + return cls(_high=obj >> 64, _low=obj & 0xFFFFFFFFFFFFFFFF) + + def to_python(self) -> int: + return int(self._high << 64 | self._low) + +def tb_assert(value: Any) -> None: + """ + Python's built-in assert can be silently disabled if Python is run with -O. + """ + if not value: + raise AssertionError() + + +tbclient = _load_tbclient() diff --git a/ocam/src/clients/python/src/tigerbeetle/py.typed b/ocam/src/clients/python/src/tigerbeetle/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/ocam/src/clients/python/tests/test_basic.py b/ocam/src/clients/python/tests/test_basic.py new file mode 100644 index 00000000..afdf7733 --- /dev/null +++ b/ocam/src/clients/python/tests/test_basic.py @@ -0,0 +1,1513 @@ +import os +import sys +import time +from dataclasses import asdict + +import pytest + +import tigerbeetle as tb +tb.configure_logging(debug=True) + +replica_addresses = os.getenv("TB_ADDRESS") +if not replica_addresses: + print('error: missing TB_ADDRESS environment variable') + sys.exit(1) + +@pytest.fixture +def client(): + client = tb.ClientSync(cluster_id=0, replica_addresses=replica_addresses) + yield client + client.close() + +BATCH_MAX = 8189 + +# Test data +account_a = tb.Account( + id=17, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=718, + flags=0, + timestamp=0 +) +account_b = tb.Account( + id=19, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=719, + flags=0, + timestamp=0 +) + +def test_range_check_u128_cannot_exceed(client): + account = tb.Account(**{ **asdict(account_a), "id": 9999999999999999999999999999999999999999 }) + + try: + error = client.create_accounts([account]) + except tb.IntegerOverflowError: + pass + +def test_range_check_u128_cannot_be_negative(client): + account = tb.Account(**{ **asdict(account_a), "id": -1 }) + + try: + error = client.create_accounts([account]) + except tb.IntegerOverflowError: + pass + +def test_range_check_code_on_account_to_be_u16(client): + account = tb.Account(**{ **asdict(account_a), "id": 0, "code": 65535 + 1 }) + + try: + code_error = client.create_accounts([account]) + except tb.IntegerOverflowError: + pass + + accounts = client.lookup_accounts([account.id]) + assert accounts == [] + +def test_create_accounts(client): + results = client.create_accounts([account_a]) + assert len(results) == 1 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateAccountStatus.CREATED + +def test_return_error_on_account(client): + results = client.create_accounts([account_a, account_b]) + assert len(results) == 2 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateAccountStatus.EXISTS + assert results[1].timestamp > 0 + assert results[1].status == tb.CreateAccountStatus.CREATED + +def test_error_if_timestamp_is_not_set_to_0_on_account(client): + account = { **asdict(account_a), "timestamp": 2, "id": 3 } + results = client.create_accounts([tb.Account(**account)]) + assert len(results) == 1 + assert results[0].timestamp > 0 + assert results[0].timestamp != 2 + assert results[0].status == tb.CreateAccountStatus.TIMESTAMP_MUST_BE_ZERO + +def test_lookup_accounts(client): + accounts = client.lookup_accounts([account_a.id, account_b.id]) + + assert len(accounts) == 2 + account1 = accounts[0] + assert account1.id == 17 + assert account1.credits_posted == 0 + assert account1.credits_pending == 0 + assert account1.debits_posted == 0 + assert account1.debits_pending == 0 + assert account1.user_data_128 == 0 + assert account1.user_data_64 == 0 + assert account1.user_data_32 == 0 + assert account1.code == 718 + assert account1.ledger == 1 + assert account1.flags == 0 + assert account1.timestamp > 0 + + account2 = accounts[1] + assert account2.id == 19 + assert account2.credits_posted == 0 + assert account2.credits_pending == 0 + assert account2.debits_posted == 0 + assert account2.debits_pending == 0 + assert account2.user_data_128 == 0 + assert account2.user_data_64 == 0 + assert account2.user_data_32 == 0 + assert account2.code == 719 + assert account2.ledger == 1 + assert account2.flags == 0 + assert account2.timestamp > 0 + +def test_create_a_transfer(client): + transfer = tb.Transfer( + id=1, + debit_account_id=account_b.id, + credit_account_id=account_a.id, + amount=100, + user_data_128=0, + user_data_64=0, + user_data_32=0, + pending_id=0, + timeout=0, + ledger=1, + code=1, + flags=0, + timestamp=0, # this will be set correctly by the TigerBeetle server + ) + + results = client.create_transfers([transfer]) + assert len(results) == 1 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateTransferStatus.CREATED + + accounts = client.lookup_accounts([account_a.id, account_b.id]) + assert len(accounts) == 2 + assert accounts[0].credits_posted == 100 + assert accounts[0].credits_pending == 0 + assert accounts[0].debits_posted == 0 + assert accounts[0].debits_pending == 0 + + assert accounts[1].credits_posted == 0 + assert accounts[1].credits_pending == 0 + assert accounts[1].debits_posted == 100 + assert accounts[1].debits_pending == 0 + +def test_create_a_two_phase_transfer(client): + transfer = tb.Transfer( + id=2, + debit_account_id=account_b.id, + credit_account_id=account_a.id, + amount=50, + user_data_128=0, + user_data_64=0, + user_data_32=0, + pending_id=0, + timeout=int(2e9), + ledger=1, + code=1, + flags=tb.TransferFlags.PENDING, + timestamp=0, # this will be set correctly by the TigerBeetle server + ) + + results = client.create_transfers([transfer]) + assert len(results) == 1 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateTransferStatus.CREATED + + accounts = client.lookup_accounts([account_a.id, account_b.id]) + assert len(accounts) == 2 + assert accounts[0].credits_posted == 100 + assert accounts[0].credits_pending == 50 + assert accounts[0].debits_posted == 0 + assert accounts[0].debits_pending == 0 + + assert accounts[1].credits_posted == 0 + assert accounts[1].credits_pending == 0 + assert accounts[1].debits_posted == 100 + assert accounts[1].debits_pending == 50 + + # Lookup the transfer: + transfers = client.lookup_transfers([transfer.id]) + assert len(transfers) == 1 + assert transfers[0].id == 2 + assert transfers[0].debit_account_id == account_b.id + assert transfers[0].credit_account_id == account_a.id + assert transfers[0].amount == 50 + assert transfers[0].user_data_128 == 0 + assert transfers[0].user_data_64 == 0 + assert transfers[0].user_data_32 == 0 + assert transfers[0].timeout > 0 + assert transfers[0].code == 1 + assert transfers[0].flags == 2 + assert transfers[0].timestamp == results[0].timestamp + +def test_post_a_two_phase_transfer(client): + commit = tb.Transfer( + id=3, + debit_account_id=0, + credit_account_id=0, + amount=tb.AMOUNT_MAX, + user_data_128=0, + user_data_64=0, + user_data_32=0, + pending_id=2,# must match the id of the pending transfer + timeout=0, + ledger=1, + code=1, + flags=tb.TransferFlags.POST_PENDING_TRANSFER, + timestamp=0, # this will be set correctly by the TigerBeetle server + ) + + results = client.create_transfers([commit]) + assert len(results) == 1 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateTransferStatus.CREATED + + accounts = client.lookup_accounts([account_a.id, account_b.id]) + assert len(accounts) == 2 + assert accounts[0].credits_posted == 150 + assert accounts[0].credits_pending == 0 + assert accounts[0].debits_posted == 0 + assert accounts[0].debits_pending == 0 + + assert accounts[1].credits_posted == 0 + assert accounts[1].credits_pending == 0 + assert accounts[1].debits_posted == 150 + assert accounts[1].debits_pending == 0 + +def test_reject_a_two_phase_transfer(client): + # Create a two-phase transfer: + transfer = tb.Transfer( + id=4, + debit_account_id=account_b.id, + credit_account_id=account_a.id, + amount=50, + user_data_128=0, + user_data_64=0, + user_data_32=0, + pending_id=0, + timeout=int(1e9), + ledger=1, + code=1, + flags=tb.TransferFlags.PENDING, + timestamp=0, # this will be set correctly by the TigerBeetle server + ) + results = client.create_transfers([transfer]) + assert len(results) == 1 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateTransferStatus.CREATED + + # send in the reject + reject = tb.Transfer( + id=5, + debit_account_id=0, + credit_account_id=0, + amount=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + pending_id=4, # must match the id of the pending transfer + timeout=0, + ledger=1, + code=1, + flags=tb.TransferFlags.VOID_PENDING_TRANSFER, + timestamp=0, # this will be set correctly by the TigerBeetle server + ) + + results = client.create_transfers([reject]) + assert len(results) == 1 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateTransferStatus.CREATED + + accounts = client.lookup_accounts([account_a.id, account_b.id]) + assert len(accounts) == 2 + assert accounts[0].credits_posted == 150 + assert accounts[0].credits_pending == 0 + assert accounts[0].debits_posted == 0 + assert accounts[0].debits_pending == 0 + + assert accounts[1].credits_posted == 0 + assert accounts[1].credits_pending == 0 + assert accounts[1].debits_posted == 150 + assert accounts[1].debits_pending == 0 + +def test_link_transfers(client): + transfer1 = tb.Transfer( + id=6, + debit_account_id=account_b.id, + credit_account_id=account_a.id, + amount=100, + user_data_128=0, + user_data_64=0, + user_data_32=0, + pending_id=0, + timeout=0, + ledger=1, + code=1, + flags=tb.TransferFlags.LINKED, # points to transfer2 + timestamp=0, # will be set correctly by the TigerBeetle server + ) + transfer2 = tb.Transfer( + id=6, + debit_account_id=account_b.id, + credit_account_id=account_a.id, + amount=100, + user_data_128=0, + user_data_64=0, + user_data_32=0, + pending_id=0, + timeout=0, + ledger=1, + code=1, + # Does not have linked flag as it is the end of the chain. + # This will also cause it to fail as this is now a duplicate with different flags + flags=0, + timestamp=0, # will be set correctly by the TigerBeetle server + ) + + results = client.create_transfers([transfer1, transfer2]) + assert len(results) == 2 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateTransferStatus.LINKED_EVENT_FAILED + assert results[1].timestamp > 0 + assert results[1].status == tb.CreateTransferStatus.EXISTS_WITH_DIFFERENT_FLAGS + + accounts = client.lookup_accounts([account_a.id, account_b.id]) + assert len(accounts) == 2 + assert accounts[0].credits_posted == 150 + assert accounts[0].credits_pending == 0 + assert accounts[0].debits_posted == 0 + assert accounts[0].debits_pending == 0 + + assert accounts[1].credits_posted == 0 + assert accounts[1].credits_pending == 0 + assert accounts[1].debits_posted == 150 + assert accounts[1].debits_pending == 0 + +def test_cannot_void_an_expired_transfer(client): + # Create a two-phase transfer: + transfer = tb.Transfer( + id=6, + debit_account_id=account_b.id, + credit_account_id=account_a.id, + amount=50, + user_data_128=0, + user_data_64=0, + user_data_32=0, + pending_id=0, + timeout=1, + ledger=1, + code=1, + flags=tb.TransferFlags.PENDING, + timestamp=0, # this will be set correctly by the TigerBeetle server + ) + results = client.create_transfers([transfer]) + assert len(results) == 1 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateTransferStatus.CREATED + + accounts = client.lookup_accounts([account_a.id, account_b.id]) + assert len(accounts) == 2 + assert accounts[0].credits_posted == 150 + assert accounts[0].credits_pending == 50 + assert accounts[0].debits_posted == 0 + assert accounts[0].debits_pending == 0 + + assert accounts[1].credits_posted == 0 + assert accounts[1].credits_pending == 0 + assert accounts[1].debits_posted == 150 + assert accounts[1].debits_pending == 50 + + # We need to wait 1s for the server to expire the transfer, however the + # server can pulse the expiry operation anytime after the timeout, + # so adding an extra delay to avoid flaky tests. + extra_wait_time = 0.50 + time.sleep(transfer.timeout + extra_wait_time) + + # Looking up the accounts again for the updated balance. + accounts = client.lookup_accounts([account_a.id, account_b.id]) + assert len(accounts) == 2 + assert accounts[0].credits_posted == 150 + assert accounts[0].credits_pending == 0 + assert accounts[0].debits_posted == 0 + assert accounts[0].debits_pending == 0 + + assert accounts[1].credits_posted == 0 + assert accounts[1].credits_pending == 0 + assert accounts[1].debits_posted == 150 + assert accounts[1].debits_pending == 0 + + # send in the reject + reject = tb.Transfer( + id=7, + debit_account_id=0, + credit_account_id=0, + amount=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + pending_id=6, # must match the id of the pending transfer + timeout=0, + ledger=1, + code=1, + flags=tb.TransferFlags.VOID_PENDING_TRANSFER, + timestamp=0, # this will be set correctly by the TigerBeetle server + ) + + results = client.create_transfers([reject]) + assert len(results) == 1 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateTransferStatus.PENDING_TRANSFER_EXPIRED + +def test_close_accounts(client): + closing_transfer = tb.Transfer( + id=tb.id(), + debit_account_id=account_b.id, + credit_account_id=account_a.id, + amount=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + pending_id=0, + timeout=0, + ledger=1, + code=1, + flags=tb.TransferFlags.CLOSING_DEBIT | tb.TransferFlags.CLOSING_CREDIT | tb.TransferFlags.PENDING, + timestamp=0, # will be set correctly by the TigerBeetle server + ) + results = client.create_transfers([closing_transfer]) + assert len(results) == 1 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateTransferStatus.CREATED + + accounts = client.lookup_accounts([account_a.id, account_b.id]) + assert len(accounts) == 2 + assert account_a.flags != accounts[0].flags + assert (accounts[0].flags & tb.AccountFlags.CLOSED) != tb.AccountFlags.NONE + + assert account_b.flags != accounts[1].flags + assert (accounts[1].flags & tb.AccountFlags.CLOSED) != tb.AccountFlags.NONE + + voiding_transfer = tb.Transfer( + id=tb.id(), + debit_account_id=account_b.id, + credit_account_id=account_a.id, + amount=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + timeout=0, + ledger=1, + code=1, + flags=tb.TransferFlags.VOID_PENDING_TRANSFER, + pending_id=closing_transfer.id, + timestamp=0, # will be set correctly by the TigerBeetle server + ) + + results = client.create_transfers([voiding_transfer]) + assert len(results) == 1 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateTransferStatus.CREATED + + accounts = client.lookup_accounts([account_a.id, account_b.id]) + assert len(accounts) == 2 + assert account_a.flags == accounts[0].flags + assert (accounts[0].flags & tb.AccountFlags.CLOSED) == tb.AccountFlags.NONE + + assert account_b.flags == accounts[1].flags + assert (accounts[1].flags & tb.AccountFlags.CLOSED) == tb.AccountFlags.NONE + +def test_get_account_transfers(client): + accountC = tb.Account( + id=21, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=718, + flags=tb.AccountFlags.HISTORY, + timestamp=0 + ) + account_results = client.create_accounts([accountC]) + assert len(account_results) == 1 + assert account_results[0].timestamp > 0 + assert account_results[0].status == tb.CreateAccountStatus.CREATED + + transfers_created = [] + # Create transfers where the new account is either the debit or credit account: + for i in range(10): + transfers_created.append(tb.Transfer( + id=i + 10000, + debit_account_id=accountC.id if i % 2 == 0 else account_a.id, + credit_account_id=account_b.id if i % 2 == 0 else accountC.id, + amount=100, + user_data_128=0, + user_data_64=0, + user_data_32=0, + pending_id=0, + timeout=0, + ledger=1, + code=1, + flags=0, + timestamp=0, + )) + + transfers_results = client.create_transfers(transfers_created) + assert len(transfers_results) == len(transfers_created) + for result in transfers_results: + assert result.timestamp > 0 + assert result.status == tb.CreateAccountStatus.CREATED + + # Query all transfers for accountC: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS, + ) + transfers = client.get_account_transfers(filter) + account_balances = client.get_account_balances(filter) + assert len(transfers) == len(transfers_created) + assert len(account_balances) == len(transfers) + + timestamp = 0 + for i, transfer in enumerate(transfers): + assert transfer.timestamp == transfers_results[i].timestamp + assert timestamp < transfer.timestamp + timestamp = transfer.timestamp + + assert account_balances[i].timestamp == transfer.timestamp + + # Query only the debit transfers for accountC, descending: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.AccountFilterFlags.DEBITS | tb.AccountFilterFlags.REVERSED, + ) + transfers = client.get_account_transfers(filter) + account_balances = client.get_account_balances(filter) + + assert len(transfers) == len(transfers_created) // 2 + assert len(account_balances) == len(transfers) + + timestamp = 1 << 64 + for i, transfer in enumerate(transfers): + assert transfer.timestamp < timestamp + timestamp = transfer.timestamp + + assert account_balances[i].timestamp == transfer.timestamp + + # Query only the credit transfers for accountC, descending: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.REVERSED, + ) + transfers = client.get_account_transfers(filter) + account_balances = client.get_account_balances(filter) + + assert len(transfers) == len(transfers_created) // 2 + assert len(account_balances) == len(transfers) + + timestamp = 1 << 64 + for i, transfer in enumerate(transfers): + assert transfer.timestamp < timestamp + timestamp = transfer.timestamp + + assert account_balances[i].timestamp == transfer.timestamp + + # Query the first 5 transfers for accountC: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=len(transfers_created) // 2, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS, + ) + transfers = client.get_account_transfers(filter) + account_balances = client.get_account_balances(filter) + + assert len(transfers) == len(transfers_created) // 2 + assert len(account_balances) == len(transfers) + + timestamp = 0 + for i, transfer in enumerate(transfers): + assert timestamp < transfer.timestamp + timestamp = transfer.timestamp + + assert account_balances[i].timestamp == transfer.timestamp + + # Query the next 5 transfers for accountC, with pagination: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=timestamp + 1, + timestamp_max=0, + limit=len(transfers_created) // 2, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS, + ) + transfers = client.get_account_transfers(filter) + account_balances = client.get_account_balances(filter) + + assert len(transfers) == len(transfers_created) // 2 + assert len(account_balances) == len(transfers) + + for i, transfer in enumerate(transfers): + assert timestamp < transfer.timestamp + timestamp = transfer.timestamp + + assert account_balances[i].timestamp == transfer.timestamp + + # Query again, no more transfers should be found: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=timestamp + 1, + timestamp_max=0, + limit=len(transfers_created) // 2, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS, + ) + transfers = client.get_account_transfers(filter) + account_balances = client.get_account_balances(filter) + + assert transfers == [] + assert len(account_balances) == len(transfers) + + # Query the first 5 transfers for accountC ORDER BY DESC: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=len(transfers_created) // 2, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS | tb.AccountFilterFlags.REVERSED, + ) + transfers = client.get_account_transfers(filter) + account_balances = client.get_account_balances(filter) + + assert len(transfers) == len(transfers_created) // 2 + assert len(account_balances) == len(transfers) + + timestamp = 1 << 64 + for i, transfer in enumerate(transfers): + assert timestamp > transfer.timestamp + timestamp = transfer.timestamp + + assert account_balances[i].timestamp == transfer.timestamp + + # Query the next 5 transfers for accountC, with pagination: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=timestamp - 1, + limit=len(transfers_created) // 2, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS | tb.AccountFilterFlags.REVERSED, + ) + transfers = client.get_account_transfers(filter) + account_balances = client.get_account_balances(filter) + + assert len(transfers) == len(transfers_created) // 2 + assert len(account_balances) == len(transfers) + + for i, transfer in enumerate(transfers): + assert timestamp > transfer.timestamp + timestamp = transfer.timestamp + + assert account_balances[i].timestamp == transfer.timestamp + + # Query again, no more transfers should be found: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=timestamp - 1, + limit=len(transfers_created) // 2, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS | tb.AccountFilterFlags.REVERSED, + ) + transfers = client.get_account_transfers(filter) + account_balances = client.get_account_balances(filter) + + assert transfers == [] + assert len(account_balances) == len(transfers) + + # Invalid account: + filter = tb.AccountFilter( + account_id=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS, + ) + assert client.get_account_transfers(filter) == [] + assert client.get_account_balances(filter) == [] + + # Invalid timestamp min: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=(1 << 64) - 1, # ulong max value + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS, + ) + assert client.get_account_transfers(filter) == [] + assert client.get_account_balances(filter) == [] + + # Invalid timestamp max: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=(1 << 64) - 1, # ulong max value + limit=BATCH_MAX, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS, + ) + assert client.get_account_transfers(filter) == [] + assert client.get_account_balances(filter) == [] + + # Invalid timestamp range: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=(1 << 64) - 2, # ulong max - 1 + timestamp_max=1, + limit=BATCH_MAX, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS, + ) + assert client.get_account_transfers(filter) == [] + assert client.get_account_balances(filter) == [] + + # Zero limit: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=0, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS, + ) + assert client.get_account_transfers(filter) == [] + assert client.get_account_balances(filter) == [] + + # TooMuchData + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=10_000, + flags=tb.AccountFilterFlags.CREDITS | tb.AccountFilterFlags.DEBITS, + ) + try: + client.get_account_transfers(filter) + except Exception as err: + assert isinstance(err, tb.TooMuchDataError) + else: + assert False + try: + client.get_account_balances(filter) + except Exception as err: + assert isinstance(err, tb.TooMuchDataError) + else: + assert False + + + # Empty flags: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.AccountFilterFlags.NONE, + ) + assert client.get_account_transfers(filter) == [] + assert client.get_account_balances(filter) == [] + + # Invalid flags: + filter = tb.AccountFilter( + account_id=accountC.id, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=0xFFFF, + ) + assert client.get_account_transfers(filter) == [] + assert client.get_account_balances(filter) == [] + + +def test_query_accounts(client): + accounts = [] + # Create transfers: + for i in range(10): + accounts.append(tb.Account( + id=tb.id(), + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=1000 if i % 2 == 0 else 2000, + user_data_64=100 if i % 2 == 0 else 200, + user_data_32=10 if i % 2 == 0 else 20, + ledger=1, + code=999, + flags=tb.AccountFlags.NONE, + timestamp=0, + )) + + account_results = client.create_accounts(accounts) + assert len(accounts) == len(account_results) + for result in account_results: + assert result.timestamp > 0 + assert result.status == tb.CreateAccountStatus.CREATED + + + # Querying accounts where: + # `user_data_128=1000 AND user_data_64=100 AND user_data_32=10 + # AND code=999 AND ledger=1 ORDER BY timestamp ASC`. + filter = tb.QueryFilter( + user_data_128=1000, + user_data_64=100, + user_data_32=10, + ledger=1, + code=999, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.QueryFilterFlags.NONE, + ) + query = client.query_accounts(filter) + assert len(query) == 5 + + timestamp = 0 + for account in query: + assert timestamp < account.timestamp + timestamp = account.timestamp + + assert account.user_data_128 == filter.user_data_128 + assert account.user_data_64 == filter.user_data_64 + assert account.user_data_32 == filter.user_data_32 + assert account.ledger == filter.ledger + assert account.code == filter.code + + # Querying accounts where: + # `user_data_128=2000 AND user_data_64=200 AND user_data_32=20 + # AND code=999 AND ledger=1 ORDER BY timestamp DESC`. + filter = tb.QueryFilter( + user_data_128=2000, + user_data_64=200, + user_data_32=20, + ledger=1, + code=999, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.QueryFilterFlags.REVERSED, + ) + query = client.query_accounts(filter) + assert len(query) == 5 + + timestamp = 1 << 64 + for account in query: + assert timestamp > account.timestamp + timestamp = account.timestamp + + assert account.user_data_128 == filter.user_data_128 + assert account.user_data_64 == filter.user_data_64 + assert account.user_data_32 == filter.user_data_32 + assert account.ledger == filter.ledger + assert account.code == filter.code + + # Querying accounts where: + # `code=999 ORDER BY timestamp ASC` + filter = tb.QueryFilter( + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=0, + code=999, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.QueryFilterFlags.NONE, + ) + query = client.query_accounts(filter) + assert len(query) == 10 + + timestamp = 0 + for account in query: + assert timestamp < account.timestamp + timestamp = account.timestamp + + assert account.code == filter.code + + # Querying accounts where: + # `code=999 ORDER BY timestamp DESC LIMIT 5`. + filter = tb.QueryFilter( + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=0, + code=999, + timestamp_min=0, + timestamp_max=0, + limit=5, + flags=tb.QueryFilterFlags.REVERSED, + ) + + # First 5 items: + query = client.query_accounts(filter) + assert len(query) == 5 + + timestamp = 1 << 64 + for account in query: + assert timestamp > account.timestamp + timestamp = account.timestamp + + assert account.code == filter.code + + # Next 5 items: + filter.timestamp_max = timestamp - 1 + query = client.query_accounts(filter) + assert len(query) == 5 + + for account in query: + assert timestamp > account.timestamp + timestamp = account.timestamp + + assert account.code == filter.code + + # No more results: + filter.timestamp_max = timestamp - 1 + query = client.query_accounts(filter) + assert len(query) == 0 + + # Not found: + filter = tb.QueryFilter( + user_data_128=0, + user_data_64=200, + user_data_32=10, + ledger=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.QueryFilterFlags.NONE, + ) + query = client.query_accounts(filter) + assert len(query) == 0 + +def test_query_transfers(client): + account = tb.Account( + id=tb.id(), + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=718, + flags=tb.AccountFlags.NONE, + timestamp=0 + ) + account_results = client.create_accounts([account]) + assert len(account_results) == 1 + account_results[0].timestamp > 0 + account_results[0].status == tb.CreateAccountStatus.CREATED + + transfers_created = [] + # Create transfers: + for i in range (10): + transfers_created.append(tb.Transfer( + id=tb.id(), + debit_account_id=account.id if i % 2 == 0 else account_a.id, + credit_account_id=account_b.id if i % 2 == 0 else account.id, + amount=100, + user_data_128=1000 if i % 2 == 0 else 2000, + user_data_64=100 if i % 2 == 0 else 200, + user_data_32=10 if i % 2 == 0 else 20, + pending_id=0, + timeout=0, + ledger=1, + code=999, + flags=0, + timestamp=0, + )) + + transfers_results = client.create_transfers(transfers_created) + assert len(transfers_results) == len(transfers_created) + for result in transfers_results: + assert result.timestamp > 0 + assert result.status == tb.CreateTransferStatus.CREATED + + # Querying transfers where: + # `user_data_128=1000 AND user_data_64=100 AND user_data_32=10 + # AND code=999 AND ledger=1 ORDER BY timestamp ASC`. + filter = tb.QueryFilter( + user_data_128=1000, + user_data_64=100, + user_data_32=10, + ledger=1, + code=999, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.QueryFilterFlags.NONE, + ) + query = client.query_transfers(filter) + assert len(query) == 5 + + timestamp = 0 + for transfer in query: + assert timestamp < transfer.timestamp + timestamp = transfer.timestamp + + assert transfer.user_data_128 == filter.user_data_128 + assert transfer.user_data_64 == filter.user_data_64 + assert transfer.user_data_32 == filter.user_data_32 + assert transfer.ledger == filter.ledger + assert transfer.code == filter.code + + # Querying transfers where: + # `user_data_128=2000 AND user_data_64=200 AND user_data_32=20 + # AND code=999 AND ledger=1 ORDER BY timestamp DESC`. + filter = tb.QueryFilter( + user_data_128=2000, + user_data_64=200, + user_data_32=20, + ledger=1, + code=999, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.QueryFilterFlags.REVERSED, + ) + query = client.query_transfers(filter) + assert len(query) == 5 + + timestamp = 1 << 64 + for transfer in query: + assert timestamp > transfer.timestamp + timestamp = transfer.timestamp + + assert transfer.user_data_128 == filter.user_data_128 + assert transfer.user_data_64 == filter.user_data_64 + assert transfer.user_data_32 == filter.user_data_32 + assert transfer.ledger == filter.ledger + assert transfer.code == filter.code + + # Querying transfers where: + # `code=999 ORDER BY timestamp ASC` + filter = tb.QueryFilter( + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=0, + code=999, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.QueryFilterFlags.NONE, + ) + query = client.query_transfers(filter) + assert len(query) == 10 + + timestamp = 0 + for transfer in query: + assert timestamp < transfer.timestamp + timestamp = transfer.timestamp + + assert transfer.code == filter.code + + # Querying transfers where: + # `code=999 ORDER BY timestamp DESC LIMIT 5`. + filter = tb.QueryFilter( + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=0, + code=999, + timestamp_min=0, + timestamp_max=0, + limit=5, + flags=tb.QueryFilterFlags.REVERSED, + ) + + # First 5 items: + query = client.query_transfers(filter) + assert len(query) == 5 + + timestamp = 1 << 64 + for transfer in query: + assert timestamp > transfer.timestamp + timestamp = transfer.timestamp + + assert transfer.code == filter.code + + # Next 5 items: + filter.timestamp_max = timestamp - 1 + query = client.query_transfers(filter) + assert len(query) == 5 + + for transfer in query: + assert timestamp > transfer.timestamp + timestamp = transfer.timestamp + + assert transfer.code == filter.code + + # No more results: + filter.timestamp_max = timestamp - 1 + query = client.query_transfers(filter) + assert len(query) == 0 + + # Not found: + filter = tb.QueryFilter( + user_data_128=0, + user_data_64=200, + user_data_32=10, + ledger=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.QueryFilterFlags.NONE, + ) + query = client.query_transfers(filter) + assert len(query) == 0 + +def test_query_with_invalid_filter(client): + # Invalid timestamp min: + filter = tb.QueryFilter( + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=0, + code=0, + timestamp_min=(1 << 64) - 1, # ulong max value + timestamp_max=0, + limit=BATCH_MAX, + flags=tb.QueryFilterFlags.NONE, + ) + assert client.query_accounts(filter) == [] + assert client.query_transfers(filter) == [] + + # Invalid timestamp max: + filter = tb.QueryFilter( + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=0, + code=0, + timestamp_min=0, + timestamp_max=(1 << 64) - 1, # ulong max value, + limit=BATCH_MAX, + flags=tb.QueryFilterFlags.NONE, + ) + assert client.query_accounts(filter) == [] + assert client.query_transfers(filter) == [] + + # Invalid timestamp range: + filter = tb.QueryFilter( + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=0, + code=0, + timestamp_min=(1 << 64) - 2, # ulong max - 1 + timestamp_max=1, + limit=BATCH_MAX, + flags=tb.QueryFilterFlags.NONE, + ) + assert client.query_accounts(filter) == [] + assert client.query_transfers(filter) == [] + + # Zero limit: + filter = tb.QueryFilter( + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=0, + flags=tb.QueryFilterFlags.NONE, + ) + assert client.query_accounts(filter) == [] + assert client.query_transfers(filter) == [] + + # TooMuchData + filter = tb.QueryFilter( + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=10_000, + flags=tb.QueryFilterFlags.NONE, + ) + try: + client.query_accounts(filter) + except Exception as err: + assert isinstance(err, tb.TooMuchDataError) + else: + assert False + try: + client.query_transfers(filter) + except Exception as err: + assert isinstance(err, tb.TooMuchDataError) + else: + assert False + + # Invalid flags: + filter = tb.QueryFilter( + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=0, + flags=0xFFFF, + ) + assert client.query_accounts(filter) == [] + assert client.query_transfers(filter) == [] + +def test_import_accounts_and_transfers(client): + account_tmp = tb.Account( + id=tb.id(), + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=718, + flags=0, + timestamp=0 + ) + account_results = client.create_accounts([account_tmp]) + assert len(account_results) == 1 + account_results[0].timestamp > 0 + account_results[0].status == tb.CreateAccountStatus.CREATED + + timestamp_max = account_results[0].timestamp + + # Wait 10 ms so we can use the account's timestamp as the reference for past time + # after the last object inserted. + time.sleep(0.01) + + account_a = tb.Account( + id=tb.id(), + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=718, + flags=tb.AccountFlags.IMPORTED, + timestamp=timestamp_max + 1 # user-defined timestamp + ) + account_b = tb.Account( + id=tb.id(), + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=718, + flags=tb.AccountFlags.IMPORTED, + timestamp=timestamp_max + 2 # user-defined timestamp + ) + account_results = client.create_accounts([account_a, account_b]) + assert len(account_results) == 2 + account_results[0].timestamp == account_a.timestamp + account_results[0].status == tb.CreateAccountStatus.CREATED + account_results[1].timestamp == account_b.timestamp + account_results[1].status == tb.CreateAccountStatus.CREATED + + account_lookup = client.lookup_accounts([account_a.id, account_b.id]) + assert len(account_lookup) == 2 + assert account_lookup[0].timestamp == account_a.timestamp + assert account_lookup[1].timestamp == account_b.timestamp + + transfer = tb.Transfer( + id=tb.id(), + debit_account_id=account_a.id, + credit_account_id=account_b.id, + amount=100, + user_data_128=0, + user_data_64=0, + user_data_32=0, + pending_id=0, + timeout=0, + ledger=1, + code=1, + flags=tb.TransferFlags.IMPORTED, + timestamp=timestamp_max + 3, # user-defined timestamp. + ) + + transfers_results = client.create_transfers([transfer]) + assert len(transfers_results) == 1 + assert transfers_results[0].timestamp == transfer.timestamp + assert transfers_results[0].status == tb.CreateTransferStatus.CREATED + + transfers = client.lookup_transfers([transfer.id]) + assert len(transfers) == 1 + assert transfers[0].timestamp == transfers_results[0].timestamp + +def test_accept_zero_length_create_accounts(client): + results = client.create_accounts([]) + assert results == [] + +def test_accept_zero_length_create_transfers(client): + results = client.create_transfers([]) + assert results == [] + +def test_accept_zero_length_lookup_accounts(client): + accounts = client.lookup_accounts([]) + assert accounts == [] + +def test_accept_zero_length_lookup_transfers(client): + transfers = client.lookup_transfers([]) + assert transfers == [] + +def test_uint128(client): + import json + import subprocess + + account = tb.Account( + id=2**128-10, + user_data_128=2**128-1024, + user_data_64=2**64-1024, + ledger=1, + code=1 + ) + results = client.create_accounts([account]) + assert len(results) == 1 + assert results[0].timestamp > 0 + assert results[0].status == tb.CreateAccountStatus.CREATED + + accounts = client.lookup_accounts([account.id]) + assert len(accounts) == 1 + assert accounts[0] == tb.Account( + id=340282366920938463463374607431768211446, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=340282366920938463463374607431768210432, + user_data_64=18446744073709550592, + user_data_32=0, + ledger=1, + code=1, + timestamp=results[0].timestamp, + flags=tb.AccountFlags.NONE + ) + + expected_repl_response = { + "id": "340282366920938463463374607431768211446", + "debits_pending": "0", + "debits_posted": "0", + "credits_pending": "0", + "credits_posted": "0", + "user_data_128": "340282366920938463463374607431768210432", + "user_data_64": "18446744073709550592", + "user_data_32": "0", + "ledger": "1", + "code": "1", + "flags": [], + } + expected_repl_response["timestamp"] = str(results[0].timestamp) + + expected_repl_response_as_account = tb.Account() + for k, v in expected_repl_response.items(): + if k == "flags": + v = tb.AccountFlags.NONE + else: + v = int(v) + setattr(expected_repl_response_as_account, k, v) + + assert accounts[0] == expected_repl_response_as_account + + tigerbeetle = os.getenv("TIGERBEETLE_BINARY", "tigerbeetle") + repl_output = subprocess.run( + [ + tigerbeetle, + "repl", + "--cluster=0", + "--addresses=" + replica_addresses, + "--command=lookup_accounts id=340282366920938463463374607431768211446" + ], + check=True, + capture_output=True + ) + assert json.loads(repl_output.stdout) == expected_repl_response + + +def test_ids_random(): + """IDs are different from repeated invocations of the function.""" + samples = [tb.id() for _ in range(10_000)] + assert len(samples) == len(set(samples)) + +def test_ids_increasing(): + """IDs are expected to be strictly increasing.""" + id_previous = tb.id() + for i in range(10_000): + id = tb.id() + assert id_previous < id + id_previous = id diff --git a/ocam/src/clients/python/tests/test_close.py b/ocam/src/clients/python/tests/test_close.py new file mode 100644 index 00000000..791f0356 --- /dev/null +++ b/ocam/src/clients/python/tests/test_close.py @@ -0,0 +1,87 @@ +import asyncio +import ctypes +import itertools +import socket +import threading +import time + +import pytest + +import tigerbeetle as tb +tb.configure_logging(debug=True) + +def _blocking_lookup(client, result): + assert isinstance(result, list) + try: + result = client.lookup_accounts([1]) + raise AssertionError("lookup_accounts didn't throw an exception") + except Exception as e: + result.append(e) + +def test_close_sync(): + # Bind a socket to a free port to get a socket that's definitely not TigerBeetle. + not_tigerbeetle = socket.socket() + not_tigerbeetle.bind(('127.0.0.1', 0)) + not_tigerbeetle_port = not_tigerbeetle.getsockname()[1] + + client = tb.ClientSync(cluster_id=1234, replica_addresses=f"127.0.0.1:{not_tigerbeetle_port}") + + # Submit a request, which would normally block. + thread_result = [] + thread = threading.Thread(target=_blocking_lookup, args=(client, thread_result)) + thread.start() + + # Wait until the request is actually in-flight. + for i in range(0, 10): + if len(client._inflight_packets) == 1: + break + time.sleep(0.01) + else: + raise AssertionError("thread didn't create a request in time") + + assert len(client._inflight_packets) == 1 + + client.close() + thread.join() + + assert len(thread_result) == 1 + with pytest.raises(tb.ClientClosedError): + raise thread_result[0] + + # Closing the client should have resulted in the request being terminated. + assert len(client._inflight_packets) == 0 + assert client._client_key not in tb.ClientSync._clients + +def test_close_async(): + # Saves having an extra dependency like pytest-asyncio! + asyncio.run(_test_close_async()) + +async def _test_close_async(): + # Bind a socket to a free port to get a socket that's definitely not TigerBeetle. + not_tigerbeetle = socket.socket() + not_tigerbeetle.bind(('127.0.0.1', 0)) + not_tigerbeetle_port = not_tigerbeetle.getsockname()[1] + + client = tb.ClientAsync(cluster_id=1234, replica_addresses=f"127.0.0.1:{not_tigerbeetle_port}") + + # Submit a request, as a task. + lookup_task = asyncio.create_task(client.lookup_accounts([1])) + + # Wait until the request is actually in-flight. + for i in range(0, 10): + if len(client._inflight_packets) == 1: + break + await asyncio.sleep(0.01) + else: + raise AssertionError("thread didn't create a request in time") + + assert len(client._inflight_packets) == 1 + + await client.close() + + with pytest.raises(tb.ClientClosedError): + lookup_result = await lookup_task + + # Closing the client should have resulted in the request being terminated. + assert len(client._inflight_packets) == 0 + assert client._client_key not in tb.ClientSync._clients diff --git a/ocam/src/clients/python/tests/test_init_parameters.py b/ocam/src/clients/python/tests/test_init_parameters.py new file mode 100644 index 00000000..6633adcc --- /dev/null +++ b/ocam/src/clients/python/tests/test_init_parameters.py @@ -0,0 +1,51 @@ +import ctypes +import itertools + +import tigerbeetle as tb +tb.configure_logging(debug=True) + +cluster_ids = [ + 2**8 - 1, + 2**16 - 1, + 2**32 - 1, + 2**64 - 1, + 2**128 - 1, + 71274155903562890452255960078140154531, +] + +addresses = [ + "1.1.1.1", + "1.1.1.1:3000", + "1.1.1.1:40000", + "127.127.127.127:12712", + "[0000:0000:0000:0000:0000:ffff:c0a8:64e4]:65535", + "[0000:0000:0000:0000:0000:ffff:c0a8:64e4]:65534", +] + +def test_init_parameters(): + address_permutations = [] + for address in addresses: + address_permutations.append(",".join(itertools.repeat(address, 6))) + address_permutations.append(",".join(addresses)) + + for cluster_id in cluster_ids: + for address_permutation in address_permutations: + client = tb.ClientSync(cluster_id=cluster_id, replica_addresses=address_permutation) + init_parameters_out = tb.InitParameters() + + status = tb.bindings.tb_client_init_parameters( + client._client, + ctypes.byref(init_parameters_out), + ) + + assert status == tb.ClientStatus.OK + + addresses_out_slice = ctypes.cast(init_parameters_out.addresses_ptr, + ctypes.POINTER(ctypes.c_char * init_parameters_out.addresses_len)) + addresses_out = bytes(addresses_out_slice.contents).decode("ascii") + + assert init_parameters_out.client_id.to_python() != 0 + assert init_parameters_out.cluster_id.to_python() == cluster_id + assert addresses_out == address_permutation + + client.close() diff --git a/ocam/src/clients/python/wheel.zig b/ocam/src/clients/python/wheel.zig new file mode 100644 index 00000000..455112b7 --- /dev/null +++ b/ocam/src/clients/python/wheel.zig @@ -0,0 +1,218 @@ +//! Create a tigerbeetle Python wheel. +//! cwd must be src/clients/python. + +const std = @import("std"); +const stdx = @import("stdx"); + +const file_size_max = 10 * 1024 * 1024; + +const metadata_header = + \\Metadata-Version: 2.4 + \\Name: tigerbeetle + \\Version: {s} + \\Summary: The TigerBeetle client for Python. + \\Project-URL: Homepage, https://github.com/tigerbeetle/tigerbeetle + \\Project-URL: Issues, https://github.com/tigerbeetle/tigerbeetle/issues + \\Classifier: Development Status :: 5 - Production/Stable + \\Classifier: License :: OSI Approved :: Apache Software License + \\Classifier: Operating System :: MacOS :: MacOS X + \\Classifier: Operating System :: Microsoft :: Windows + \\Classifier: Operating System :: POSIX :: Linux + \\Classifier: Programming Language :: Python :: 3 + \\Classifier: Topic :: Database :: Front-Ends + \\Requires-Python: >=3.7 + \\Description-Content-Type: text/markdown + \\ + \\ +; +const readme = @embedFile("README.md"); + +const wheel_content = + \\Wheel-Version: 1.0 + \\Generator: tigerbeetle/src/scripts/release.zig + \\Root-Is-Purelib: true + \\Tag: py3-none-any + \\ +; + +// base64 of a 32 byte sha256 digest is always 43 chars. +const sha256_base64_length = 43; + +const Entry = struct { + archive_name: []const u8, + local_header_offset: u32, + crc32: u32, + compressed_size: u32, + uncompressed_size: u32, + sha256_base64: [sha256_base64_length]u8, +}; + +pub fn make( + shell: *stdx.Shell, + tag: []const u8, + commit_timestamp: stdx.InstantUnix, + output_path: []const u8, +) !void { + const arena = shell.arena.allocator(); + const dos_timestamp = stdx.Shell.unix_to_dos_timestamp(commit_timestamp); + + if (std.fs.path.dirname(output_path)) |path| try shell.cwd.makePath(path); + const output_file = try shell.cwd.createFile(output_path, .{}); + defer output_file.close(); + + var buffered_writer = std.io.bufferedWriter(output_file.writer()); + const writer = buffered_writer.writer(); + + var metadata_buffer = std.ArrayList(u8).init(arena); + try metadata_buffer.writer().print(metadata_header, .{tag}); + try metadata_buffer.appendSlice(readme); + const metadata = metadata_buffer.items; + + var package_dir = try shell.cwd.openDir("src/tigerbeetle", .{ .iterate = true }); + defer package_dir.close(); + + var walker = try package_dir.walk(arena); + defer walker.deinit(); + + var file_paths = std.ArrayList([]const u8).init(arena); + while (try walker.next()) |entry| { + if (entry.kind != .file) continue; + try file_paths.append(try arena.dupe(u8, entry.path)); + } + // Sort files for reproducibility. + std.mem.sort([]const u8, file_paths.items, {}, string_less_than); + + var offset: u32 = 0; + var entries = std.ArrayList(Entry).init(arena); + + for (file_paths.items) |relative_path| { + const archive_name = try shell.fmt("tigerbeetle/{s}", .{relative_path}); + const data = try package_dir.readFileAlloc(arena, relative_path, file_size_max); + try add_entry(arena, &entries, writer, &offset, archive_name, data, dos_timestamp); + } + + const dist_info = try shell.fmt("tigerbeetle-{s}.dist-info", .{tag}); + + const metadata_name = try shell.fmt("{s}/METADATA", .{dist_info}); + try add_entry(arena, &entries, writer, &offset, metadata_name, metadata, dos_timestamp); + + const wheel_name = try shell.fmt("{s}/WHEEL", .{dist_info}); + try add_entry(arena, &entries, writer, &offset, wheel_name, wheel_content, dos_timestamp); + + // Build RECORD: all prior entries with sha256 hashes, then RECORD itself with empty fields. + const record_name = try shell.fmt("{s}/RECORD", .{dist_info}); + var record_buffer = std.ArrayList(u8).init(arena); + for (entries.items) |entry| { + try record_buffer.writer().print("{s},sha256={s},{d}\n", .{ + entry.archive_name, entry.sha256_base64, entry.uncompressed_size, + }); + } + try record_buffer.writer().print("{s},,\n", .{record_name}); + try add_entry( + arena, + &entries, + writer, + &offset, + record_name, + record_buffer.items, + dos_timestamp, + ); + + // Write central directory. + const central_directory_offset = offset; + for (entries.items) |entry| { + const central_directory_header: std.zip.CentralDirectoryFileHeader = .{ + .signature = std.zip.central_file_header_sig, + .version_made_by = 0, + .version_needed_to_extract = 20, + .flags = @bitCast(@as(u16, 0)), + .compression_method = .deflate, + .last_modification_time = dos_timestamp.time, + .last_modification_date = dos_timestamp.date, + .crc32 = entry.crc32, + .compressed_size = entry.compressed_size, + .uncompressed_size = entry.uncompressed_size, + .filename_len = @intCast(entry.archive_name.len), + .extra_len = 0, + .comment_len = 0, + .disk_number = 0, + .internal_file_attributes = 0, + .external_file_attributes = 0, + .local_file_header_offset = entry.local_header_offset, + }; + try writer.writeStructEndian(central_directory_header, .little); + try writer.writeAll(entry.archive_name); + offset += @intCast(@sizeOf(std.zip.CentralDirectoryFileHeader) + entry.archive_name.len); + } + + const end_record: std.zip.EndRecord = .{ + .signature = std.zip.end_record_sig, + .disk_number = 0, + .central_directory_disk_number = 0, + .record_count_disk = @intCast(entries.items.len), + .record_count_total = @intCast(entries.items.len), + .central_directory_size = offset - central_directory_offset, + .central_directory_offset = central_directory_offset, + .comment_len = 0, + }; + try writer.writeStructEndian(end_record, .little); + + try buffered_writer.flush(); +} + +fn add_entry( + arena: std.mem.Allocator, + entries: *std.ArrayList(Entry), + writer: anytype, + offset: *u32, + archive_name: []const u8, + data: []const u8, + dos_timestamp: stdx.Shell.DOSTimestamp, +) !void { + const crc32 = std.hash.Crc32.hash(data); + + var compressed_buffer = std.ArrayList(u8).init(arena); + var compressor = try std.compress.flate.compressor(compressed_buffer.writer(), .{}); + try compressor.writer().writeAll(data); + try compressor.finish(); + const compressed = compressed_buffer.items; + + var sha256_digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(data, &sha256_digest, .{}); + var sha256_base64_buffer: [sha256_base64_length]u8 = undefined; + _ = std.base64.url_safe_no_pad.Encoder.encode(&sha256_base64_buffer, &sha256_digest); + + const local_header_offset = offset.*; + + const local_header: std.zip.LocalFileHeader = .{ + .signature = std.zip.local_file_header_sig, + .version_needed_to_extract = 20, + .flags = @bitCast(@as(u16, 0)), + .compression_method = .deflate, + .last_modification_time = dos_timestamp.time, + .last_modification_date = dos_timestamp.date, + .crc32 = crc32, + .compressed_size = @intCast(compressed.len), + .uncompressed_size = @intCast(data.len), + .filename_len = @intCast(archive_name.len), + .extra_len = 0, + }; + try writer.writeStructEndian(local_header, .little); + try writer.writeAll(archive_name); + try writer.writeAll(compressed); + + offset.* += @intCast(@sizeOf(std.zip.LocalFileHeader) + archive_name.len + compressed.len); + + try entries.append(.{ + .archive_name = archive_name, + .local_header_offset = local_header_offset, + .crc32 = crc32, + .compressed_size = @intCast(compressed.len), + .uncompressed_size = @intCast(data.len), + .sha256_base64 = sha256_base64_buffer, + }); +} + +fn string_less_than(_: void, lhs: []const u8, rhs: []const u8) bool { + return std.mem.order(u8, lhs, rhs) == .lt; +} diff --git a/ocam/src/clients/ruby/.gitignore b/ocam/src/clients/ruby/.gitignore new file mode 100644 index 00000000..a8287528 --- /dev/null +++ b/ocam/src/clients/ruby/.gitignore @@ -0,0 +1,8 @@ +*.gem +src/ext/tigerbeetle/lib/ +src/ext/tigerbeetle/Makefile +src/ext/tigerbeetle/*.o +src/ext/tigerbeetle/*.so +src/ext/tigerbeetle/*.bundle +src/ext/tigerbeetle/mkmf.log +src/ext/tigerbeetle/compile_flags.txt diff --git a/ocam/src/clients/ruby/LICENSE b/ocam/src/clients/ruby/LICENSE new file mode 100644 index 00000000..d9a10c0d --- /dev/null +++ b/ocam/src/clients/ruby/LICENSE @@ -0,0 +1,176 @@ + 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 diff --git a/ocam/src/clients/ruby/README.md b/ocam/src/clients/ruby/README.md new file mode 100644 index 00000000..0699cea1 --- /dev/null +++ b/ocam/src/clients/ruby/README.md @@ -0,0 +1,722 @@ + +# tigerbeetle + +The TigerBeetle client for Ruby. + +>[!IMPORTANT] +>This gem changed ownership from [Anthony D](https://github.com/antstorm) +to TigerBeetle. If you're upgrading from a 0.0.x version, please consult +the [migration guide]( +https://github.com/tigerbeetle/tigerbeetle/blob/main/src/clients/ruby/docs/migration.md +) for the necessary code changes. + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Ruby >= `3.3` + +## Setup + +First, create a directory for your project and `cd` into the directory. + +Then, install the TigerBeetle client: + +```console +gem install tigerbeetle +``` + +Now, create `main.rb` and copy this into it: + +```ruby +require "tigerbeetle" + +puts("Import OK!") +``` + +Finally, build and run: + +```console +ruby main.rb +``` + +Now that all prerequisites and dependencies are correctly set +up, let's dig into using TigerBeetle. + +## Sample projects + +This document is primarily a reference guide to +the client. Below are various sample projects demonstrating +features of TigerBeetle. + +* [Basic](/src/clients/ruby/samples/basic/): Create two accounts and transfer an amount between them. +* [Two-Phase Transfer](/src/clients/ruby/samples/two-phase/): Create two accounts and start a pending transfer between +them, then post the transfer. +* [Many Two-Phase Transfers](/src/clients/ruby/samples/two-phase-many/): Create two accounts and start a number of pending transfers +between them, posting and voiding alternating transfers. + +## Creating a Client + +A client is created with a cluster ID and replica +addresses for all replicas in the cluster. The cluster +ID and replica addresses are both chosen by the system that +starts the TigerBeetle cluster. + +Clients are thread-safe and a single instance should be shared +between multiple concurrent tasks. This allows events to be +[automatically batched](https://docs.tigerbeetle.com/coding/requests/#batching-events). + +Multiple clients are useful when connecting to more than +one TigerBeetle cluster. + +In this example the cluster ID is `0` and there is one +replica. The address is read from the `TB_ADDRESS` +environment variable and defaults to port `3000`. + +```ruby +replica_addresses = ENV.fetch("TB_ADDRESS", "3000") + +TigerBeetle::Client.open(cluster_id: 0, replica_addresses:) do |client| + # Use the client. +end +``` + +The gem also provides an optional top-level alias. +Require `tigerbeetle/tb` to use `TB` as a shorthand for `TigerBeetle`: + +```ruby +require "tigerbeetle/tb" + +account = TB::Account.new(id: TB.id, ledger: 1, code: 1) +``` + +The alias is opt-in and is not defined by `require "tigerbeetle"`. + +The `TigerBeetle::Client` is fiber-scheduler aware, so it works with e.g. +the `async` gem without requiring code changes. + +```ruby +require "async" +require "async/semaphore" +require "tigerbeetle" + +semaphore = Async::Semaphore.new(16) + +account_batches = Array.new(16) do + Array.new(1_000) do + TigerBeetle::Account.new(id: TigerBeetle.id, ledger: 1, code: 1) + end +end + +TigerBeetle::Client.open(cluster_id: 0, replica_addresses: "3000") do |client| + Async do + account_batches + .map { |batch| semaphore.async { client.create_accounts(batch) } } + .each(&:wait) + end +end +``` + +The following are valid addresses: +* `3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1:3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1` (interpreted as `127.0.0.1:3001`, `3001` is the default port) + +## Creating Accounts + +See details for account fields in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account). + +```ruby +account = TigerBeetle::Account.new( + id: TigerBeetle.id, + debits_pending: 0, + debits_posted: 0, + credits_pending: 0, + credits_posted: 0, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + ledger: 1, + code: 718, + flags: TigerBeetle::AccountFlags::NONE, + timestamp: 0 +) + +account_results = client.create_accounts([account]) +# Results handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +### Account Flags + +The account flags value is a bitfield. See details for +these flags in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account#flags). + +To toggle behavior for an account, combine constants from the +`TigerBeetle::AccountFlags` module with bitwise-or: + +* `AccountFlags::LINKED` +* `AccountFlags::DEBITS_MUST_NOT_EXCEED_CREDITS` +* `AccountFlags::CREDITS_MUST_NOT_EXCEED_DEBITS` +* `AccountFlags::HISTORY` + + +For example, to link two accounts where the first account +additionally has the `debits_must_not_exceed_credits` constraint: + +```ruby +account0 = TigerBeetle::Account.new( + id: TigerBeetle.id, + ledger: 1, + code: 1, + flags: TigerBeetle::AccountFlags::LINKED | + TigerBeetle::AccountFlags::DEBITS_MUST_NOT_EXCEED_CREDITS +) +account1 = TigerBeetle::Account.new( + id: TigerBeetle.id, + ledger: 1, + code: 1, + flags: TigerBeetle::AccountFlags::HISTORY +) + +account_results = client.create_accounts([account0, account1]) +# Results handling omitted. +``` + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each account in the request batch: +- Successfully created accounts with the status + [`created`](https://docs.tigerbeetle.com/reference/requests/create_accounts#created) + return the timestamp assigned to the `Account` object. +- Already existing accounts with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists) + return the timestamp of the original existing object. +- Failed accounts return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_accounts reference](https://docs.tigerbeetle.com/reference/requests/create_accounts#status). + +```ruby +accounts = [ + TigerBeetle::Account.new(id: TigerBeetle.id, ledger: 1, code: 1), + TigerBeetle::Account.new(id: TigerBeetle.id, ledger: 1, code: 1), + TigerBeetle::Account.new(id: TigerBeetle.id, ledger: 1, code: 1) +] + +account_results = client.create_accounts(accounts) +account_results.each_with_index do |result, index| + case result.status + when TigerBeetle::CreateAccountStatus::CREATED + puts("Batch account at #{index} successfully created with timestamp #{result.timestamp}.") + when TigerBeetle::CreateAccountStatus::EXISTS + puts("Batch account at #{index} already exists with timestamp #{result.timestamp}.") + else + puts("Batch account at #{index} failed to create: #{result.status}.") + end +end +``` + +To handle errors you can compare the result status returned +from `client.create_accounts` with constants in the +`TigerBeetle::CreateAccountStatus` module. + +## Account Lookup + +Account lookup is batched, like account creation. Pass +in all IDs to fetch. The account for each matched ID is returned. + +If no account matches an ID, no object is returned for +that account. So the order of accounts in the response is +not necessarily the same as the order of IDs in the +request. You can refer to the ID field in the response to +distinguish accounts. + +```ruby +accounts = client.lookup_accounts([account0.id, account1.id]) +``` + +## Create Transfers + +This creates a journal entry between two accounts. + +See details for transfer fields in the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer). + +```ruby +transfers = [ + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + pending_id: 0, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: TigerBeetle::TransferFlags::NONE, + timestamp: 0 + ) +] + +transfer_results = client.create_transfers(transfers) +# Results handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each transfer in the request batch: +- Successfully created transfers with the result + [`created`](https://docs.tigerbeetle.com/reference/requests/create_transfers#created) + return the timestamp assigned to the `Transfer` object. +- Already existing transfers with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists) + return the timestamp of the original existing object. +- Failed transfers return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_transfers reference](https://docs.tigerbeetle.com/reference/requests/create_transfers#status). + +```ruby +batch = [ + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720 + ), + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720 + ), + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720 + ) +] + +transfer_results = client.create_transfers(batch) +transfer_results.each_with_index do |result, index| + case result.status + when TigerBeetle::CreateTransferStatus::CREATED + puts("Batch transfer at #{index} successfully created with timestamp #{result.timestamp}.") + when TigerBeetle::CreateTransferStatus::EXISTS + puts("Batch transfer at #{index} already exists with timestamp #{result.timestamp}.") + else + puts("Batch transfer at #{index} failed to create: #{result.status}.") + end +end +``` + +To handle errors you can compare the result status returned +from `client.create_transfers` with constants in the +`TigerBeetle::CreateTransferStatus` module. + +## Batching + +TigerBeetle performance is maximized when you batch +API requests. + +A client instance shared across multiple threads/tasks can automatically +batch concurrent requests, but the application must still send as many events +as possible in a single call. + +For example, if you insert 1 million transfers sequentially, one at a time, +the insert rate will be a *fraction* of the potential, because the client will +wait for a reply between each one. +Instead, **always batch as much as you can**. + +The maximum batch size is set in the TigerBeetle server. The default is 8189. + +```ruby +# Array of transfers to create. +batch = [] +batch.each_slice(8189) do |slice| + transfer_results = client.create_transfers(slice) + # Results handling omitted. +end +``` + +### Queues and Workers + +If you are making requests to TigerBeetle from workers +pulling jobs from a queue, you can batch requests to +TigerBeetle by having the worker act on multiple jobs from +the queue at once rather than one at a time. i.e. pulling +multiple jobs from the queue rather than just one. + +## Transfer Flags + +The transfer `flags` value is a bitfield. See details for these flags in +the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer#flags). + +To toggle behavior for a transfer, combine constants from the +`TigerBeetle::TransferFlags` module with bitwise-or: + +* `TransferFlags::LINKED` +* `TransferFlags::PENDING` +* `TransferFlags::POST_PENDING_TRANSFER` +* `TransferFlags::VOID_PENDING_TRANSFER` + +For example, to link `transfer0` and `transfer1`: + +```ruby +transfer0 = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720, + flags: TigerBeetle::TransferFlags::LINKED +) +transfer1 = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720 +) + +transfer_results = client.create_transfers([transfer0, transfer1]) +# Results handling omitted. +``` + +### Two-Phase Transfers + +Two-phase transfers are supported natively by toggling the appropriate +flag. TigerBeetle will then adjust the `credits_pending` and +`debits_pending` fields of the appropriate accounts. A corresponding +post pending transfer then needs to be sent to post or void the +transfer. + +#### Post a Pending Transfer + +With `flags` set to `post_pending_transfer`, +TigerBeetle will post the transfer. TigerBeetle will atomically roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and apply them to the `debits_posted` and +`credits_posted` balances. + +```ruby +pending_transfer = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720, + flags: TigerBeetle::TransferFlags::PENDING +) + +transfer_results = client.create_transfers([pending_transfer]) +# Results handling omitted. + +post_transfer = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + pending_id: pending_transfer.id, + ledger: 1, + code: 720, + flags: TigerBeetle::TransferFlags::POST_PENDING_TRANSFER +) + +transfer_results = client.create_transfers([post_transfer]) +# Results handling omitted. +``` + +#### Void a Pending Transfer + +In contrast, with `flags` set to `void_pending_transfer`, +TigerBeetle will void the transfer. TigerBeetle will roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and **not** apply them to the `debits_posted` and +`credits_posted` balances. + +```ruby +pending_transfer = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720, + flags: TigerBeetle::TransferFlags::PENDING +) + +transfer_results = client.create_transfers([pending_transfer]) +# Results handling omitted. + +void_transfer = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 0, + pending_id: pending_transfer.id, + ledger: 1, + code: 720, + flags: TigerBeetle::TransferFlags::VOID_PENDING_TRANSFER +) + +transfer_results = client.create_transfers([void_transfer]) +# Results handling omitted. +``` + +## Transfer Lookup + +NOTE: While transfer lookup exists, it is not a flexible query API. We +are developing query APIs and there will be new methods for querying +transfers in the future. + +Transfer lookup is batched, like transfer creation. Pass in all `id`s to +fetch, and matched transfers are returned. + +If no transfer matches an `id`, no object is returned for that +transfer. So the order of transfers in the response is not necessarily +the same as the order of `id`s in the request. You can refer to the +`id` field in the response to distinguish transfers. + +```ruby +transfers = client.lookup_transfers([transfer0.id, transfer1.id]) +``` + +## Get Account Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the transfers involving a given account, allowing basic filter and pagination +capabilities. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```ruby +filter = TigerBeetle::AccountFilter.new( + account_id: account1.id, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + code: 0, + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: TigerBeetle::AccountFilterFlags::DEBITS | + TigerBeetle::AccountFilterFlags::CREDITS | + TigerBeetle::AccountFilterFlags::REVERSED +) + +account_transfers = client.get_account_transfers(filter) +``` + +## Get Account Balances + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the point-in-time balances of a given account, allowing basic filter and +pagination capabilities. + +Only accounts created with the flag +[`history`](https://docs.tigerbeetle.com/reference/account#flagshistory) set retain +[historical balances](https://docs.tigerbeetle.com/reference/requests/get_account_balances). + +The balances in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```ruby +filter = TigerBeetle::AccountFilter.new( + account_id: account1.id, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + code: 0, + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: TigerBeetle::AccountFilterFlags::DEBITS | + TigerBeetle::AccountFilterFlags::CREDITS | + TigerBeetle::AccountFilterFlags::REVERSED +) + +account_balances = client.get_account_balances(filter) +``` + +## Query Accounts + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query accounts by the intersection of some fields and by timestamp range. + +The accounts in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```ruby +query_filter = TigerBeetle::QueryFilter.new( + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + ledger: 1, + code: 1, + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: TigerBeetle::QueryFilterFlags::REVERSED +) + +query_accounts = client.query_accounts(query_filter) +``` + +## Query Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query transfers by the intersection of some fields and by timestamp range. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```ruby +query_filter = TigerBeetle::QueryFilter.new( + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + ledger: 1, + code: 720, + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: TigerBeetle::QueryFilterFlags::REVERSED +) + +query_transfers = client.query_transfers(query_filter) +``` + +## Linked Events + +When the `linked` flag is specified for an account when creating accounts or +a transfer when creating transfers, it links that event with the next event in the +batch, to create a chain of events, of arbitrary length, which all +succeed or fail together. The tail of a chain is denoted by the first +event without this flag. The last event in a batch may therefore never +have the `linked` flag set as this would leave a chain +open-ended. Multiple chains or individual events may coexist within a +batch to succeed or fail independently. + +Events within a chain are executed within order, or are rolled back on +error, so that the effect of each event in the chain is visible to the +next, and so that the chain is either visible or invisible as a unit +to subsequent events after the chain. The event that was the first to +break the chain will have a unique error result. Other events in the +chain will have their error result set to `linked_event_failed`. + +```ruby +linked_flag = TigerBeetle::TransferFlags::LINKED +batch = [ + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 1, + ledger: 1, + code: 720, + flags: linked_flag + ), + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 1, + ledger: 1, + code: 720 + ) +] + +transfer_results = client.create_transfers(batch) +# Results handling omitted. +``` + +## Imported Events + +When the `imported` flag is specified for an account when creating accounts or +a transfer when creating transfers, it allows importing historical events with +a user-defined timestamp. + +The entire batch of events must be set with the flag `imported`. + +It's recommended to submit the whole batch as a `linked` chain of events, ensuring that +if any event fails, none of them are committed, preserving the last timestamp unchanged. +This approach gives the application a chance to correct failed imported events, re-submitting +the batch again with the same user-defined timestamps. + +```ruby +historical_timestamp = 0 +# Loaded from an external source. +historical_accounts = [] +# Loaded from an external source. +historical_transfers = [] + +accounts_to_import = historical_accounts.map.with_index do |historical_account, index| + historical_timestamp += 1 + historical_account.timestamp = historical_timestamp + historical_account.flags = TigerBeetle::AccountFlags::IMPORTED + if index < historical_accounts.length - 1 + historical_account.flags |= TigerBeetle::AccountFlags::LINKED + end + + historical_account +end + +account_results = client.create_accounts(accounts_to_import) +# Results handling omitted. + +transfers_to_import = historical_transfers.map.with_index do |historical_transfer, index| + historical_timestamp += 1 + historical_transfer.timestamp = historical_timestamp + historical_transfer.flags = TigerBeetle::TransferFlags::IMPORTED + if index < historical_transfers.length - 1 + historical_transfer.flags |= TigerBeetle::TransferFlags::LINKED + end + + historical_transfer +end + +transfer_results = client.create_transfers(transfers_to_import) +# Results handling omitted. +``` + +## Timeouts And Cancellation + +The Client retries indefinitely and doesn't impose any per-request timeout. Cancellation is +provided as a mechanism, and the specific cancellation policy is left to the +application. A Client instance can be closed at any time. On close, all in-flight +requests are canceled and return an error to the caller. Even if an error is returned, +a request might still be processed by the TigerBeetle server. +[Reliable transaction submission](https://docs.tigerbeetle.com/coding/reliable-transaction-submission/) +explains how to make transfers retry-proof using IDs for end-to-end idempotency. diff --git a/ocam/src/clients/ruby/Rakefile b/ocam/src/clients/ruby/Rakefile new file mode 100644 index 00000000..6ed7baea --- /dev/null +++ b/ocam/src/clients/ruby/Rakefile @@ -0,0 +1,33 @@ +require "minitest/test_task" + +EXT_DIR = File.expand_path("src/ext/tigerbeetle", __dir__) +PROJECT_ROOT = File.expand_path("../../..", __dir__) + +task(:generate) do + zig = ENV.fetch("ZIG_EXE") { File.join(PROJECT_ROOT, "zig", "zig") } + sh(zig, "build", "clients:ruby", chdir: PROJECT_ROOT) +end + +desc("Build the C extension") +task(:ext_compile) do + Rake::Task[:generate].invoke unless ENV.key?("CI") + sh("ruby extconf.rb && make", chdir: EXT_DIR) +end + +namespace(:test) do + Minitest::TestTask.create(:unit) do |t| + t.libs = [".", "src", "src/ext"] + t.test_globs = ["tests/unit/**/*.rb"] + end + + task(unit: :ext_compile) + + Minitest::TestTask.create(:integration) do |t| + t.libs = [".", "src", "src/ext"] + t.test_globs = ["tests/integration/**/*.rb"] + end + + task(integration: :ext_compile) +end + +task(default: "test:unit") diff --git a/ocam/src/clients/ruby/ci.zig b/ocam/src/clients/ruby/ci.zig new file mode 100644 index 00000000..a3242a2e --- /dev/null +++ b/ocam/src/clients/ruby/ci.zig @@ -0,0 +1,118 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const log = std.log; +const assert = std.debug.assert; + +const Shell = @import("stdx").Shell; +const TmpTigerBeetle = @import("../../testing/tmp_tigerbeetle.zig"); + +pub fn tests(shell: *Shell, gpa: std.mem.Allocator) !void { + assert(shell.file_exists("tigerbeetle.gemspec")); + + // Integration tests. + + try shell.exec_zig("build clients:ruby -Drelease", .{}); + + // Only to test the build process - the samples below run directly from the src/ directory. + try shell.exec("gem build tigerbeetle.gemspec", .{}); + + { + log.info("running tests", .{}); + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + const tigerbeetle_exe = comptime "tigerbeetle" ++ builtin.target.exeFileExt(); + const tigerbeetle_path = try shell.project_root.realpathAlloc( + shell.arena.allocator(), + tigerbeetle_exe, + ); + try shell.env.put("TIGERBEETLE_BINARY", tigerbeetle_path); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + try shell.exec("rake test:unit", .{}); + try shell.exec("rake test:integration", .{}); + } + + inline for ([_][]const u8{ "basic", "two-phase", "two-phase-many", "walkthrough" }) |sample| { + log.info("testing sample '{s}'", .{sample}); + + try shell.pushd("./samples/" ++ sample); + defer shell.popd(); + + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + try shell.exec("ruby -I ../../src -I ../../src/ext main.rb", .{}); + } +} + +pub fn validate_release_package(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, +}) !void { + _ = shell; + _ = gpa; + _ = options; +} + +pub fn validate_release_sample(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, + tigerbeetle: []const u8, +}) !void { + const tmp_dir = try shell.create_tmp_dir(); + defer shell.cwd.deleteTree(tmp_dir) catch {}; + + try shell.env.put("GEM_HOME", tmp_dir); + try shell.env.put("GEM_PATH", tmp_dir); + + for (0..9) |_| { + if (shell.exec("gem install tigerbeetle -v {release}", .{ + .release = options.release, + })) { + break; + } else |_| { + log.warn("waiting for 5 minutes for the {s} version to appear in RubyGems", .{ + options.release, + }); + std.time.sleep(5 * std.time.ns_per_min); + } + } else { + shell.exec("gem install tigerbeetle -v {release}", .{ + .release = options.release, + }) catch |err| { + log.err("package is not available in RubyGems", .{}); + return err; + }; + } + + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + .prebuilt = options.tigerbeetle, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + + try Shell.copy_path( + shell.cwd, + "src/clients/ruby/samples/basic/main.rb", + shell.cwd, + "main.rb", + ); + try shell.exec("ruby main.rb", .{}); +} + +pub fn release_published_latest(shell: *Shell) ![]const u8 { + const output = try shell.exec_stdout("gem search --exact --versions tigerbeetle", .{}); + const version_start = std.mem.indexOf(u8, output, "(").? + 1; + const version_end = std.mem.indexOf(u8, output, ")").?; + + return output[version_start..version_end]; +} diff --git a/ocam/src/clients/ruby/docs.zig b/ocam/src/clients/ruby/docs.zig new file mode 100644 index 00000000..f3b71558 --- /dev/null +++ b/ocam/src/clients/ruby/docs.zig @@ -0,0 +1,106 @@ +const Docs = @import("../docs_types.zig").Docs; + +pub const RubyDocs = Docs{ + .directory = "ruby", + + .markdown_name = "ruby", + .extension = "rb", + .proper_name = "Ruby", + + .test_source_path = "", + + .name = "tigerbeetle", + .description = + \\The TigerBeetle client for Ruby. + \\ + \\>[!IMPORTANT] + \\>This gem changed ownership from [Anthony D](https://github.com/antstorm) + \\to TigerBeetle. If you're upgrading from a 0.0.x version, please consult + \\the [migration guide]( + \\https://github.com/tigerbeetle/tigerbeetle/blob/main/src/clients/ruby/docs/migration.md + \\) for the necessary code changes. + , + .prerequisites = + \\* Ruby >= `3.3` + , + + .project_file = "", + .project_file_name = "", + .test_file_name = "main", + + .install_commands = "gem install tigerbeetle", + .run_commands = "ruby main.rb", + + .examples = "", + + .client_object_documentation = + \\The gem also provides an optional top-level alias. + \\Require `tigerbeetle/tb` to use `TB` as a shorthand for `TigerBeetle`: + \\ + \\```ruby + \\require "tigerbeetle/tb" + \\ + \\account = TB::Account.new(id: TB.id, ledger: 1, code: 1) + \\``` + \\ + \\The alias is opt-in and is not defined by `require "tigerbeetle"`. + \\ + \\The `TigerBeetle::Client` is fiber-scheduler aware, so it works with e.g. + \\the `async` gem without requiring code changes. + \\ + \\```ruby + \\require "async" + \\require "async/semaphore" + \\require "tigerbeetle" + \\ + \\semaphore = Async::Semaphore.new(16) + \\ + \\account_batches = Array.new(16) do + \\ Array.new(1_000) do + \\ TigerBeetle::Account.new(id: TigerBeetle.id, ledger: 1, code: 1) + \\ end + \\end + \\ + \\TigerBeetle::Client.open(cluster_id: 0, replica_addresses: "3000") do |client| + \\ Async do + \\ account_batches + \\ .map { |batch| semaphore.async { client.create_accounts(batch) } } + \\ .each(&:wait) + \\ end + \\end + \\``` + , + .create_accounts_documentation = "", + .account_flags_documentation = + \\To toggle behavior for an account, combine constants from the + \\`TigerBeetle::AccountFlags` module with bitwise-or: + \\ + \\* `AccountFlags::LINKED` + \\* `AccountFlags::DEBITS_MUST_NOT_EXCEED_CREDITS` + \\* `AccountFlags::CREDITS_MUST_NOT_EXCEED_DEBITS` + \\* `AccountFlags::HISTORY` + \\ + , + + .create_accounts_errors_documentation = + \\To handle errors you can compare the result status returned + \\from `client.create_accounts` with constants in the + \\`TigerBeetle::CreateAccountStatus` module. + , + .create_transfers_documentation = "", + .create_transfers_errors_documentation = + \\To handle errors you can compare the result status returned + \\from `client.create_transfers` with constants in the + \\`TigerBeetle::CreateTransferStatus` module. + , + + .transfer_flags_documentation = + \\To toggle behavior for a transfer, combine constants from the + \\`TigerBeetle::TransferFlags` module with bitwise-or: + \\ + \\* `TransferFlags::LINKED` + \\* `TransferFlags::PENDING` + \\* `TransferFlags::POST_PENDING_TRANSFER` + \\* `TransferFlags::VOID_PENDING_TRANSFER` + , +}; diff --git a/ocam/src/clients/ruby/docs/migration.md b/ocam/src/clients/ruby/docs/migration.md new file mode 100644 index 00000000..229b1e0f --- /dev/null +++ b/ocam/src/clients/ruby/docs/migration.md @@ -0,0 +1,201 @@ +# TigerBeetle gem migration guide + +Between 0.0.x and 0.x.y the TigerBeetle gem changed from a third-party client +developed by [Anthony D](https://github.com/antstorm) to an official client +maintained by TigerBeetle. Thank you, Anthony! + +While the overall API remains similar, some changes were made to provide an +experience that's more consistent with the official clients for other languages. + +- [Client changes](#client-changes) + - [Connecting](#connecting) + - [Callback API](#callback-api) + - [Splat parameters](#splat-parameters) + - [Flag handling](#flag-handling) + - [Returned objects](#returned-objects) + - [Exception classes](#exception-classes) + - [Logging](#logging) + - [`TB` top-level alias](#tb-top-level-alias) +- [Licensing](#licensing) + +## Client changes + +### Connecting + +The `TigerBeetle.connect` method has been removed and `TigerBeetle::Client.new` +no longer provides default arguments. + +```rb +# Before +client = TigerBeetle.connect # using default cluster_id (0) and address (127.0.0.1:3000) + +# After +client = TigerBeetle::Client.new(cluster_id: 0, replica_addresses: "127.0.0.1:3000") +``` + +The preferred way to manage the client's lifecycle is `TigerBeetle::Client.open`, +which will automatically close the connection when it is no longer needed: + +```rb +replica_addresses = ENV.fetch("TB_ADDRESS", "3000") + +TigerBeetle::Client.open(cluster_id: 0, replica_addresses:) do |client| + # Use the client. +end +``` + +When manually managing the client's lifecycle, `client.close` replaces the old +`client.deinit`. + +See ["Creating a Client"](../README.md#creating-a-client) for details. + +### Callback API + +The callback-based async API has been removed. `TigerBeetle::Client` is now +fiber-scheduler aware and works with the [`async`](https://github.com/socketry/async) +gem out of the box. + +```rb +# Before +client.lookup_accounts(100) do |result| + result # [#] +end + +# After +semaphore = Async::Semaphore.new(16) + +account_batches = [...] # set up account batches + +TigerBeetle::Client.open(cluster_id: 0, replica_addresses: "3000") do |client| + Async do + account_batches + .map { |batch| semaphore.async { client.create_accounts(batch) } } + .each(&:wait) + end +end +``` + +### Splat parameters + +All methods that previously took splat parameters now require explicit arrays: + +```rb +# Before +account_1, account_2 = client.lookup_accounts(100, 101) + +# After +account_1, account_2 = client.lookup_accounts([100, 101]) +``` + +### Flag handling + +All flags are now explicit numeric constants combined with `|`, not symbol arrays. + +```rb +# Before +filter = TigerBeetle::AccountFilter.new( + account_id: 100, + limit: 10, + flags: [:DEBITS, :CREDITS] +) + +transfers = client.get_account_transfers(filter) + +# After +filter = TigerBeetle::AccountFilter.new( + account_id: 100, + limit: 10, + flags: TigerBeetle::AccountFilterFlags::DEBITS | + TigerBeetle::AccountFilterFlags::CREDITS +) + +transfers = client.get_account_transfers(filter) +``` + +### Timestamp attributes + +All timestamp attributes have changed type from `Time` to `Integer` representing +nanoseconds since UNIX epoch. + +```rb +# Before +account.timestamp +# => 2026-06-22 11:49:05 1382929/2097152 +0100 + +# After +account.timestamp +# => 1782125345659431936 +``` + +Affected attributes: + +``` +TigerBeetle::Account#timestamp +TigerBeetle::AccountBalance#timestamp +TigerBeetle::AccountFilter#timestamp_min +TigerBeetle::AccountFilter#timestamp_max +TigerBeetle::QueryFilter#timestamp_min +TigerBeetle::QueryFilter#timestamp_max +TigerBeetle::Transfer#timestamp +``` + +### Returned objects + +Returned objects have changed from `FFI::Struct`/`Struct`-style objects to +regular Ruby classes, so `[]` no longer works for accessing fields. + +```rb +# Before +account[:debits_posted] + +# After +account.debits_posted +``` + +The return types of create operations also changed from two-element arrays to +result objects: + +```rb +# Before +index, status = result + +# After +result.timestamp +result.status +``` + +### Exception classes + +There was a change to the exception classes raised by the gem. + +```rb +# Before +StandardError + TigerBeetle::Error + TigerBeetle::ClientError + +# After +StandardError + TigerBeetle::InitError + TigerBeetle::ClientClosedError + TigerBeetle::PacketError +``` + +### Logging + +The `client.logger=` API has been removed. Any logging should happen in the +surrounding application code. + +### `TB` top-level alias + +The new gem provides a top-level `TB` alias which can be enabled as follows: + +```rb +require "tigerbeetle/tb" + +account = TB::Account.new(id: TB.id, ledger: 1, code: 1) +``` + +## Licensing + +No licensing changes have been made. The gem remains under the [Apache License, Version 2.0](../LICENSE). diff --git a/ocam/src/clients/ruby/ruby_bindings.zig b/ocam/src/clients/ruby/ruby_bindings.zig new file mode 100644 index 00000000..560273aa --- /dev/null +++ b/ocam/src/clients/ruby/ruby_bindings.zig @@ -0,0 +1,834 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const generator_options = @import("ruby_bindings_options"); +const vsr = @import("vsr"); +const exports = vsr.tb_client.exports; +const tb = vsr.tigerbeetle; +const stdx = vsr.stdx; + +const flag_mappings = .{ + .{ tb.AccountFlags, "AccountFlags" }, + .{ tb.TransferFlags, "TransferFlags" }, + .{ tb.AccountFilterFlags, "AccountFilterFlags" }, + .{ tb.QueryFilterFlags, "QueryFilterFlags" }, +}; + +const Buffer = struct { + inner: std.ArrayList(u8), + + pub fn init(allocator: std.mem.Allocator) Buffer { + return .{ .inner = std.ArrayList(u8).init(allocator) }; + } + + pub fn print(self: *Buffer, comptime format: []const u8, args: anytype) void { + self.inner.writer().print(format, args) catch unreachable; + } + + pub fn write(self: *Buffer, bytes: []const u8) void { + self.inner.writer().writeAll(bytes) catch unreachable; + } +}; + +fn ruby_flags_name_from_type(comptime Type: type) ?[]const u8 { + comptime for (flag_mappings) |mapping| { + const ZigType, const ruby_name = mapping; + if (Type == ZigType) return ruby_name; + }; + return null; +} + +fn c_operation_name(comptime operation: tb.Operation) []const u8 { + const upper_snake = stdx.to_case(@tagName(operation), .UPPER_CASE); + return "TB_OPERATION_" ++ upper_snake; +} + +fn c_init_status_name(comptime status: exports.tb_init_status) []const u8 { + const upper_snake = stdx.to_case(@tagName(status), .UPPER_CASE); + return "TB_INIT_" ++ upper_snake; +} + +fn operation_supported(comptime operation: tb.Operation) bool { + const name = @tagName(operation); + if (comptime std.mem.startsWith(u8, name, "deprecated_")) return false; + return switch (operation) { + .pulse, + .get_change_events, + => false, + else => true, + }; +} + +fn type_basename(comptime Type: type) []const u8 { + const name = @typeName(Type); + if (comptime std.mem.lastIndexOfScalar(u8, name, '.')) |index| { + return name[index + 1 ..]; + } + return name; +} + +fn to_snake_case(comptime input: []const u8) []const u8 { + comptime var output: [input.len * 2]u8 = undefined; + comptime var len: usize = 0; + inline for (input, 0..) |c, i| { + if (c >= 'A' and c <= 'Z') { + if (i > 0) { + output[len] = '_'; + len += 1; + } + output[len] = c + 32; + } else { + output[len] = c; + } + len += 1; + } + return output[0..len]; +} + +fn c_type_name(comptime Type: type) []const u8 { + return "tb_" ++ to_snake_case(type_basename(Type)) ++ "_t"; +} + +fn ruby_type_name(comptime Type: type) []const u8 { + return type_basename(Type); +} + +fn result_status_type(comptime Type: type) ?type { + if (Type == tb.CreateAccountResult) return tb.CreateAccountStatus; + if (Type == tb.CreateTransferResult) return tb.CreateTransferStatus; + return null; +} + +fn operation_function_name(comptime operation: tb.Operation) []const u8 { + return @tagName(operation); +} + +fn int_bits(comptime Type: type) comptime_int { + return switch (@typeInfo(Type)) { + .int => |info| info.bits, + .@"enum" => |info| @bitSizeOf(info.tag_type), + .@"struct" => |info| switch (info.layout) { + .@"packed" => @bitSizeOf(Type), + else => @compileError("unsupported struct integer field: " ++ @typeName(Type)), + }, + else => @compileError("unsupported integer field: " ++ @typeName(Type)), + }; +} + +fn field_reserved(comptime field_name: []const u8) bool { + if (comptime std.mem.eql(u8, field_name, "reserved")) return true; + if (comptime std.mem.eql(u8, field_name, "padding")) return true; + return false; +} + +fn emit_flags_module( + buffer: *Buffer, + comptime Type: type, + comptime ruby_name: []const u8, +) void { + assert(@typeInfo(Type) == .@"struct"); + assert(@typeInfo(Type).@"struct".layout == .@"packed"); + + buffer.print(" module {s}\n", .{ruby_name}); + buffer.print(" NONE = 0\n", .{}); + inline for (@typeInfo(Type).@"struct".fields, 0..) |field, i| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + if (comptime std.mem.eql(u8, field.name, "padding")) continue; + const upper_snake = stdx.to_case(field.name, .UPPER_CASE); + buffer.print(" {s} = 1 << {d}\n", .{ upper_snake, i }); + } + buffer.print(" end\n\n", .{}); +} + +fn emit_enum_module( + buffer: *Buffer, + comptime Type: type, + comptime ruby_name: []const u8, + comptime skip_fields: []const []const u8, +) void { + assert(@typeInfo(Type) == .@"enum"); + + buffer.print(" module {s}\n", .{ruby_name}); + inline for (@typeInfo(Type).@"enum".fields) |field| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + comptime var skip = false; + inline for (skip_fields) |sf| { + skip = skip or comptime std.mem.eql(u8, sf, field.name); + } + if (skip) continue; + const upper_snake = stdx.to_case(field.name, .UPPER_CASE); + const value: u64 = @intCast(@intFromEnum(@field(Type, field.name))); + buffer.print(" {s} = {d}\n", .{ upper_snake, value }); + } + buffer.print(" end\n\n", .{}); +} + +fn emit_field_default(buffer: *Buffer, comptime FieldType: type) void { + const type_info = @typeInfo(FieldType); + const is_flags = type_info == .@"struct" and type_info.@"struct".layout == .@"packed"; + if (is_flags) { + buffer.print("{s}::NONE", .{comptime ruby_flags_name_from_type(FieldType).?}); + } else { + buffer.print("0", .{}); + } +} + +fn emit_struct_class( + buffer: *Buffer, + comptime Type: type, + comptime ruby_name: []const u8, + comptime read_only: bool, +) void { + assert(@typeInfo(Type) == .@"struct"); + assert(@typeInfo(Type).@"struct".layout == .@"extern"); + + const fields = @typeInfo(Type).@"struct".fields; + + buffer.print(" class {s}\n", .{ruby_name}); + + inline for (fields) |field| { + if (comptime std.mem.eql(u8, field.name, "reserved")) continue; + buffer.print( + " attr_{s} :{s}\n", + .{ if (read_only) "reader" else "accessor", field.name }, + ); + if (comptime read_only and + std.mem.eql(u8, field.name, "status") and + result_status_type(Type) != null) + { + buffer.print(" attr_reader :status_name\n", .{}); + } + } + buffer.print("\n", .{}); + + if (!read_only) { + buffer.print(" def initialize(\n", .{}); + comptime var sep: []const u8 = ""; + inline for (fields) |field| { + if (comptime std.mem.eql(u8, field.name, "reserved")) continue; + if (sep.len > 0) buffer.print("{s}", .{sep}); + buffer.print(" {s}: ", .{field.name}); + emit_field_default(buffer, field.type); + sep = ",\n"; + } + buffer.print("\n )\n", .{}); + inline for (fields) |field| { + if (comptime std.mem.eql(u8, field.name, "reserved")) continue; + buffer.print(" @{s} = {s}\n", .{ field.name, field.name }); + } + buffer.print(" end\n", .{}); + } else { + buffer.print(" def initialize\n", .{}); + inline for (fields) |field| { + if (comptime std.mem.eql(u8, field.name, "reserved")) continue; + buffer.print(" @{s} = 0\n", .{field.name}); + } + buffer.print(" end\n", .{}); + + if (comptime result_status_type(Type) != null) { + buffer.print("\n", .{}); + buffer.print(" def to_s =\n", .{}); + buffer.write(" \"#<#{self.class} timestamp=#{@timestamp} "); + buffer.write("status_name=#{@status_name}>\"\n"); + } + } + buffer.print(" end\n\n", .{}); +} + +fn emit_rbs_constants_module( + buffer: *Buffer, + comptime Type: type, + comptime ruby_name: []const u8, + comptime skip_fields: []const []const u8, +) void { + buffer.print(" module {s}\n", .{ruby_name}); + + switch (@typeInfo(Type)) { + .@"struct" => |info| { + assert(info.layout == .@"packed"); + buffer.print(" NONE: Integer\n", .{}); + inline for (info.fields) |field| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + if (comptime std.mem.eql(u8, field.name, "padding")) continue; + const upper_snake = stdx.to_case(field.name, .UPPER_CASE); + buffer.print(" {s}: Integer\n", .{upper_snake}); + } + }, + .@"enum" => |info| { + inline for (info.fields) |field| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + comptime var skip = false; + inline for (skip_fields) |sf| { + skip = skip or comptime std.mem.eql(u8, sf, field.name); + } + if (skip) continue; + const upper_snake = stdx.to_case(field.name, .UPPER_CASE); + buffer.print(" {s}: Integer\n", .{upper_snake}); + } + }, + else => @compileError("unsupported RBS constants module type: " ++ @typeName(Type)), + } + + buffer.print(" end\n\n", .{}); +} + +fn emit_rbs_status_name_alias( + buffer: *Buffer, + comptime StatusType: type, + comptime alias_name: []const u8, +) void { + assert(@typeInfo(StatusType) == .@"enum"); + + buffer.print(" type {s} =\n", .{alias_name}); + comptime var sep: []const u8 = " "; + inline for (@typeInfo(StatusType).@"enum".fields) |field| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + buffer.print("{s}:{s}\n", .{ sep, field.name }); + sep = " | "; + } + buffer.print("\n", .{}); +} + +fn emit_rbs_struct_class( + buffer: *Buffer, + comptime Type: type, + comptime ruby_name: []const u8, + comptime read_only: bool, +) void { + assert(@typeInfo(Type) == .@"struct"); + assert(@typeInfo(Type).@"struct".layout == .@"extern"); + + const fields = @typeInfo(Type).@"struct".fields; + + buffer.print(" class {s}\n", .{ruby_name}); + + inline for (fields) |field| { + if (comptime std.mem.eql(u8, field.name, "reserved")) continue; + buffer.print( + " attr_{s} {s}: Integer\n", + .{ if (read_only) "reader" else "accessor", field.name }, + ); + if (comptime read_only and std.mem.eql(u8, field.name, "status")) { + if (comptime Type == tb.CreateAccountResult) { + buffer.print(" attr_reader status_name: create_account_status_name\n", .{}); + } else if (comptime Type == tb.CreateTransferResult) { + buffer.print(" attr_reader status_name: create_transfer_status_name\n", .{}); + } + } + } + buffer.print("\n", .{}); + + if (read_only) { + buffer.print(" def initialize: () -> void\n", .{}); + } else { + buffer.print(" def initialize: (", .{}); + comptime var sep: []const u8 = ""; + inline for (fields) |field| { + if (comptime std.mem.eql(u8, field.name, "reserved")) continue; + buffer.print("{s}?{s}: Integer", .{ sep, field.name }); + sep = ", "; + } + buffer.print(") -> void\n", .{}); + } + + buffer.print(" end\n\n", .{}); +} + +fn emit_rbs_bindings(buffer: *Buffer) void { + @setEvalBranchQuota(100_000); + + buffer.write( + \\######################################################## + \\## This file was auto-generated by ruby_bindings.zig ## + \\## Do not manually modify. ## + \\######################################################## + \\ + \\module TigerBeetle + \\ VERSION: String + \\ + \\ def self.id: () -> Integer + \\ + \\ class InitError < StandardError + \\ end + \\ + \\ class ClientClosedError < StandardError + \\ end + \\ + \\ class PacketError < StandardError + \\ end + \\ + \\ class Client + ); + buffer.write("\n def self.open: (cluster_id: Integer, replica_addresses: String)"); + buffer.write(" { (Client) -> untyped } -> untyped\n\n"); + buffer.write( + \\ def initialize: (cluster_id: Integer, replica_addresses: String) -> void + \\ def close: () -> nil + \\ def closed?: () -> bool + \\ + \\ def create_accounts: (Array[Account]) -> Array[CreateAccountResult] + \\ def create_transfers: (Array[Transfer]) -> Array[CreateTransferResult] + \\ def lookup_accounts: (Array[Integer]) -> Array[Account] + \\ def lookup_transfers: (Array[Integer]) -> Array[Transfer] + \\ def get_account_transfers: (AccountFilter) -> Array[Transfer] + \\ def get_account_balances: (AccountFilter) -> Array[AccountBalance] + \\ def query_accounts: (QueryFilter) -> Array[Account] + \\ def query_transfers: (QueryFilter) -> Array[Transfer] + \\ end + \\ + \\ + ); + + inline for (flag_mappings) |mapping| { + const ZigType, const ruby_name = mapping; + emit_rbs_constants_module(buffer, ZigType, ruby_name, &.{}); + } + + emit_rbs_constants_module(buffer, exports.tb_operation, "Operation", &.{ + "reserved", "root", "register", "pulse", "get_change_events", + }); + emit_rbs_constants_module(buffer, tb.CreateAccountStatus, "CreateAccountStatus", &.{}); + emit_rbs_constants_module(buffer, tb.CreateTransferStatus, "CreateTransferStatus", &.{}); + + emit_rbs_struct_class(buffer, tb.Account, "Account", false); + emit_rbs_struct_class(buffer, tb.Transfer, "Transfer", false); + emit_rbs_struct_class(buffer, tb.AccountFilter, "AccountFilter", false); + emit_rbs_struct_class(buffer, tb.QueryFilter, "QueryFilter", false); + + emit_rbs_struct_class(buffer, tb.AccountBalance, "AccountBalance", true); + emit_rbs_status_name_alias( + buffer, + tb.CreateAccountStatus, + "create_account_status_name", + ); + emit_rbs_status_name_alias( + buffer, + tb.CreateTransferStatus, + "create_transfer_status_name", + ); + emit_rbs_struct_class(buffer, tb.CreateAccountResult, "CreateAccountResult", true); + emit_rbs_struct_class(buffer, tb.CreateTransferResult, "CreateTransferResult", true); + + buffer.print("end\n", .{}); +} + +fn emit_ruby_bindings(buffer: *Buffer) void { + @setEvalBranchQuota(100_000); + + buffer.print( + \\######################################################## + \\## This file was auto-generated by ruby_bindings.zig ## + \\## Do not manually modify. ## + \\######################################################## + \\ + \\module TigerBeetle + \\ + , .{}); + + // Flag modules (packed structs). + inline for (flag_mappings) |mapping| { + const ZigType, const ruby_name = mapping; + emit_flags_module(buffer, ZigType, ruby_name); + } + + // Operation constants. + emit_enum_module(buffer, exports.tb_operation, "Operation", &.{ + "reserved", "root", "register", "pulse", "get_change_events", + }); + + // Status constants. + emit_enum_module(buffer, tb.CreateAccountStatus, "CreateAccountStatus", &.{}); + emit_enum_module(buffer, tb.CreateTransferStatus, "CreateTransferStatus", &.{}); + + // Struct classes — input types (read-write, yield self). + emit_struct_class(buffer, tb.Account, "Account", false); + emit_struct_class(buffer, tb.Transfer, "Transfer", false); + emit_struct_class(buffer, tb.AccountFilter, "AccountFilter", false); + emit_struct_class(buffer, tb.QueryFilter, "QueryFilter", false); + + // Struct classes — response-only types (read-only, no yield). + emit_struct_class(buffer, tb.AccountBalance, "AccountBalance", true); + emit_struct_class(buffer, tb.CreateAccountResult, "CreateAccountResult", true); + emit_struct_class(buffer, tb.CreateTransferResult, "CreateTransferResult", true); + + buffer.print("end\n", .{}); +} + +fn emit_c_header_preamble(buffer: *Buffer) void { + buffer.write( + \\//////////////////////////////////////////////////////// + \\// This file was auto-generated by ruby_bindings.zig // + \\// Do not manually modify. // + \\//////////////////////////////////////////////////////// + \\ + \\#ifndef RB_TB_GEN_H + \\#define RB_TB_GEN_H + \\ + \\#include "ruby.h" + \\#include "tb_client.h" + \\#include + \\#include + \\#include + \\#include + \\ + \\static inline void rb_tb_pack_u128(VALUE v, void *dst) { + \\ int status = rb_integer_pack(v, dst, 16, 1, 0, INTEGER_PACK_LITTLE_ENDIAN); + \\ if (status != 0 && status != 1) { + \\ rb_raise(rb_eRangeError, "integer must be between 0 and 2**128 - 1"); + \\ } + \\} + \\ + \\static inline VALUE rb_tb_unpack_u128(const void *src) { + \\ return rb_integer_unpack(src, 16, 1, 0, INTEGER_PACK_LITTLE_ENDIAN); + \\} + \\ + \\static inline void tb_assert_fail( + \\ const char *condition, + \\ int line, + \\ const char *function + \\) { + \\ fprintf(stderr, "tb_assert failed: %s at line %d in %s\n", condition, line, function); + \\ abort(); + \\} + \\ + \\// A version of `assert` macro that's always on regardless of NDEBUG macro. + \\#define tb_assert(condition) \ + \\ do { \ + \\ if (!(condition)) { \ + \\ tb_assert_fail(#condition, __LINE__, __func__); \ + \\ } \ + \\ } while (0) + \\ + ); +} + +fn emit_c_init_error_message(buffer: *Buffer) void { + buffer.print("static const char *rb_tb_init_error_message(TB_INIT_STATUS status) {{\n", .{}); + buffer.print(" switch (status) {{\n", .{}); + inline for (@typeInfo(exports.tb_init_status).@"enum".fields) |field| { + const status: exports.tb_init_status = @enumFromInt(field.value); + buffer.print(" case {s}:\n", .{comptime c_init_status_name(status)}); + buffer.print(" return \"{s}\";\n", .{field.name}); + } + buffer.write( + \\ default: + \\ return "unknown"; + \\ } + \\} + \\ + \\ + ); +} + +fn emit_c_status_name_function( + buffer: *Buffer, + comptime StatusType: type, + comptime function_name: []const u8, +) void { + assert(@typeInfo(StatusType) == .@"enum"); + + buffer.print("static VALUE {s}(uint32_t status) {{\n", .{function_name}); + buffer.print(" switch (status) {{\n", .{}); + inline for (@typeInfo(StatusType).@"enum".fields) |field| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + const value: u64 = @intCast(@intFromEnum(@field(StatusType, field.name))); + buffer.print(" case {d}:\n", .{value}); + buffer.print(" return ID2SYM(rb_intern(\"{s}\"));\n", .{field.name}); + } + buffer.write( + \\ default: + \\ tb_assert(false); + \\ return Qnil; + \\ } + \\} + ); + buffer.write("\n\n"); +} + +fn emit_c_num_from_ruby( + buffer: *Buffer, + comptime FieldType: type, + comptime ruby_value: []const u8, +) void { + const bits = comptime int_bits(FieldType); + switch (bits) { + 8 => buffer.print("RB_NUM2CHR({s})", .{ruby_value}), + 16 => buffer.print("RB_NUM2USHORT({s})", .{ruby_value}), + 32 => buffer.print("RB_NUM2UINT({s})", .{ruby_value}), + 64 => buffer.print("RB_NUM2ULL({s})", .{ruby_value}), + else => @compileError("unsupported Ruby numeric field"), + } +} + +fn emit_c_value_from_field( + buffer: *Buffer, + comptime FieldType: type, + comptime field_expr: []const u8, +) void { + const bits = comptime int_bits(FieldType); + switch (bits) { + 8, 16, 32 => buffer.print("RB_UINT2NUM({s})", .{field_expr}), + 64 => buffer.print("RB_ULL2NUM({s})", .{field_expr}), + 128 => buffer.print("rb_tb_unpack_u128(&{s})", .{field_expr}), + else => @compileError("unsupported C numeric field"), + } +} + +fn emit_c_serialize_struct(buffer: *Buffer, comptime operation: tb.Operation) void { + const Type = operation.EventType(); + const operation_name = comptime operation_function_name(operation); + const c_name = comptime c_type_name(Type); + buffer.print( + "static void rb_tb_serialize_{s}(VALUE items_rb, uint8_t *buf, long count) {{\n", + .{ + operation_name, + }, + ); + buffer.print(" {s} *items = ({s} *)buf;\n", .{ c_name, c_name }); + buffer.print(" for (long i = 0; i < count; i++) {{\n", .{}); + buffer.print(" VALUE item_rb = RARRAY_AREF(items_rb, i);\n", .{}); + buffer.print(" {s} *item = &items[i];\n", .{c_name}); + + inline for (@typeInfo(Type).@"struct".fields) |field| { + if (comptime field_reserved(field.name)) { + switch (@typeInfo(field.type)) { + .array => buffer.print( + " memset(item->{s}, 0, sizeof(item->{s}));\n", + .{ field.name, field.name }, + ), + else => buffer.print(" item->{s} = 0;\n", .{field.name}), + } + continue; + } + const value_expr = "rb_ivar_get(item_rb, rb_intern(\"@" ++ field.name ++ "\"))"; + switch (@typeInfo(field.type)) { + .int => |info| if (info.bits == 128) { + buffer.print( + " rb_tb_pack_u128({s}, &item->{s});\n", + .{ value_expr, field.name }, + ); + } else { + buffer.print(" item->{s} = ", .{field.name}); + emit_c_num_from_ruby(buffer, field.type, value_expr); + buffer.print(";\n", .{}); + }, + .@"enum", .@"struct" => { + buffer.print(" item->{s} = ", .{field.name}); + emit_c_num_from_ruby(buffer, field.type, value_expr); + buffer.print(";\n", .{}); + }, + else => @compileError("unsupported serializer field: " ++ @typeName(field.type)), + } + } + + buffer.print(" }}\n", .{}); + buffer.print("}}\n\n", .{}); +} + +fn emit_c_deserialize_struct(buffer: *Buffer, comptime operation: tb.Operation) void { + const Type = operation.ResultType(); + const operation_name = comptime operation_function_name(operation); + const c_name = comptime c_type_name(Type); + buffer.print( + "static VALUE rb_tb_deserialize_{s}(const uint8_t *buf, uint32_t buf_size) {{\n", + .{operation_name}, + ); + buffer.print(" VALUE klass = rb_path2class(\"TigerBeetle::{s}\");\n", .{ + comptime ruby_type_name(Type), + }); + buffer.print(" tb_assert(buf_size % sizeof({s}) == 0);\n", .{c_name}); + buffer.print(" long count = (long)(buf_size / sizeof({s}));\n", .{c_name}); + buffer.print(" VALUE results = rb_ary_new_capa(count);\n", .{}); + buffer.print(" const {s} *items = (const {s} *)buf;\n", .{ c_name, c_name }); + buffer.print(" for (long i = 0; i < count; i++) {{\n", .{}); + buffer.print(" const {s} *item = &items[i];\n", .{c_name}); + buffer.print(" VALUE obj = rb_obj_alloc(klass);\n", .{}); + + inline for (@typeInfo(Type).@"struct".fields) |field| { + if (comptime field_reserved(field.name)) { + switch (@typeInfo(field.type)) { + .array => buffer.print( + \\ uint8_t zero[sizeof(item->{s})] = {{0}}; + \\ tb_assert(memcmp(item->{s}, zero, sizeof(item->{s})) == 0); + \\ + , + .{ field.name, field.name, field.name }, + ), + else => buffer.print(" tb_assert(item->{s} == 0);\n", .{field.name}), + } + continue; + } + const field_expr = "item->" ++ field.name; + buffer.print(" rb_ivar_set(obj, rb_intern(\"@{s}\"), ", .{field.name}); + emit_c_value_from_field(buffer, field.type, field_expr); + buffer.print(");\n", .{}); + if (comptime std.mem.eql(u8, field.name, "status")) { + if (comptime result_status_type(Type) != null) { + buffer.print( + \\ rb_ivar_set( + \\ obj, + \\ rb_intern("@status_name"), + \\ rb_tb_{s}_status_name(item->status) + \\ ); + \\ + , .{operation_name}); + } + } + } + + buffer.print(" rb_ary_push(results, obj);\n", .{}); + buffer.print(" }}\n", .{}); + buffer.print(" return results;\n", .{}); + buffer.print("}}\n\n", .{}); +} + +fn emit_c_lookup_serializer(buffer: *Buffer) void { + buffer.write( + \\static void rb_tb_serialize_u128(VALUE items_rb, uint8_t *buf, long count) { + \\ tb_uint128_t *ids = (tb_uint128_t *)buf; + \\ for (long i = 0; i < count; i++) { + \\ rb_tb_pack_u128(RARRAY_AREF(items_rb, i), &ids[i]); + \\ } + \\} + \\ + \\ + ); +} + +fn emit_c_event_size(buffer: *Buffer) void { + buffer.print("static size_t rb_tb_event_size(TB_OPERATION operation) {{\n", .{}); + buffer.print(" switch (operation) {{\n", .{}); + inline for (@typeInfo(tb.Operation).@"enum".fields) |operation_field| { + const operation: tb.Operation = @enumFromInt(operation_field.value); + if (comptime !operation_supported(operation)) continue; + const Event = operation.EventType(); + buffer.print(" case {s}:\n", .{comptime c_operation_name(operation)}); + if (Event == u128) { + buffer.print(" return sizeof(tb_uint128_t);\n", .{}); + } else { + buffer.print(" return sizeof({s});\n", .{comptime c_type_name(Event)}); + } + } + buffer.write( + \\ default: + \\ rb_raise(rb_eRuntimeError, "unsupported operation: %d", (int)operation); + \\ return 0; + \\ } + \\} + \\ + \\ + ); +} + +fn emit_c_serialize_dispatch(buffer: *Buffer) void { + buffer.write( + \\static void rb_tb_serialize( + \\ TB_OPERATION operation, + \\ VALUE items_rb, + \\ uint8_t *buf, + \\ long count + \\) { + \\ + ); + buffer.print(" switch (operation) {{\n", .{}); + inline for (@typeInfo(tb.Operation).@"enum".fields) |operation_field| { + const operation: tb.Operation = @enumFromInt(operation_field.value); + if (comptime !operation_supported(operation)) continue; + const Event = operation.EventType(); + buffer.print(" case {s}:\n", .{comptime c_operation_name(operation)}); + if (Event == u128) { + buffer.print(" rb_tb_serialize_u128(items_rb, buf, count);\n", .{}); + } else { + buffer.print(" rb_tb_serialize_{s}(items_rb, buf, count);\n", .{ + comptime operation_function_name(operation), + }); + } + buffer.print(" break;\n", .{}); + } + buffer.write( + \\ default: + \\ rb_raise(rb_eRuntimeError, "unsupported operation: %d", (int)operation); + \\ } + \\} + \\ + \\ + ); +} + +fn emit_c_deserialize_dispatch(buffer: *Buffer) void { + buffer.write( + \\static VALUE rb_tb_deserialize( + \\ TB_OPERATION operation, + \\ const uint8_t *buf, + \\ uint32_t buf_size + \\) { + \\ + ); + buffer.print(" switch (operation) {{\n", .{}); + inline for (@typeInfo(tb.Operation).@"enum".fields) |operation_field| { + const operation: tb.Operation = @enumFromInt(operation_field.value); + if (comptime !operation_supported(operation)) continue; + buffer.print(" case {s}:\n", .{comptime c_operation_name(operation)}); + buffer.print(" return rb_tb_deserialize_{s}(buf, buf_size);\n", .{ + comptime operation_function_name(operation), + }); + } + buffer.write( + \\ default: + \\ rb_raise(rb_eRuntimeError, "unsupported operation: %d", (int)operation); + \\ } + \\} + \\ + ); +} + +fn emit_c_header(buffer: *Buffer) void { + @setEvalBranchQuota(100_000); + + emit_c_header_preamble(buffer); + emit_c_init_error_message(buffer); + emit_c_status_name_function( + buffer, + tb.CreateAccountStatus, + "rb_tb_create_accounts_status_name", + ); + emit_c_status_name_function( + buffer, + tb.CreateTransferStatus, + "rb_tb_create_transfers_status_name", + ); + + inline for (@typeInfo(tb.Operation).@"enum".fields) |operation_field| { + const operation: tb.Operation = @enumFromInt(operation_field.value); + if (comptime !operation_supported(operation)) continue; + if (operation.EventType() != u128) emit_c_serialize_struct(buffer, operation); + emit_c_deserialize_struct(buffer, operation); + } + + emit_c_lookup_serializer(buffer); + emit_c_event_size(buffer); + emit_c_serialize_dispatch(buffer); + emit_c_deserialize_dispatch(buffer); + + buffer.print("#endif\n", .{}); +} + +pub fn main() !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var buffer = Buffer.init(allocator); + + if (comptime std.mem.eql(u8, generator_options.output, "ruby")) { + emit_ruby_bindings(&buffer); + } else if (comptime std.mem.eql(u8, generator_options.output, "rbs")) { + emit_rbs_bindings(&buffer); + } else if (comptime std.mem.eql(u8, generator_options.output, "c_header")) { + emit_c_header(&buffer); + } else { + @compileError("unsupported ruby bindings output mode"); + } + + try std.io.getStdOut().writeAll(buffer.inner.items); +} diff --git a/ocam/src/clients/ruby/samples/basic/README.md b/ocam/src/clients/ruby/samples/basic/README.md new file mode 100644 index 00000000..3bc636ae --- /dev/null +++ b/ocam/src/clients/ruby/samples/basic/README.md @@ -0,0 +1,61 @@ + +# Basic Ruby Sample + +Code for this sample is in [./main.rb](./main.rb). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Ruby >= `3.3` + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/ruby/samples/basic`. + +Then, install the TigerBeetle client: + +```console +gem install tigerbeetle +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +ruby main.rb +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create a transfer + +Then it transfers `10` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate account balances + +Then it fetches both accounts, checks they both exist, and +checks that **account `1`** has: + * `debits_posted = 10` + * and `credits_posted = 0` + +And that **account `2`** has: + * `debits_posted= 0` + * and `credits_posted = 10` diff --git a/ocam/src/clients/ruby/samples/basic/main.rb b/ocam/src/clients/ruby/samples/basic/main.rb new file mode 100644 index 00000000..1496994f --- /dev/null +++ b/ocam/src/clients/ruby/samples/basic/main.rb @@ -0,0 +1,56 @@ +require "tigerbeetle" + +replica_addresses = ENV.fetch("TB_ADDRESS", "3000") + +TigerBeetle::Client.open(cluster_id: 0, replica_addresses:) do |client| + account_results = client.create_accounts( + [ + TigerBeetle::Account.new(id: 1, ledger: 1, code: 1), + TigerBeetle::Account.new(id: 2, ledger: 1, code: 1) + ] + ) + + raise "expected 2 account results" unless account_results.length == 2 + unless account_results[0].status == TigerBeetle::CreateAccountStatus::CREATED + raise "account 1 was not created" + end + unless account_results[1].status == TigerBeetle::CreateAccountStatus::CREATED + raise "account 2 was not created" + end + + transfer_results = client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: 1, + debit_account_id: 1, + credit_account_id: 2, + amount: 10, + ledger: 1, + code: 1 + ) + ] + ) + + raise "expected 1 transfer result" unless transfer_results.length == 1 + unless transfer_results[0].status == TigerBeetle::CreateTransferStatus::CREATED + raise "transfer was not created" + end + + accounts = client.lookup_accounts([1, 2]) + raise "expected 2 accounts" unless accounts.length == 2 + + accounts.each do |account| + case account.id + when 1 + raise "account 1 debits_posted mismatch" unless account.debits_posted == 10 + raise "account 1 credits_posted mismatch" unless account.credits_posted == 0 + when 2 + raise "account 2 debits_posted mismatch" unless account.debits_posted == 0 + raise "account 2 credits_posted mismatch" unless account.credits_posted == 10 + else + raise "unexpected account: #{account.inspect}" + end + end + + puts("ok") +end diff --git a/ocam/src/clients/ruby/samples/two-phase-many/README.md b/ocam/src/clients/ruby/samples/two-phase-many/README.md new file mode 100644 index 00000000..c944fb13 --- /dev/null +++ b/ocam/src/clients/ruby/samples/two-phase-many/README.md @@ -0,0 +1,91 @@ + +# Many Two-Phase Transfers Ruby Sample + +Code for this sample is in [./main.rb](./main.rb). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Ruby >= `3.3` + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/ruby/samples/two-phase-many`. + +Then, install the TigerBeetle client: + +```console +gem install tigerbeetle +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +ruby main.rb +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfers + +Then it begins 5 pending transfers of amounts `100` to +`500`, incrementing by `100` for each transfer. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 1500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 1500` + +(This is because a pending transfer only affects **pending** +credits and debits on accounts, not **posted** credits and +debits.) + +## 4. Post and void alternating transfers + +Then it alternatively posts and voids each transfer, +checking account balances after each transfer. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +solely *posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 900` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 900` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/ruby/samples/two-phase-many/main.rb b/ocam/src/clients/ruby/samples/two-phase-many/main.rb new file mode 100644 index 00000000..029372ca --- /dev/null +++ b/ocam/src/clients/ruby/samples/two-phase-many/main.rb @@ -0,0 +1,123 @@ +require "tigerbeetle" + +def assert_accounts(accounts, expected) + raise "expected #{expected.length} accounts" unless accounts.length == expected.length + + accounts.each do |account| + values = expected.fetch(account.id) { raise "unexpected account: #{account.inspect}" } + unless account.debits_posted == values.fetch(:debits_posted) + raise "account #{account.id} debits_posted mismatch" + end + + unless account.credits_posted == values.fetch(:credits_posted) + raise "account #{account.id} credits_posted mismatch" + end + + unless account.debits_pending == values.fetch(:debits_pending) + raise "account #{account.id} debits_pending mismatch" + end + + unless account.credits_pending == values.fetch(:credits_pending) + raise "account #{account.id} credits_pending mismatch" + end + end +end + +replica_addresses = ENV.fetch("TB_ADDRESS", "3000") + +TigerBeetle::Client.open(cluster_id: 0, replica_addresses:) do |client| + account_results = client.create_accounts( + [ + TigerBeetle::Account.new(id: 1, ledger: 1, code: 1), + TigerBeetle::Account.new(id: 2, ledger: 1, code: 1) + ] + ) + + raise "expected 2 account results" unless account_results.length == 2 + account_results.each.with_index(1) do |result, index| + unless result.status == TigerBeetle::CreateAccountStatus::CREATED + raise "account #{index} was not created" + end + end + + transfers = (1..5).map do |id| + TigerBeetle::Transfer.new( + id: id, + debit_account_id: 1, + credit_account_id: 2, + amount: id * 100, + ledger: 1, + code: 1, + flags: TigerBeetle::TransferFlags::PENDING + ) + end + + transfer_results = client.create_transfers(transfers) + unless transfer_results.length == transfers.length + raise "expected #{transfers.length} pending transfer results" + end + + transfer_results.each do |result| + unless result.status == TigerBeetle::CreateTransferStatus::CREATED + raise "pending transfer was not created" + end + end + + assert_accounts( + client.lookup_accounts([1, 2]), + { + 1 => { + debits_posted: 0, + credits_posted: 0, + debits_pending: 1500, + credits_pending: 0 + }, + 2 => {debits_posted: 0, credits_posted: 0, debits_pending: 0, credits_pending: 1500} + } + ) + + operations = [ + [6, 1, 100, TigerBeetle::TransferFlags::POST_PENDING_TRANSFER, 100, 1400], + [7, 2, 200, TigerBeetle::TransferFlags::VOID_PENDING_TRANSFER, 100, 1200], + [8, 3, 300, TigerBeetle::TransferFlags::POST_PENDING_TRANSFER, 400, 900], + [9, 4, 400, TigerBeetle::TransferFlags::VOID_PENDING_TRANSFER, 400, 500], + [10, 5, 500, TigerBeetle::TransferFlags::POST_PENDING_TRANSFER, 900, 0] + ] + + operations.each do |id, pending_id, amount, flags, posted, pending| + transfer_results = client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: id, + debit_account_id: 1, + credit_account_id: 2, + amount: amount, + pending_id: pending_id, + ledger: 1, + code: 1, + flags: flags + ) + ] + ) + + raise "expected 1 finishing transfer result" unless transfer_results.length == 1 + unless transfer_results[0].status == TigerBeetle::CreateTransferStatus::CREATED + raise "finishing transfer #{id} was not created" + end + + assert_accounts( + client.lookup_accounts([1, 2]), + { + 1 => { + debits_posted: posted, + credits_posted: 0, + debits_pending: pending, + credits_pending: 0 + }, + 2 => {debits_posted: 0, credits_posted: posted, debits_pending: 0, credits_pending: pending} + } + ) + end + + puts("ok") +end diff --git a/ocam/src/clients/ruby/samples/two-phase/README.md b/ocam/src/clients/ruby/samples/two-phase/README.md new file mode 100644 index 00000000..cce89725 --- /dev/null +++ b/ocam/src/clients/ruby/samples/two-phase/README.md @@ -0,0 +1,100 @@ + +# Two-Phase Transfer Ruby Sample + +Code for this sample is in [./main.rb](./main.rb). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Ruby >= `3.3` + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/ruby/samples/two-phase`. + +Then, install the TigerBeetle client: + +```console +gem install tigerbeetle +``` + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +ruby main.rb +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfer + +Then it begins a +pending transfer of `500` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 500` + +(This is because a pending +transfer only affects **pending** credits and debits on accounts, +not **posted** credits and debits.) + +## 4. Post pending transfer + +Then it creates a second transfer that marks the first +transfer as posted. + +## 5. Fetch and validate transfers + +Then it fetches both transfers, validates +that the two transfers exist, validates that the first +transfer had (and still has) a `pending` flag, and validates +that the second transfer had (and still has) a +`post_pending_transfer` flag. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +*posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 500` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 500` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/ruby/samples/two-phase/main.rb b/ocam/src/clients/ruby/samples/two-phase/main.rb new file mode 100644 index 00000000..7727507f --- /dev/null +++ b/ocam/src/clients/ruby/samples/two-phase/main.rb @@ -0,0 +1,123 @@ +require "tigerbeetle" + +replica_addresses = ENV.fetch("TB_ADDRESS", "3000") + +TigerBeetle::Client.open(cluster_id: 0, replica_addresses:) do |client| + account_results = client.create_accounts( + [ + TigerBeetle::Account.new(id: 1, ledger: 1, code: 1), + TigerBeetle::Account.new(id: 2, ledger: 1, code: 1) + ] + ) + + raise "expected 2 account results" unless account_results.length == 2 + unless account_results[0].status == TigerBeetle::CreateAccountStatus::CREATED + raise "account 1 was not created" + end + + unless account_results[1].status == TigerBeetle::CreateAccountStatus::CREATED + raise "account 2 was not created" + end + + transfer_results = client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: 1, + debit_account_id: 1, + credit_account_id: 2, + amount: 500, + ledger: 1, + code: 1, + flags: TigerBeetle::TransferFlags::PENDING + ) + ] + ) + + raise "expected 1 pending transfer result" unless transfer_results.length == 1 + raise "pending transfer timestamp was not set" unless transfer_results[0].timestamp.positive? + unless transfer_results[0].status == TigerBeetle::CreateTransferStatus::CREATED + raise "pending transfer was not created" + end + + accounts = client.lookup_accounts([1, 2]) + raise "expected 2 accounts before posting" unless accounts.length == 2 + accounts.each do |account| + case account.id + when 1 + raise "account 1 debits_posted before posting mismatch" unless account.debits_posted == 0 + raise "account 1 credits_posted before posting mismatch" unless account.credits_posted == 0 + raise "account 1 debits_pending before posting mismatch" unless account.debits_pending == 500 + raise "account 1 credits_pending before posting mismatch" unless account.credits_pending == 0 + when 2 + raise "account 2 debits_posted before posting mismatch" unless account.debits_posted == 0 + raise "account 2 credits_posted before posting mismatch" unless account.credits_posted == 0 + raise "account 2 debits_pending before posting mismatch" unless account.debits_pending == 0 + unless account.credits_pending == 500 + raise "account 2 credits_pending before posting mismatch" + end + else + raise "unexpected account: #{account.inspect}" + end + end + + transfer_results = client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: 2, + debit_account_id: 1, + credit_account_id: 2, + amount: 500, + pending_id: 1, + ledger: 1, + code: 1, + flags: TigerBeetle::TransferFlags::POST_PENDING_TRANSFER + ) + ] + ) + + raise "expected 1 post-pending transfer result" unless transfer_results.length == 1 + unless transfer_results[0].status == TigerBeetle::CreateTransferStatus::CREATED + raise "post-pending transfer was not created" + end + + transfers = client.lookup_transfers([1, 2]) + raise "expected 2 transfers" unless transfers.length == 2 + transfers.each do |transfer| + case transfer.id + when 1 + pending = TigerBeetle::TransferFlags::PENDING + unless (transfer.flags & pending) == pending + raise "transfer 1 was not pending" + end + + when 2 + post_pending = TigerBeetle::TransferFlags::POST_PENDING_TRANSFER + unless (transfer.flags & post_pending) == post_pending + raise "transfer 2 was not post-pending" + end + else + raise "unexpected transfer: #{transfer.inspect}" + end + end + + accounts = client.lookup_accounts([1, 2]) + raise "expected 2 accounts after posting" unless accounts.length == 2 + accounts.each do |account| + case account.id + when 1 + raise "account 1 debits_posted after posting mismatch" unless account.debits_posted == 500 + raise "account 1 credits_posted after posting mismatch" unless account.credits_posted == 0 + raise "account 1 debits_pending after posting mismatch" unless account.debits_pending == 0 + raise "account 1 credits_pending after posting mismatch" unless account.credits_pending == 0 + when 2 + raise "account 2 debits_posted after posting mismatch" unless account.debits_posted == 0 + raise "account 2 credits_posted after posting mismatch" unless account.credits_posted == 500 + raise "account 2 debits_pending after posting mismatch" unless account.debits_pending == 0 + raise "account 2 credits_pending after posting mismatch" unless account.credits_pending == 0 + else + raise "unexpected account: #{account.inspect}" + end + end + + puts("ok") +end diff --git a/ocam/src/clients/ruby/samples/walkthrough/main.rb b/ocam/src/clients/ruby/samples/walkthrough/main.rb new file mode 100644 index 00000000..5359a7ce --- /dev/null +++ b/ocam/src/clients/ruby/samples/walkthrough/main.rb @@ -0,0 +1,392 @@ +def assert_created(results, status_constant) + results.each_with_index do |result, index| + next if result.status == status_constant + + raise "event #{index} failed with status #{result.status}" + end +end + +# section:imports +require "tigerbeetle" + +puts("Import OK!") +# endsection:imports + +# section:client +replica_addresses = ENV.fetch("TB_ADDRESS", "3000") + +TigerBeetle::Client.open(cluster_id: 0, replica_addresses:) do |client| + # Use the client. +end +# endsection:client + +TigerBeetle::Client.open(cluster_id: 0, replica_addresses:) do |client| + # section:create-accounts + account = TigerBeetle::Account.new( + id: TigerBeetle.id, + debits_pending: 0, + debits_posted: 0, + credits_pending: 0, + credits_posted: 0, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + ledger: 1, + code: 718, + flags: TigerBeetle::AccountFlags::NONE, + timestamp: 0 + ) + + account_results = client.create_accounts([account]) + # Results handling omitted. + # endsection:create-accounts + assert_created(account_results, TigerBeetle::CreateAccountStatus::CREATED) + + # section:account-flags + account0 = TigerBeetle::Account.new( + id: TigerBeetle.id, + ledger: 1, + code: 1, + flags: TigerBeetle::AccountFlags::LINKED | + TigerBeetle::AccountFlags::DEBITS_MUST_NOT_EXCEED_CREDITS + ) + account1 = TigerBeetle::Account.new( + id: TigerBeetle.id, + ledger: 1, + code: 1, + flags: TigerBeetle::AccountFlags::HISTORY + ) + + account_results = client.create_accounts([account0, account1]) + # Results handling omitted. + # endsection:account-flags + assert_created(account_results, TigerBeetle::CreateAccountStatus::CREATED) + + # section:create-accounts-errors + accounts = [ + TigerBeetle::Account.new(id: TigerBeetle.id, ledger: 1, code: 1), + TigerBeetle::Account.new(id: TigerBeetle.id, ledger: 1, code: 1), + TigerBeetle::Account.new(id: TigerBeetle.id, ledger: 1, code: 1) + ] + + account_results = client.create_accounts(accounts) + account_results.each_with_index do |result, index| + case result.status + when TigerBeetle::CreateAccountStatus::CREATED + puts("Batch account at #{index} successfully created with timestamp #{result.timestamp}.") + when TigerBeetle::CreateAccountStatus::EXISTS + puts("Batch account at #{index} already exists with timestamp #{result.timestamp}.") + else + puts("Batch account at #{index} failed to create: #{result.status}.") + end + end + # endsection:create-accounts-errors + + transfer_debit_account_id = accounts[0].id + transfer_credit_account_id = account1.id + + # section:lookup-accounts + accounts = client.lookup_accounts([account0.id, account1.id]) + # endsection:lookup-accounts + raise "expected 2 accounts" unless accounts.length == 2 + + # section:create-transfers + transfers = [ + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + pending_id: 0, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + timeout: 0, + ledger: 1, + code: 720, + flags: TigerBeetle::TransferFlags::NONE, + timestamp: 0 + ) + ] + + transfer_results = client.create_transfers(transfers) + # Results handling omitted. + # endsection:create-transfers + assert_created(transfer_results, TigerBeetle::CreateTransferStatus::CREATED) + + # section:create-transfers-errors + batch = [ + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720 + ), + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720 + ), + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720 + ) + ] + + transfer_results = client.create_transfers(batch) + transfer_results.each_with_index do |result, index| + case result.status + when TigerBeetle::CreateTransferStatus::CREATED + puts("Batch transfer at #{index} successfully created with timestamp #{result.timestamp}.") + when TigerBeetle::CreateTransferStatus::EXISTS + puts("Batch transfer at #{index} already exists with timestamp #{result.timestamp}.") + else + puts("Batch transfer at #{index} failed to create: #{result.status}.") + end + end + # endsection:create-transfers-errors + + # section:batch + # Array of transfers to create. + batch = [] + batch.each_slice(8189) do |slice| + transfer_results = client.create_transfers(slice) + # Results handling omitted. + end + # endsection:batch + + # section:transfer-flags-link + transfer0 = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720, + flags: TigerBeetle::TransferFlags::LINKED + ) + transfer1 = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720 + ) + + transfer_results = client.create_transfers([transfer0, transfer1]) + # Results handling omitted. + # endsection:transfer-flags-link + assert_created(transfer_results, TigerBeetle::CreateTransferStatus::CREATED) + + # section:transfer-flags-post + pending_transfer = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720, + flags: TigerBeetle::TransferFlags::PENDING + ) + + transfer_results = client.create_transfers([pending_transfer]) + # Results handling omitted. + + post_transfer = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + pending_id: pending_transfer.id, + ledger: 1, + code: 720, + flags: TigerBeetle::TransferFlags::POST_PENDING_TRANSFER + ) + + transfer_results = client.create_transfers([post_transfer]) + # Results handling omitted. + # endsection:transfer-flags-post + assert_created(transfer_results, TigerBeetle::CreateTransferStatus::CREATED) + + # section:transfer-flags-void + pending_transfer = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 10, + ledger: 1, + code: 720, + flags: TigerBeetle::TransferFlags::PENDING + ) + + transfer_results = client.create_transfers([pending_transfer]) + # Results handling omitted. + + void_transfer = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 0, + pending_id: pending_transfer.id, + ledger: 1, + code: 720, + flags: TigerBeetle::TransferFlags::VOID_PENDING_TRANSFER + ) + + transfer_results = client.create_transfers([void_transfer]) + # Results handling omitted. + # endsection:transfer-flags-void + assert_created(transfer_results, TigerBeetle::CreateTransferStatus::CREATED) + + # section:lookup-transfers + transfers = client.lookup_transfers([transfer0.id, transfer1.id]) + # endsection:lookup-transfers + raise "expected 2 transfers" unless transfers.length == 2 + + # section:get-account-transfers + filter = TigerBeetle::AccountFilter.new( + account_id: account1.id, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + code: 0, + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: TigerBeetle::AccountFilterFlags::DEBITS | + TigerBeetle::AccountFilterFlags::CREDITS | + TigerBeetle::AccountFilterFlags::REVERSED + ) + + account_transfers = client.get_account_transfers(filter) + # endsection:get-account-transfers + raise "expected account transfers" if account_transfers.empty? + + # section:get-account-balances + filter = TigerBeetle::AccountFilter.new( + account_id: account1.id, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + code: 0, + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: TigerBeetle::AccountFilterFlags::DEBITS | + TigerBeetle::AccountFilterFlags::CREDITS | + TigerBeetle::AccountFilterFlags::REVERSED + ) + + account_balances = client.get_account_balances(filter) + # endsection:get-account-balances + raise "expected account balances" if account_balances.empty? + + # section:query-accounts + query_filter = TigerBeetle::QueryFilter.new( + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + ledger: 1, + code: 1, + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: TigerBeetle::QueryFilterFlags::REVERSED + ) + + query_accounts = client.query_accounts(query_filter) + # endsection:query-accounts + raise "expected query accounts" if query_accounts.empty? + + # section:query-transfers + query_filter = TigerBeetle::QueryFilter.new( + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + ledger: 1, + code: 720, + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: TigerBeetle::QueryFilterFlags::REVERSED + ) + + query_transfers = client.query_transfers(query_filter) + # endsection:query-transfers + raise "expected query transfers" if query_transfers.empty? + + # section:linked-events + linked_flag = TigerBeetle::TransferFlags::LINKED + batch = [ + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 1, + ledger: 1, + code: 720, + flags: linked_flag + ), + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: transfer_debit_account_id, + credit_account_id: transfer_credit_account_id, + amount: 1, + ledger: 1, + code: 720 + ) + ] + + transfer_results = client.create_transfers(batch) + # Results handling omitted. + # endsection:linked-events + assert_created(transfer_results, TigerBeetle::CreateTransferStatus::CREATED) + + # section:imported-events + historical_timestamp = 0 + # Loaded from an external source. + historical_accounts = [] + # Loaded from an external source. + historical_transfers = [] + + accounts_to_import = historical_accounts.map.with_index do |historical_account, index| + historical_timestamp += 1 + historical_account.timestamp = historical_timestamp + historical_account.flags = TigerBeetle::AccountFlags::IMPORTED + if index < historical_accounts.length - 1 + historical_account.flags |= TigerBeetle::AccountFlags::LINKED + end + + historical_account + end + + account_results = client.create_accounts(accounts_to_import) + # Results handling omitted. + + transfers_to_import = historical_transfers.map.with_index do |historical_transfer, index| + historical_timestamp += 1 + historical_transfer.timestamp = historical_timestamp + historical_transfer.flags = TigerBeetle::TransferFlags::IMPORTED + if index < historical_transfers.length - 1 + historical_transfer.flags |= TigerBeetle::TransferFlags::LINKED + end + + historical_transfer + end + + transfer_results = client.create_transfers(transfers_to_import) + # Results handling omitted. + # endsection:imported-events + + puts("ok") +end diff --git a/ocam/src/clients/ruby/sig/tigerbeetle.rbs b/ocam/src/clients/ruby/sig/tigerbeetle.rbs new file mode 100644 index 00000000..6905efe6 --- /dev/null +++ b/ocam/src/clients/ruby/sig/tigerbeetle.rbs @@ -0,0 +1,372 @@ +######################################################## +## This file was auto-generated by ruby_bindings.zig ## +## Do not manually modify. ## +######################################################## + +module TigerBeetle + VERSION: String + + def self.id: () -> Integer + + class InitError < StandardError + end + + class ClientClosedError < StandardError + end + + class PacketError < StandardError + end + + class Client + def self.open: (cluster_id: Integer, replica_addresses: String) { (Client) -> untyped } -> untyped + + def initialize: (cluster_id: Integer, replica_addresses: String) -> void + def close: () -> nil + def closed?: () -> bool + + def create_accounts: (Array[Account]) -> Array[CreateAccountResult] + def create_transfers: (Array[Transfer]) -> Array[CreateTransferResult] + def lookup_accounts: (Array[Integer]) -> Array[Account] + def lookup_transfers: (Array[Integer]) -> Array[Transfer] + def get_account_transfers: (AccountFilter) -> Array[Transfer] + def get_account_balances: (AccountFilter) -> Array[AccountBalance] + def query_accounts: (QueryFilter) -> Array[Account] + def query_transfers: (QueryFilter) -> Array[Transfer] + end + + module AccountFlags + NONE: Integer + LINKED: Integer + DEBITS_MUST_NOT_EXCEED_CREDITS: Integer + CREDITS_MUST_NOT_EXCEED_DEBITS: Integer + HISTORY: Integer + IMPORTED: Integer + CLOSED: Integer + end + + module TransferFlags + NONE: Integer + LINKED: Integer + PENDING: Integer + POST_PENDING_TRANSFER: Integer + VOID_PENDING_TRANSFER: Integer + BALANCING_DEBIT: Integer + BALANCING_CREDIT: Integer + CLOSING_DEBIT: Integer + CLOSING_CREDIT: Integer + IMPORTED: Integer + end + + module AccountFilterFlags + NONE: Integer + DEBITS: Integer + CREDITS: Integer + REVERSED: Integer + end + + module QueryFilterFlags + NONE: Integer + REVERSED: Integer + end + + module Operation + LOOKUP_ACCOUNTS: Integer + LOOKUP_TRANSFERS: Integer + GET_ACCOUNT_TRANSFERS: Integer + GET_ACCOUNT_BALANCES: Integer + QUERY_ACCOUNTS: Integer + QUERY_TRANSFERS: Integer + CREATE_ACCOUNTS: Integer + CREATE_TRANSFERS: Integer + end + + module CreateAccountStatus + CREATED: Integer + LINKED_EVENT_FAILED: Integer + LINKED_EVENT_CHAIN_OPEN: Integer + IMPORTED_EVENT_EXPECTED: Integer + IMPORTED_EVENT_NOT_EXPECTED: Integer + TIMESTAMP_MUST_BE_ZERO: Integer + IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE: Integer + IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE: Integer + RESERVED_FIELD: Integer + RESERVED_FLAG: Integer + ID_MUST_NOT_BE_ZERO: Integer + ID_MUST_NOT_BE_INT_MAX: Integer + EXISTS_WITH_DIFFERENT_FLAGS: Integer + EXISTS_WITH_DIFFERENT_USER_DATA_128: Integer + EXISTS_WITH_DIFFERENT_USER_DATA_64: Integer + EXISTS_WITH_DIFFERENT_USER_DATA_32: Integer + EXISTS_WITH_DIFFERENT_LEDGER: Integer + EXISTS_WITH_DIFFERENT_CODE: Integer + EXISTS: Integer + FLAGS_ARE_MUTUALLY_EXCLUSIVE: Integer + DEBITS_PENDING_MUST_BE_ZERO: Integer + DEBITS_POSTED_MUST_BE_ZERO: Integer + CREDITS_PENDING_MUST_BE_ZERO: Integer + CREDITS_POSTED_MUST_BE_ZERO: Integer + LEDGER_MUST_NOT_BE_ZERO: Integer + CODE_MUST_NOT_BE_ZERO: Integer + IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS: Integer + end + + module CreateTransferStatus + CREATED: Integer + LINKED_EVENT_FAILED: Integer + LINKED_EVENT_CHAIN_OPEN: Integer + IMPORTED_EVENT_EXPECTED: Integer + IMPORTED_EVENT_NOT_EXPECTED: Integer + TIMESTAMP_MUST_BE_ZERO: Integer + IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE: Integer + IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE: Integer + RESERVED_FLAG: Integer + ID_MUST_NOT_BE_ZERO: Integer + ID_MUST_NOT_BE_INT_MAX: Integer + EXISTS_WITH_DIFFERENT_FLAGS: Integer + EXISTS_WITH_DIFFERENT_PENDING_ID: Integer + EXISTS_WITH_DIFFERENT_TIMEOUT: Integer + EXISTS_WITH_DIFFERENT_DEBIT_ACCOUNT_ID: Integer + EXISTS_WITH_DIFFERENT_CREDIT_ACCOUNT_ID: Integer + EXISTS_WITH_DIFFERENT_AMOUNT: Integer + EXISTS_WITH_DIFFERENT_USER_DATA_128: Integer + EXISTS_WITH_DIFFERENT_USER_DATA_64: Integer + EXISTS_WITH_DIFFERENT_USER_DATA_32: Integer + EXISTS_WITH_DIFFERENT_LEDGER: Integer + EXISTS_WITH_DIFFERENT_CODE: Integer + EXISTS: Integer + ID_ALREADY_FAILED: Integer + FLAGS_ARE_MUTUALLY_EXCLUSIVE: Integer + DEBIT_ACCOUNT_ID_MUST_NOT_BE_ZERO: Integer + DEBIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX: Integer + CREDIT_ACCOUNT_ID_MUST_NOT_BE_ZERO: Integer + CREDIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX: Integer + ACCOUNTS_MUST_BE_DIFFERENT: Integer + PENDING_ID_MUST_BE_ZERO: Integer + PENDING_ID_MUST_NOT_BE_ZERO: Integer + PENDING_ID_MUST_NOT_BE_INT_MAX: Integer + PENDING_ID_MUST_BE_DIFFERENT: Integer + TIMEOUT_RESERVED_FOR_PENDING_TRANSFER: Integer + CLOSING_TRANSFER_MUST_BE_PENDING: Integer + LEDGER_MUST_NOT_BE_ZERO: Integer + CODE_MUST_NOT_BE_ZERO: Integer + DEBIT_ACCOUNT_NOT_FOUND: Integer + CREDIT_ACCOUNT_NOT_FOUND: Integer + ACCOUNTS_MUST_HAVE_THE_SAME_LEDGER: Integer + TRANSFER_MUST_HAVE_THE_SAME_LEDGER_AS_ACCOUNTS: Integer + PENDING_TRANSFER_NOT_FOUND: Integer + PENDING_TRANSFER_NOT_PENDING: Integer + PENDING_TRANSFER_HAS_DIFFERENT_DEBIT_ACCOUNT_ID: Integer + PENDING_TRANSFER_HAS_DIFFERENT_CREDIT_ACCOUNT_ID: Integer + PENDING_TRANSFER_HAS_DIFFERENT_LEDGER: Integer + PENDING_TRANSFER_HAS_DIFFERENT_CODE: Integer + EXCEEDS_PENDING_TRANSFER_AMOUNT: Integer + PENDING_TRANSFER_HAS_DIFFERENT_AMOUNT: Integer + PENDING_TRANSFER_ALREADY_POSTED: Integer + PENDING_TRANSFER_ALREADY_VOIDED: Integer + PENDING_TRANSFER_EXPIRED: Integer + IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS: Integer + IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_DEBIT_ACCOUNT: Integer + IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_CREDIT_ACCOUNT: Integer + IMPORTED_EVENT_TIMEOUT_MUST_BE_ZERO: Integer + DEBIT_ACCOUNT_ALREADY_CLOSED: Integer + CREDIT_ACCOUNT_ALREADY_CLOSED: Integer + OVERFLOWS_DEBITS_PENDING: Integer + OVERFLOWS_CREDITS_PENDING: Integer + OVERFLOWS_DEBITS_POSTED: Integer + OVERFLOWS_CREDITS_POSTED: Integer + OVERFLOWS_DEBITS: Integer + OVERFLOWS_CREDITS: Integer + OVERFLOWS_TIMEOUT: Integer + EXCEEDS_CREDITS: Integer + EXCEEDS_DEBITS: Integer + end + + class Account + attr_accessor id: Integer + attr_accessor debits_pending: Integer + attr_accessor debits_posted: Integer + attr_accessor credits_pending: Integer + attr_accessor credits_posted: Integer + attr_accessor user_data_128: Integer + attr_accessor user_data_64: Integer + attr_accessor user_data_32: Integer + attr_accessor ledger: Integer + attr_accessor code: Integer + attr_accessor flags: Integer + attr_accessor timestamp: Integer + + def initialize: (?id: Integer, ?debits_pending: Integer, ?debits_posted: Integer, ?credits_pending: Integer, ?credits_posted: Integer, ?user_data_128: Integer, ?user_data_64: Integer, ?user_data_32: Integer, ?ledger: Integer, ?code: Integer, ?flags: Integer, ?timestamp: Integer) -> void + end + + class Transfer + attr_accessor id: Integer + attr_accessor debit_account_id: Integer + attr_accessor credit_account_id: Integer + attr_accessor amount: Integer + attr_accessor pending_id: Integer + attr_accessor user_data_128: Integer + attr_accessor user_data_64: Integer + attr_accessor user_data_32: Integer + attr_accessor timeout: Integer + attr_accessor ledger: Integer + attr_accessor code: Integer + attr_accessor flags: Integer + attr_accessor timestamp: Integer + + def initialize: (?id: Integer, ?debit_account_id: Integer, ?credit_account_id: Integer, ?amount: Integer, ?pending_id: Integer, ?user_data_128: Integer, ?user_data_64: Integer, ?user_data_32: Integer, ?timeout: Integer, ?ledger: Integer, ?code: Integer, ?flags: Integer, ?timestamp: Integer) -> void + end + + class AccountFilter + attr_accessor account_id: Integer + attr_accessor user_data_128: Integer + attr_accessor user_data_64: Integer + attr_accessor user_data_32: Integer + attr_accessor code: Integer + attr_accessor timestamp_min: Integer + attr_accessor timestamp_max: Integer + attr_accessor limit: Integer + attr_accessor flags: Integer + + def initialize: (?account_id: Integer, ?user_data_128: Integer, ?user_data_64: Integer, ?user_data_32: Integer, ?code: Integer, ?timestamp_min: Integer, ?timestamp_max: Integer, ?limit: Integer, ?flags: Integer) -> void + end + + class QueryFilter + attr_accessor user_data_128: Integer + attr_accessor user_data_64: Integer + attr_accessor user_data_32: Integer + attr_accessor ledger: Integer + attr_accessor code: Integer + attr_accessor timestamp_min: Integer + attr_accessor timestamp_max: Integer + attr_accessor limit: Integer + attr_accessor flags: Integer + + def initialize: (?user_data_128: Integer, ?user_data_64: Integer, ?user_data_32: Integer, ?ledger: Integer, ?code: Integer, ?timestamp_min: Integer, ?timestamp_max: Integer, ?limit: Integer, ?flags: Integer) -> void + end + + class AccountBalance + attr_reader debits_pending: Integer + attr_reader debits_posted: Integer + attr_reader credits_pending: Integer + attr_reader credits_posted: Integer + attr_reader timestamp: Integer + + def initialize: () -> void + end + + type create_account_status_name = + :created + | :linked_event_failed + | :linked_event_chain_open + | :imported_event_expected + | :imported_event_not_expected + | :timestamp_must_be_zero + | :imported_event_timestamp_out_of_range + | :imported_event_timestamp_must_not_advance + | :reserved_field + | :reserved_flag + | :id_must_not_be_zero + | :id_must_not_be_int_max + | :exists_with_different_flags + | :exists_with_different_user_data_128 + | :exists_with_different_user_data_64 + | :exists_with_different_user_data_32 + | :exists_with_different_ledger + | :exists_with_different_code + | :exists + | :flags_are_mutually_exclusive + | :debits_pending_must_be_zero + | :debits_posted_must_be_zero + | :credits_pending_must_be_zero + | :credits_posted_must_be_zero + | :ledger_must_not_be_zero + | :code_must_not_be_zero + | :imported_event_timestamp_must_not_regress + + type create_transfer_status_name = + :created + | :linked_event_failed + | :linked_event_chain_open + | :imported_event_expected + | :imported_event_not_expected + | :timestamp_must_be_zero + | :imported_event_timestamp_out_of_range + | :imported_event_timestamp_must_not_advance + | :reserved_flag + | :id_must_not_be_zero + | :id_must_not_be_int_max + | :exists_with_different_flags + | :exists_with_different_pending_id + | :exists_with_different_timeout + | :exists_with_different_debit_account_id + | :exists_with_different_credit_account_id + | :exists_with_different_amount + | :exists_with_different_user_data_128 + | :exists_with_different_user_data_64 + | :exists_with_different_user_data_32 + | :exists_with_different_ledger + | :exists_with_different_code + | :exists + | :id_already_failed + | :flags_are_mutually_exclusive + | :debit_account_id_must_not_be_zero + | :debit_account_id_must_not_be_int_max + | :credit_account_id_must_not_be_zero + | :credit_account_id_must_not_be_int_max + | :accounts_must_be_different + | :pending_id_must_be_zero + | :pending_id_must_not_be_zero + | :pending_id_must_not_be_int_max + | :pending_id_must_be_different + | :timeout_reserved_for_pending_transfer + | :closing_transfer_must_be_pending + | :ledger_must_not_be_zero + | :code_must_not_be_zero + | :debit_account_not_found + | :credit_account_not_found + | :accounts_must_have_the_same_ledger + | :transfer_must_have_the_same_ledger_as_accounts + | :pending_transfer_not_found + | :pending_transfer_not_pending + | :pending_transfer_has_different_debit_account_id + | :pending_transfer_has_different_credit_account_id + | :pending_transfer_has_different_ledger + | :pending_transfer_has_different_code + | :exceeds_pending_transfer_amount + | :pending_transfer_has_different_amount + | :pending_transfer_already_posted + | :pending_transfer_already_voided + | :pending_transfer_expired + | :imported_event_timestamp_must_not_regress + | :imported_event_timestamp_must_postdate_debit_account + | :imported_event_timestamp_must_postdate_credit_account + | :imported_event_timeout_must_be_zero + | :debit_account_already_closed + | :credit_account_already_closed + | :overflows_debits_pending + | :overflows_credits_pending + | :overflows_debits_posted + | :overflows_credits_posted + | :overflows_debits + | :overflows_credits + | :overflows_timeout + | :exceeds_credits + | :exceeds_debits + + class CreateAccountResult + attr_reader timestamp: Integer + attr_reader status: Integer + attr_reader status_name: create_account_status_name + + def initialize: () -> void + end + + class CreateTransferResult + attr_reader timestamp: Integer + attr_reader status: Integer + attr_reader status_name: create_transfer_status_name + + def initialize: () -> void + end + +end diff --git a/ocam/src/clients/ruby/src/ext/tigerbeetle/.clang-format b/ocam/src/clients/ruby/src/ext/tigerbeetle/.clang-format new file mode 100644 index 00000000..3197bad9 --- /dev/null +++ b/ocam/src/clients/ruby/src/ext/tigerbeetle/.clang-format @@ -0,0 +1,7 @@ +BasedOnStyle: LLVM +IndentWidth: 4 +PointerAlignment: Right +ColumnLimit: 100 +BinPackArguments: false +BinPackParameters: false +AlignAfterOpenBracket: BlockIndent diff --git a/ocam/src/clients/ruby/src/ext/tigerbeetle/extconf.rb b/ocam/src/clients/ruby/src/ext/tigerbeetle/extconf.rb new file mode 100644 index 00000000..bf5262b1 --- /dev/null +++ b/ocam/src/clients/ruby/src/ext/tigerbeetle/extconf.rb @@ -0,0 +1,47 @@ +require "mkmf" +require "rbconfig" + +arch = RbConfig::CONFIG["target_cpu"] +target_os = RbConfig::CONFIG["target_os"] + +platform_arch = + case arch + when "x64", "x86_64" + "x86_64" + when "arm64", "aarch64" + "aarch64" + else + raise "Unsupported architecture #{arch.inspect}" + end + +platform_os = + case target_os + when /linux/ + host_os = RbConfig::CONFIG["host_os"] + + if host_os.include?("musl") || target_os.include?("musl") + "linux-musl" + else + "linux-gnu*" + end + when /darwin/ + "macos" + when /mingw|mswin/ + raise "Unsupported Windows architecture #{arch.inspect}" unless platform_arch == "x86_64" + + "windows" + else + raise "Unsupported operating system #{target_os.inspect}" + end + +platform = "#{platform_arch}-#{platform_os}" + +platform_dir = Dir.glob(File.join(__dir__, "lib", platform)).first +raise "No prebuilt libtb_client found for #{arch}-#{target_os}" unless platform_dir + +$DLDFLAGS << " -Wl,-rpath,#{platform_dir}" if target_os.match?(/darwin/) + +dir_config("tb_client", __dir__, platform_dir) +have_library("tb_client") or raise("libtb_client not found") + +create_makefile("tigerbeetle/tigerbeetle") diff --git a/ocam/src/clients/ruby/src/ext/tigerbeetle/rb_tb_gen.h b/ocam/src/clients/ruby/src/ext/tigerbeetle/rb_tb_gen.h new file mode 100644 index 00000000..35191c4d --- /dev/null +++ b/ocam/src/clients/ruby/src/ext/tigerbeetle/rb_tb_gen.h @@ -0,0 +1,677 @@ +//////////////////////////////////////////////////////// +// This file was auto-generated by ruby_bindings.zig // +// Do not manually modify. // +//////////////////////////////////////////////////////// + +#ifndef RB_TB_GEN_H +#define RB_TB_GEN_H + +#include "ruby.h" +#include "tb_client.h" +#include +#include +#include +#include + +static inline void rb_tb_pack_u128(VALUE v, void *dst) { + int status = rb_integer_pack(v, dst, 16, 1, 0, INTEGER_PACK_LITTLE_ENDIAN); + if (status != 0 && status != 1) { + rb_raise(rb_eRangeError, "integer must be between 0 and 2**128 - 1"); + } +} + +static inline VALUE rb_tb_unpack_u128(const void *src) { + return rb_integer_unpack(src, 16, 1, 0, INTEGER_PACK_LITTLE_ENDIAN); +} + +static inline void tb_assert_fail( + const char *condition, + int line, + const char *function +) { + fprintf(stderr, "tb_assert failed: %s at line %d in %s\n", condition, line, function); + abort(); +} + +// A version of `assert` macro that's always on regardless of NDEBUG macro. +#define tb_assert(condition) \ + do { \ + if (!(condition)) { \ + tb_assert_fail(#condition, __LINE__, __func__); \ + } \ + } while (0) +static const char *rb_tb_init_error_message(TB_INIT_STATUS status) { + switch (status) { + case TB_INIT_SUCCESS: + return "success"; + case TB_INIT_UNEXPECTED: + return "unexpected"; + case TB_INIT_OUT_OF_MEMORY: + return "out_of_memory"; + case TB_INIT_ADDRESS_INVALID: + return "address_invalid"; + case TB_INIT_ADDRESS_LIMIT_EXCEEDED: + return "address_limit_exceeded"; + case TB_INIT_SYSTEM_RESOURCES: + return "system_resources"; + case TB_INIT_NETWORK_SUBSYSTEM: + return "network_subsystem"; + default: + return "unknown"; + } +} + +static VALUE rb_tb_create_accounts_status_name(uint32_t status) { + switch (status) { + case 4294967295: + return ID2SYM(rb_intern("created")); + case 1: + return ID2SYM(rb_intern("linked_event_failed")); + case 2: + return ID2SYM(rb_intern("linked_event_chain_open")); + case 22: + return ID2SYM(rb_intern("imported_event_expected")); + case 23: + return ID2SYM(rb_intern("imported_event_not_expected")); + case 3: + return ID2SYM(rb_intern("timestamp_must_be_zero")); + case 24: + return ID2SYM(rb_intern("imported_event_timestamp_out_of_range")); + case 25: + return ID2SYM(rb_intern("imported_event_timestamp_must_not_advance")); + case 4: + return ID2SYM(rb_intern("reserved_field")); + case 5: + return ID2SYM(rb_intern("reserved_flag")); + case 6: + return ID2SYM(rb_intern("id_must_not_be_zero")); + case 7: + return ID2SYM(rb_intern("id_must_not_be_int_max")); + case 15: + return ID2SYM(rb_intern("exists_with_different_flags")); + case 16: + return ID2SYM(rb_intern("exists_with_different_user_data_128")); + case 17: + return ID2SYM(rb_intern("exists_with_different_user_data_64")); + case 18: + return ID2SYM(rb_intern("exists_with_different_user_data_32")); + case 19: + return ID2SYM(rb_intern("exists_with_different_ledger")); + case 20: + return ID2SYM(rb_intern("exists_with_different_code")); + case 21: + return ID2SYM(rb_intern("exists")); + case 8: + return ID2SYM(rb_intern("flags_are_mutually_exclusive")); + case 9: + return ID2SYM(rb_intern("debits_pending_must_be_zero")); + case 10: + return ID2SYM(rb_intern("debits_posted_must_be_zero")); + case 11: + return ID2SYM(rb_intern("credits_pending_must_be_zero")); + case 12: + return ID2SYM(rb_intern("credits_posted_must_be_zero")); + case 13: + return ID2SYM(rb_intern("ledger_must_not_be_zero")); + case 14: + return ID2SYM(rb_intern("code_must_not_be_zero")); + case 26: + return ID2SYM(rb_intern("imported_event_timestamp_must_not_regress")); + default: + tb_assert(false); + return Qnil; + } +} + +static VALUE rb_tb_create_transfers_status_name(uint32_t status) { + switch (status) { + case 4294967295: + return ID2SYM(rb_intern("created")); + case 1: + return ID2SYM(rb_intern("linked_event_failed")); + case 2: + return ID2SYM(rb_intern("linked_event_chain_open")); + case 56: + return ID2SYM(rb_intern("imported_event_expected")); + case 57: + return ID2SYM(rb_intern("imported_event_not_expected")); + case 3: + return ID2SYM(rb_intern("timestamp_must_be_zero")); + case 58: + return ID2SYM(rb_intern("imported_event_timestamp_out_of_range")); + case 59: + return ID2SYM(rb_intern("imported_event_timestamp_must_not_advance")); + case 4: + return ID2SYM(rb_intern("reserved_flag")); + case 5: + return ID2SYM(rb_intern("id_must_not_be_zero")); + case 6: + return ID2SYM(rb_intern("id_must_not_be_int_max")); + case 36: + return ID2SYM(rb_intern("exists_with_different_flags")); + case 40: + return ID2SYM(rb_intern("exists_with_different_pending_id")); + case 44: + return ID2SYM(rb_intern("exists_with_different_timeout")); + case 37: + return ID2SYM(rb_intern("exists_with_different_debit_account_id")); + case 38: + return ID2SYM(rb_intern("exists_with_different_credit_account_id")); + case 39: + return ID2SYM(rb_intern("exists_with_different_amount")); + case 41: + return ID2SYM(rb_intern("exists_with_different_user_data_128")); + case 42: + return ID2SYM(rb_intern("exists_with_different_user_data_64")); + case 43: + return ID2SYM(rb_intern("exists_with_different_user_data_32")); + case 67: + return ID2SYM(rb_intern("exists_with_different_ledger")); + case 45: + return ID2SYM(rb_intern("exists_with_different_code")); + case 46: + return ID2SYM(rb_intern("exists")); + case 68: + return ID2SYM(rb_intern("id_already_failed")); + case 7: + return ID2SYM(rb_intern("flags_are_mutually_exclusive")); + case 8: + return ID2SYM(rb_intern("debit_account_id_must_not_be_zero")); + case 9: + return ID2SYM(rb_intern("debit_account_id_must_not_be_int_max")); + case 10: + return ID2SYM(rb_intern("credit_account_id_must_not_be_zero")); + case 11: + return ID2SYM(rb_intern("credit_account_id_must_not_be_int_max")); + case 12: + return ID2SYM(rb_intern("accounts_must_be_different")); + case 13: + return ID2SYM(rb_intern("pending_id_must_be_zero")); + case 14: + return ID2SYM(rb_intern("pending_id_must_not_be_zero")); + case 15: + return ID2SYM(rb_intern("pending_id_must_not_be_int_max")); + case 16: + return ID2SYM(rb_intern("pending_id_must_be_different")); + case 17: + return ID2SYM(rb_intern("timeout_reserved_for_pending_transfer")); + case 64: + return ID2SYM(rb_intern("closing_transfer_must_be_pending")); + case 19: + return ID2SYM(rb_intern("ledger_must_not_be_zero")); + case 20: + return ID2SYM(rb_intern("code_must_not_be_zero")); + case 21: + return ID2SYM(rb_intern("debit_account_not_found")); + case 22: + return ID2SYM(rb_intern("credit_account_not_found")); + case 23: + return ID2SYM(rb_intern("accounts_must_have_the_same_ledger")); + case 24: + return ID2SYM(rb_intern("transfer_must_have_the_same_ledger_as_accounts")); + case 25: + return ID2SYM(rb_intern("pending_transfer_not_found")); + case 26: + return ID2SYM(rb_intern("pending_transfer_not_pending")); + case 27: + return ID2SYM(rb_intern("pending_transfer_has_different_debit_account_id")); + case 28: + return ID2SYM(rb_intern("pending_transfer_has_different_credit_account_id")); + case 29: + return ID2SYM(rb_intern("pending_transfer_has_different_ledger")); + case 30: + return ID2SYM(rb_intern("pending_transfer_has_different_code")); + case 31: + return ID2SYM(rb_intern("exceeds_pending_transfer_amount")); + case 32: + return ID2SYM(rb_intern("pending_transfer_has_different_amount")); + case 33: + return ID2SYM(rb_intern("pending_transfer_already_posted")); + case 34: + return ID2SYM(rb_intern("pending_transfer_already_voided")); + case 35: + return ID2SYM(rb_intern("pending_transfer_expired")); + case 60: + return ID2SYM(rb_intern("imported_event_timestamp_must_not_regress")); + case 61: + return ID2SYM(rb_intern("imported_event_timestamp_must_postdate_debit_account")); + case 62: + return ID2SYM(rb_intern("imported_event_timestamp_must_postdate_credit_account")); + case 63: + return ID2SYM(rb_intern("imported_event_timeout_must_be_zero")); + case 65: + return ID2SYM(rb_intern("debit_account_already_closed")); + case 66: + return ID2SYM(rb_intern("credit_account_already_closed")); + case 47: + return ID2SYM(rb_intern("overflows_debits_pending")); + case 48: + return ID2SYM(rb_intern("overflows_credits_pending")); + case 49: + return ID2SYM(rb_intern("overflows_debits_posted")); + case 50: + return ID2SYM(rb_intern("overflows_credits_posted")); + case 51: + return ID2SYM(rb_intern("overflows_debits")); + case 52: + return ID2SYM(rb_intern("overflows_credits")); + case 53: + return ID2SYM(rb_intern("overflows_timeout")); + case 54: + return ID2SYM(rb_intern("exceeds_credits")); + case 55: + return ID2SYM(rb_intern("exceeds_debits")); + default: + tb_assert(false); + return Qnil; + } +} + +static VALUE rb_tb_deserialize_lookup_accounts(const uint8_t *buf, uint32_t buf_size) { + VALUE klass = rb_path2class("TigerBeetle::Account"); + tb_assert(buf_size % sizeof(tb_account_t) == 0); + long count = (long)(buf_size / sizeof(tb_account_t)); + VALUE results = rb_ary_new_capa(count); + const tb_account_t *items = (const tb_account_t *)buf; + for (long i = 0; i < count; i++) { + const tb_account_t *item = &items[i]; + VALUE obj = rb_obj_alloc(klass); + rb_ivar_set(obj, rb_intern("@id"), rb_tb_unpack_u128(&item->id)); + rb_ivar_set(obj, rb_intern("@debits_pending"), rb_tb_unpack_u128(&item->debits_pending)); + rb_ivar_set(obj, rb_intern("@debits_posted"), rb_tb_unpack_u128(&item->debits_posted)); + rb_ivar_set(obj, rb_intern("@credits_pending"), rb_tb_unpack_u128(&item->credits_pending)); + rb_ivar_set(obj, rb_intern("@credits_posted"), rb_tb_unpack_u128(&item->credits_posted)); + rb_ivar_set(obj, rb_intern("@user_data_128"), rb_tb_unpack_u128(&item->user_data_128)); + rb_ivar_set(obj, rb_intern("@user_data_64"), RB_ULL2NUM(item->user_data_64)); + rb_ivar_set(obj, rb_intern("@user_data_32"), RB_UINT2NUM(item->user_data_32)); + tb_assert(item->reserved == 0); + rb_ivar_set(obj, rb_intern("@ledger"), RB_UINT2NUM(item->ledger)); + rb_ivar_set(obj, rb_intern("@code"), RB_UINT2NUM(item->code)); + rb_ivar_set(obj, rb_intern("@flags"), RB_UINT2NUM(item->flags)); + rb_ivar_set(obj, rb_intern("@timestamp"), RB_ULL2NUM(item->timestamp)); + rb_ary_push(results, obj); + } + return results; +} + +static VALUE rb_tb_deserialize_lookup_transfers(const uint8_t *buf, uint32_t buf_size) { + VALUE klass = rb_path2class("TigerBeetle::Transfer"); + tb_assert(buf_size % sizeof(tb_transfer_t) == 0); + long count = (long)(buf_size / sizeof(tb_transfer_t)); + VALUE results = rb_ary_new_capa(count); + const tb_transfer_t *items = (const tb_transfer_t *)buf; + for (long i = 0; i < count; i++) { + const tb_transfer_t *item = &items[i]; + VALUE obj = rb_obj_alloc(klass); + rb_ivar_set(obj, rb_intern("@id"), rb_tb_unpack_u128(&item->id)); + rb_ivar_set(obj, rb_intern("@debit_account_id"), rb_tb_unpack_u128(&item->debit_account_id)); + rb_ivar_set(obj, rb_intern("@credit_account_id"), rb_tb_unpack_u128(&item->credit_account_id)); + rb_ivar_set(obj, rb_intern("@amount"), rb_tb_unpack_u128(&item->amount)); + rb_ivar_set(obj, rb_intern("@pending_id"), rb_tb_unpack_u128(&item->pending_id)); + rb_ivar_set(obj, rb_intern("@user_data_128"), rb_tb_unpack_u128(&item->user_data_128)); + rb_ivar_set(obj, rb_intern("@user_data_64"), RB_ULL2NUM(item->user_data_64)); + rb_ivar_set(obj, rb_intern("@user_data_32"), RB_UINT2NUM(item->user_data_32)); + rb_ivar_set(obj, rb_intern("@timeout"), RB_UINT2NUM(item->timeout)); + rb_ivar_set(obj, rb_intern("@ledger"), RB_UINT2NUM(item->ledger)); + rb_ivar_set(obj, rb_intern("@code"), RB_UINT2NUM(item->code)); + rb_ivar_set(obj, rb_intern("@flags"), RB_UINT2NUM(item->flags)); + rb_ivar_set(obj, rb_intern("@timestamp"), RB_ULL2NUM(item->timestamp)); + rb_ary_push(results, obj); + } + return results; +} + +static void rb_tb_serialize_get_account_transfers(VALUE items_rb, uint8_t *buf, long count) { + tb_account_filter_t *items = (tb_account_filter_t *)buf; + for (long i = 0; i < count; i++) { + VALUE item_rb = RARRAY_AREF(items_rb, i); + tb_account_filter_t *item = &items[i]; + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@account_id")), &item->account_id); + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@user_data_128")), &item->user_data_128); + item->user_data_64 = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@user_data_64"))); + item->user_data_32 = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@user_data_32"))); + item->code = RB_NUM2USHORT(rb_ivar_get(item_rb, rb_intern("@code"))); + memset(item->reserved, 0, sizeof(item->reserved)); + item->timestamp_min = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@timestamp_min"))); + item->timestamp_max = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@timestamp_max"))); + item->limit = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@limit"))); + item->flags = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@flags"))); + } +} + +static VALUE rb_tb_deserialize_get_account_transfers(const uint8_t *buf, uint32_t buf_size) { + VALUE klass = rb_path2class("TigerBeetle::Transfer"); + tb_assert(buf_size % sizeof(tb_transfer_t) == 0); + long count = (long)(buf_size / sizeof(tb_transfer_t)); + VALUE results = rb_ary_new_capa(count); + const tb_transfer_t *items = (const tb_transfer_t *)buf; + for (long i = 0; i < count; i++) { + const tb_transfer_t *item = &items[i]; + VALUE obj = rb_obj_alloc(klass); + rb_ivar_set(obj, rb_intern("@id"), rb_tb_unpack_u128(&item->id)); + rb_ivar_set(obj, rb_intern("@debit_account_id"), rb_tb_unpack_u128(&item->debit_account_id)); + rb_ivar_set(obj, rb_intern("@credit_account_id"), rb_tb_unpack_u128(&item->credit_account_id)); + rb_ivar_set(obj, rb_intern("@amount"), rb_tb_unpack_u128(&item->amount)); + rb_ivar_set(obj, rb_intern("@pending_id"), rb_tb_unpack_u128(&item->pending_id)); + rb_ivar_set(obj, rb_intern("@user_data_128"), rb_tb_unpack_u128(&item->user_data_128)); + rb_ivar_set(obj, rb_intern("@user_data_64"), RB_ULL2NUM(item->user_data_64)); + rb_ivar_set(obj, rb_intern("@user_data_32"), RB_UINT2NUM(item->user_data_32)); + rb_ivar_set(obj, rb_intern("@timeout"), RB_UINT2NUM(item->timeout)); + rb_ivar_set(obj, rb_intern("@ledger"), RB_UINT2NUM(item->ledger)); + rb_ivar_set(obj, rb_intern("@code"), RB_UINT2NUM(item->code)); + rb_ivar_set(obj, rb_intern("@flags"), RB_UINT2NUM(item->flags)); + rb_ivar_set(obj, rb_intern("@timestamp"), RB_ULL2NUM(item->timestamp)); + rb_ary_push(results, obj); + } + return results; +} + +static void rb_tb_serialize_get_account_balances(VALUE items_rb, uint8_t *buf, long count) { + tb_account_filter_t *items = (tb_account_filter_t *)buf; + for (long i = 0; i < count; i++) { + VALUE item_rb = RARRAY_AREF(items_rb, i); + tb_account_filter_t *item = &items[i]; + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@account_id")), &item->account_id); + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@user_data_128")), &item->user_data_128); + item->user_data_64 = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@user_data_64"))); + item->user_data_32 = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@user_data_32"))); + item->code = RB_NUM2USHORT(rb_ivar_get(item_rb, rb_intern("@code"))); + memset(item->reserved, 0, sizeof(item->reserved)); + item->timestamp_min = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@timestamp_min"))); + item->timestamp_max = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@timestamp_max"))); + item->limit = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@limit"))); + item->flags = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@flags"))); + } +} + +static VALUE rb_tb_deserialize_get_account_balances(const uint8_t *buf, uint32_t buf_size) { + VALUE klass = rb_path2class("TigerBeetle::AccountBalance"); + tb_assert(buf_size % sizeof(tb_account_balance_t) == 0); + long count = (long)(buf_size / sizeof(tb_account_balance_t)); + VALUE results = rb_ary_new_capa(count); + const tb_account_balance_t *items = (const tb_account_balance_t *)buf; + for (long i = 0; i < count; i++) { + const tb_account_balance_t *item = &items[i]; + VALUE obj = rb_obj_alloc(klass); + rb_ivar_set(obj, rb_intern("@debits_pending"), rb_tb_unpack_u128(&item->debits_pending)); + rb_ivar_set(obj, rb_intern("@debits_posted"), rb_tb_unpack_u128(&item->debits_posted)); + rb_ivar_set(obj, rb_intern("@credits_pending"), rb_tb_unpack_u128(&item->credits_pending)); + rb_ivar_set(obj, rb_intern("@credits_posted"), rb_tb_unpack_u128(&item->credits_posted)); + rb_ivar_set(obj, rb_intern("@timestamp"), RB_ULL2NUM(item->timestamp)); + uint8_t zero[sizeof(item->reserved)] = {0}; + tb_assert(memcmp(item->reserved, zero, sizeof(item->reserved)) == 0); + rb_ary_push(results, obj); + } + return results; +} + +static void rb_tb_serialize_query_accounts(VALUE items_rb, uint8_t *buf, long count) { + tb_query_filter_t *items = (tb_query_filter_t *)buf; + for (long i = 0; i < count; i++) { + VALUE item_rb = RARRAY_AREF(items_rb, i); + tb_query_filter_t *item = &items[i]; + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@user_data_128")), &item->user_data_128); + item->user_data_64 = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@user_data_64"))); + item->user_data_32 = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@user_data_32"))); + item->ledger = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@ledger"))); + item->code = RB_NUM2USHORT(rb_ivar_get(item_rb, rb_intern("@code"))); + memset(item->reserved, 0, sizeof(item->reserved)); + item->timestamp_min = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@timestamp_min"))); + item->timestamp_max = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@timestamp_max"))); + item->limit = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@limit"))); + item->flags = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@flags"))); + } +} + +static VALUE rb_tb_deserialize_query_accounts(const uint8_t *buf, uint32_t buf_size) { + VALUE klass = rb_path2class("TigerBeetle::Account"); + tb_assert(buf_size % sizeof(tb_account_t) == 0); + long count = (long)(buf_size / sizeof(tb_account_t)); + VALUE results = rb_ary_new_capa(count); + const tb_account_t *items = (const tb_account_t *)buf; + for (long i = 0; i < count; i++) { + const tb_account_t *item = &items[i]; + VALUE obj = rb_obj_alloc(klass); + rb_ivar_set(obj, rb_intern("@id"), rb_tb_unpack_u128(&item->id)); + rb_ivar_set(obj, rb_intern("@debits_pending"), rb_tb_unpack_u128(&item->debits_pending)); + rb_ivar_set(obj, rb_intern("@debits_posted"), rb_tb_unpack_u128(&item->debits_posted)); + rb_ivar_set(obj, rb_intern("@credits_pending"), rb_tb_unpack_u128(&item->credits_pending)); + rb_ivar_set(obj, rb_intern("@credits_posted"), rb_tb_unpack_u128(&item->credits_posted)); + rb_ivar_set(obj, rb_intern("@user_data_128"), rb_tb_unpack_u128(&item->user_data_128)); + rb_ivar_set(obj, rb_intern("@user_data_64"), RB_ULL2NUM(item->user_data_64)); + rb_ivar_set(obj, rb_intern("@user_data_32"), RB_UINT2NUM(item->user_data_32)); + tb_assert(item->reserved == 0); + rb_ivar_set(obj, rb_intern("@ledger"), RB_UINT2NUM(item->ledger)); + rb_ivar_set(obj, rb_intern("@code"), RB_UINT2NUM(item->code)); + rb_ivar_set(obj, rb_intern("@flags"), RB_UINT2NUM(item->flags)); + rb_ivar_set(obj, rb_intern("@timestamp"), RB_ULL2NUM(item->timestamp)); + rb_ary_push(results, obj); + } + return results; +} + +static void rb_tb_serialize_query_transfers(VALUE items_rb, uint8_t *buf, long count) { + tb_query_filter_t *items = (tb_query_filter_t *)buf; + for (long i = 0; i < count; i++) { + VALUE item_rb = RARRAY_AREF(items_rb, i); + tb_query_filter_t *item = &items[i]; + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@user_data_128")), &item->user_data_128); + item->user_data_64 = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@user_data_64"))); + item->user_data_32 = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@user_data_32"))); + item->ledger = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@ledger"))); + item->code = RB_NUM2USHORT(rb_ivar_get(item_rb, rb_intern("@code"))); + memset(item->reserved, 0, sizeof(item->reserved)); + item->timestamp_min = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@timestamp_min"))); + item->timestamp_max = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@timestamp_max"))); + item->limit = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@limit"))); + item->flags = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@flags"))); + } +} + +static VALUE rb_tb_deserialize_query_transfers(const uint8_t *buf, uint32_t buf_size) { + VALUE klass = rb_path2class("TigerBeetle::Transfer"); + tb_assert(buf_size % sizeof(tb_transfer_t) == 0); + long count = (long)(buf_size / sizeof(tb_transfer_t)); + VALUE results = rb_ary_new_capa(count); + const tb_transfer_t *items = (const tb_transfer_t *)buf; + for (long i = 0; i < count; i++) { + const tb_transfer_t *item = &items[i]; + VALUE obj = rb_obj_alloc(klass); + rb_ivar_set(obj, rb_intern("@id"), rb_tb_unpack_u128(&item->id)); + rb_ivar_set(obj, rb_intern("@debit_account_id"), rb_tb_unpack_u128(&item->debit_account_id)); + rb_ivar_set(obj, rb_intern("@credit_account_id"), rb_tb_unpack_u128(&item->credit_account_id)); + rb_ivar_set(obj, rb_intern("@amount"), rb_tb_unpack_u128(&item->amount)); + rb_ivar_set(obj, rb_intern("@pending_id"), rb_tb_unpack_u128(&item->pending_id)); + rb_ivar_set(obj, rb_intern("@user_data_128"), rb_tb_unpack_u128(&item->user_data_128)); + rb_ivar_set(obj, rb_intern("@user_data_64"), RB_ULL2NUM(item->user_data_64)); + rb_ivar_set(obj, rb_intern("@user_data_32"), RB_UINT2NUM(item->user_data_32)); + rb_ivar_set(obj, rb_intern("@timeout"), RB_UINT2NUM(item->timeout)); + rb_ivar_set(obj, rb_intern("@ledger"), RB_UINT2NUM(item->ledger)); + rb_ivar_set(obj, rb_intern("@code"), RB_UINT2NUM(item->code)); + rb_ivar_set(obj, rb_intern("@flags"), RB_UINT2NUM(item->flags)); + rb_ivar_set(obj, rb_intern("@timestamp"), RB_ULL2NUM(item->timestamp)); + rb_ary_push(results, obj); + } + return results; +} + +static void rb_tb_serialize_create_accounts(VALUE items_rb, uint8_t *buf, long count) { + tb_account_t *items = (tb_account_t *)buf; + for (long i = 0; i < count; i++) { + VALUE item_rb = RARRAY_AREF(items_rb, i); + tb_account_t *item = &items[i]; + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@id")), &item->id); + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@debits_pending")), &item->debits_pending); + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@debits_posted")), &item->debits_posted); + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@credits_pending")), &item->credits_pending); + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@credits_posted")), &item->credits_posted); + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@user_data_128")), &item->user_data_128); + item->user_data_64 = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@user_data_64"))); + item->user_data_32 = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@user_data_32"))); + item->reserved = 0; + item->ledger = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@ledger"))); + item->code = RB_NUM2USHORT(rb_ivar_get(item_rb, rb_intern("@code"))); + item->flags = RB_NUM2USHORT(rb_ivar_get(item_rb, rb_intern("@flags"))); + item->timestamp = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@timestamp"))); + } +} + +static VALUE rb_tb_deserialize_create_accounts(const uint8_t *buf, uint32_t buf_size) { + VALUE klass = rb_path2class("TigerBeetle::CreateAccountResult"); + tb_assert(buf_size % sizeof(tb_create_account_result_t) == 0); + long count = (long)(buf_size / sizeof(tb_create_account_result_t)); + VALUE results = rb_ary_new_capa(count); + const tb_create_account_result_t *items = (const tb_create_account_result_t *)buf; + for (long i = 0; i < count; i++) { + const tb_create_account_result_t *item = &items[i]; + VALUE obj = rb_obj_alloc(klass); + rb_ivar_set(obj, rb_intern("@timestamp"), RB_ULL2NUM(item->timestamp)); + rb_ivar_set(obj, rb_intern("@status"), RB_UINT2NUM(item->status)); + rb_ivar_set( + obj, + rb_intern("@status_name"), + rb_tb_create_accounts_status_name(item->status) + ); + tb_assert(item->reserved == 0); + rb_ary_push(results, obj); + } + return results; +} + +static void rb_tb_serialize_create_transfers(VALUE items_rb, uint8_t *buf, long count) { + tb_transfer_t *items = (tb_transfer_t *)buf; + for (long i = 0; i < count; i++) { + VALUE item_rb = RARRAY_AREF(items_rb, i); + tb_transfer_t *item = &items[i]; + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@id")), &item->id); + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@debit_account_id")), &item->debit_account_id); + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@credit_account_id")), &item->credit_account_id); + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@amount")), &item->amount); + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@pending_id")), &item->pending_id); + rb_tb_pack_u128(rb_ivar_get(item_rb, rb_intern("@user_data_128")), &item->user_data_128); + item->user_data_64 = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@user_data_64"))); + item->user_data_32 = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@user_data_32"))); + item->timeout = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@timeout"))); + item->ledger = RB_NUM2UINT(rb_ivar_get(item_rb, rb_intern("@ledger"))); + item->code = RB_NUM2USHORT(rb_ivar_get(item_rb, rb_intern("@code"))); + item->flags = RB_NUM2USHORT(rb_ivar_get(item_rb, rb_intern("@flags"))); + item->timestamp = RB_NUM2ULL(rb_ivar_get(item_rb, rb_intern("@timestamp"))); + } +} + +static VALUE rb_tb_deserialize_create_transfers(const uint8_t *buf, uint32_t buf_size) { + VALUE klass = rb_path2class("TigerBeetle::CreateTransferResult"); + tb_assert(buf_size % sizeof(tb_create_transfer_result_t) == 0); + long count = (long)(buf_size / sizeof(tb_create_transfer_result_t)); + VALUE results = rb_ary_new_capa(count); + const tb_create_transfer_result_t *items = (const tb_create_transfer_result_t *)buf; + for (long i = 0; i < count; i++) { + const tb_create_transfer_result_t *item = &items[i]; + VALUE obj = rb_obj_alloc(klass); + rb_ivar_set(obj, rb_intern("@timestamp"), RB_ULL2NUM(item->timestamp)); + rb_ivar_set(obj, rb_intern("@status"), RB_UINT2NUM(item->status)); + rb_ivar_set( + obj, + rb_intern("@status_name"), + rb_tb_create_transfers_status_name(item->status) + ); + tb_assert(item->reserved == 0); + rb_ary_push(results, obj); + } + return results; +} + +static void rb_tb_serialize_u128(VALUE items_rb, uint8_t *buf, long count) { + tb_uint128_t *ids = (tb_uint128_t *)buf; + for (long i = 0; i < count; i++) { + rb_tb_pack_u128(RARRAY_AREF(items_rb, i), &ids[i]); + } +} + +static size_t rb_tb_event_size(TB_OPERATION operation) { + switch (operation) { + case TB_OPERATION_LOOKUP_ACCOUNTS: + return sizeof(tb_uint128_t); + case TB_OPERATION_LOOKUP_TRANSFERS: + return sizeof(tb_uint128_t); + case TB_OPERATION_GET_ACCOUNT_TRANSFERS: + return sizeof(tb_account_filter_t); + case TB_OPERATION_GET_ACCOUNT_BALANCES: + return sizeof(tb_account_filter_t); + case TB_OPERATION_QUERY_ACCOUNTS: + return sizeof(tb_query_filter_t); + case TB_OPERATION_QUERY_TRANSFERS: + return sizeof(tb_query_filter_t); + case TB_OPERATION_CREATE_ACCOUNTS: + return sizeof(tb_account_t); + case TB_OPERATION_CREATE_TRANSFERS: + return sizeof(tb_transfer_t); + default: + rb_raise(rb_eRuntimeError, "unsupported operation: %d", (int)operation); + return 0; + } +} + +static void rb_tb_serialize( + TB_OPERATION operation, + VALUE items_rb, + uint8_t *buf, + long count +) { + switch (operation) { + case TB_OPERATION_LOOKUP_ACCOUNTS: + rb_tb_serialize_u128(items_rb, buf, count); + break; + case TB_OPERATION_LOOKUP_TRANSFERS: + rb_tb_serialize_u128(items_rb, buf, count); + break; + case TB_OPERATION_GET_ACCOUNT_TRANSFERS: + rb_tb_serialize_get_account_transfers(items_rb, buf, count); + break; + case TB_OPERATION_GET_ACCOUNT_BALANCES: + rb_tb_serialize_get_account_balances(items_rb, buf, count); + break; + case TB_OPERATION_QUERY_ACCOUNTS: + rb_tb_serialize_query_accounts(items_rb, buf, count); + break; + case TB_OPERATION_QUERY_TRANSFERS: + rb_tb_serialize_query_transfers(items_rb, buf, count); + break; + case TB_OPERATION_CREATE_ACCOUNTS: + rb_tb_serialize_create_accounts(items_rb, buf, count); + break; + case TB_OPERATION_CREATE_TRANSFERS: + rb_tb_serialize_create_transfers(items_rb, buf, count); + break; + default: + rb_raise(rb_eRuntimeError, "unsupported operation: %d", (int)operation); + } +} + +static VALUE rb_tb_deserialize( + TB_OPERATION operation, + const uint8_t *buf, + uint32_t buf_size +) { + switch (operation) { + case TB_OPERATION_LOOKUP_ACCOUNTS: + return rb_tb_deserialize_lookup_accounts(buf, buf_size); + case TB_OPERATION_LOOKUP_TRANSFERS: + return rb_tb_deserialize_lookup_transfers(buf, buf_size); + case TB_OPERATION_GET_ACCOUNT_TRANSFERS: + return rb_tb_deserialize_get_account_transfers(buf, buf_size); + case TB_OPERATION_GET_ACCOUNT_BALANCES: + return rb_tb_deserialize_get_account_balances(buf, buf_size); + case TB_OPERATION_QUERY_ACCOUNTS: + return rb_tb_deserialize_query_accounts(buf, buf_size); + case TB_OPERATION_QUERY_TRANSFERS: + return rb_tb_deserialize_query_transfers(buf, buf_size); + case TB_OPERATION_CREATE_ACCOUNTS: + return rb_tb_deserialize_create_accounts(buf, buf_size); + case TB_OPERATION_CREATE_TRANSFERS: + return rb_tb_deserialize_create_transfers(buf, buf_size); + default: + rb_raise(rb_eRuntimeError, "unsupported operation: %d", (int)operation); + } +} +#endif diff --git a/ocam/src/clients/ruby/src/ext/tigerbeetle/tb_client.h b/ocam/src/clients/ruby/src/ext/tigerbeetle/tb_client.h new file mode 100644 index 00000000..bb4554e6 --- /dev/null +++ b/ocam/src/clients/ruby/src/ext/tigerbeetle/tb_client.h @@ -0,0 +1,388 @@ + ////////////////////////////////////////////////////////// + // This file was auto-generated by tb_client_header.zig // + // Do not manually modify. // + ////////////////////////////////////////////////////////// + +#ifndef TB_CLIENT_H +#define TB_CLIENT_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +typedef __uint128_t tb_uint128_t; + +typedef enum TB_ACCOUNT_FLAGS { + TB_ACCOUNT_LINKED = 1 << 0, + TB_ACCOUNT_DEBITS_MUST_NOT_EXCEED_CREDITS = 1 << 1, + TB_ACCOUNT_CREDITS_MUST_NOT_EXCEED_DEBITS = 1 << 2, + TB_ACCOUNT_HISTORY = 1 << 3, + TB_ACCOUNT_IMPORTED = 1 << 4, + TB_ACCOUNT_CLOSED = 1 << 5, +} TB_ACCOUNT_FLAGS; + +typedef struct tb_account_t { + tb_uint128_t id; + tb_uint128_t debits_pending; + tb_uint128_t debits_posted; + tb_uint128_t credits_pending; + tb_uint128_t credits_posted; + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint32_t reserved; + uint32_t ledger; + uint16_t code; + uint16_t flags; + uint64_t timestamp; +} tb_account_t; + +typedef enum TB_TRANSFER_FLAGS { + TB_TRANSFER_LINKED = 1 << 0, + TB_TRANSFER_PENDING = 1 << 1, + TB_TRANSFER_POST_PENDING_TRANSFER = 1 << 2, + TB_TRANSFER_VOID_PENDING_TRANSFER = 1 << 3, + TB_TRANSFER_BALANCING_DEBIT = 1 << 4, + TB_TRANSFER_BALANCING_CREDIT = 1 << 5, + TB_TRANSFER_CLOSING_DEBIT = 1 << 6, + TB_TRANSFER_CLOSING_CREDIT = 1 << 7, + TB_TRANSFER_IMPORTED = 1 << 8, +} TB_TRANSFER_FLAGS; + +typedef struct tb_transfer_t { + tb_uint128_t id; + tb_uint128_t debit_account_id; + tb_uint128_t credit_account_id; + tb_uint128_t amount; + tb_uint128_t pending_id; + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint32_t timeout; + uint32_t ledger; + uint16_t code; + uint16_t flags; + uint64_t timestamp; +} tb_transfer_t; + +typedef enum TB_CREATE_ACCOUNT_STATUS { + TB_CREATE_ACCOUNT_CREATED = 0xFFFFFFFF, + TB_CREATE_ACCOUNT_LINKED_EVENT_FAILED = 1, + TB_CREATE_ACCOUNT_LINKED_EVENT_CHAIN_OPEN = 2, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_EXPECTED = 22, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_NOT_EXPECTED = 23, + TB_CREATE_ACCOUNT_TIMESTAMP_MUST_BE_ZERO = 3, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE = 24, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE = 25, + TB_CREATE_ACCOUNT_RESERVED_FIELD = 4, + TB_CREATE_ACCOUNT_RESERVED_FLAG = 5, + TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_ZERO = 6, + TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 7, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_FLAGS = 15, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_128 = 16, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_64 = 17, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_32 = 18, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_LEDGER = 19, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_CODE = 20, + TB_CREATE_ACCOUNT_EXISTS = 21, + TB_CREATE_ACCOUNT_FLAGS_ARE_MUTUALLY_EXCLUSIVE = 8, + TB_CREATE_ACCOUNT_DEBITS_PENDING_MUST_BE_ZERO = 9, + TB_CREATE_ACCOUNT_DEBITS_POSTED_MUST_BE_ZERO = 10, + TB_CREATE_ACCOUNT_CREDITS_PENDING_MUST_BE_ZERO = 11, + TB_CREATE_ACCOUNT_CREDITS_POSTED_MUST_BE_ZERO = 12, + TB_CREATE_ACCOUNT_LEDGER_MUST_NOT_BE_ZERO = 13, + TB_CREATE_ACCOUNT_CODE_MUST_NOT_BE_ZERO = 14, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS = 26, +} TB_CREATE_ACCOUNT_STATUS; + +typedef enum TB_CREATE_TRANSFER_STATUS { + TB_CREATE_TRANSFER_CREATED = 0xFFFFFFFF, + TB_CREATE_TRANSFER_LINKED_EVENT_FAILED = 1, + TB_CREATE_TRANSFER_LINKED_EVENT_CHAIN_OPEN = 2, + TB_CREATE_TRANSFER_IMPORTED_EVENT_EXPECTED = 56, + TB_CREATE_TRANSFER_IMPORTED_EVENT_NOT_EXPECTED = 57, + TB_CREATE_TRANSFER_TIMESTAMP_MUST_BE_ZERO = 3, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE = 58, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE = 59, + TB_CREATE_TRANSFER_RESERVED_FLAG = 4, + TB_CREATE_TRANSFER_ID_MUST_NOT_BE_ZERO = 5, + TB_CREATE_TRANSFER_ID_MUST_NOT_BE_INT_MAX = 6, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_FLAGS = 36, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_PENDING_ID = 40, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_TIMEOUT = 44, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_DEBIT_ACCOUNT_ID = 37, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CREDIT_ACCOUNT_ID = 38, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_AMOUNT = 39, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_128 = 41, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_64 = 42, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_32 = 43, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_LEDGER = 67, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CODE = 45, + TB_CREATE_TRANSFER_EXISTS = 46, + TB_CREATE_TRANSFER_ID_ALREADY_FAILED = 68, + TB_CREATE_TRANSFER_FLAGS_ARE_MUTUALLY_EXCLUSIVE = 7, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_ZERO = 8, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 9, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_ZERO = 10, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 11, + TB_CREATE_TRANSFER_ACCOUNTS_MUST_BE_DIFFERENT = 12, + TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_ZERO = 13, + TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_ZERO = 14, + TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_INT_MAX = 15, + TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_DIFFERENT = 16, + TB_CREATE_TRANSFER_TIMEOUT_RESERVED_FOR_PENDING_TRANSFER = 17, + TB_CREATE_TRANSFER_CLOSING_TRANSFER_MUST_BE_PENDING = 64, + TB_CREATE_TRANSFER_LEDGER_MUST_NOT_BE_ZERO = 19, + TB_CREATE_TRANSFER_CODE_MUST_NOT_BE_ZERO = 20, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_NOT_FOUND = 21, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_NOT_FOUND = 22, + TB_CREATE_TRANSFER_ACCOUNTS_MUST_HAVE_THE_SAME_LEDGER = 23, + TB_CREATE_TRANSFER_TRANSFER_MUST_HAVE_THE_SAME_LEDGER_AS_ACCOUNTS = 24, + TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_FOUND = 25, + TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_PENDING = 26, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_DEBIT_ACCOUNT_ID = 27, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CREDIT_ACCOUNT_ID = 28, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_LEDGER = 29, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CODE = 30, + TB_CREATE_TRANSFER_EXCEEDS_PENDING_TRANSFER_AMOUNT = 31, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_AMOUNT = 32, + TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_POSTED = 33, + TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_VOIDED = 34, + TB_CREATE_TRANSFER_PENDING_TRANSFER_EXPIRED = 35, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS = 60, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_DEBIT_ACCOUNT = 61, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_CREDIT_ACCOUNT = 62, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMEOUT_MUST_BE_ZERO = 63, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ALREADY_CLOSED = 65, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ALREADY_CLOSED = 66, + TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_PENDING = 47, + TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_PENDING = 48, + TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_POSTED = 49, + TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_POSTED = 50, + TB_CREATE_TRANSFER_OVERFLOWS_DEBITS = 51, + TB_CREATE_TRANSFER_OVERFLOWS_CREDITS = 52, + TB_CREATE_TRANSFER_OVERFLOWS_TIMEOUT = 53, + TB_CREATE_TRANSFER_EXCEEDS_CREDITS = 54, + TB_CREATE_TRANSFER_EXCEEDS_DEBITS = 55, +} TB_CREATE_TRANSFER_STATUS; + +typedef struct tb_create_account_result_t { + uint64_t timestamp; + uint32_t status; + uint32_t reserved; +} tb_create_account_result_t; + +typedef struct tb_create_transfer_result_t { + uint64_t timestamp; + uint32_t status; + uint32_t reserved; +} tb_create_transfer_result_t; + +typedef struct tb_account_filter_t { + tb_uint128_t account_id; + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint16_t code; + uint8_t reserved[58]; + uint64_t timestamp_min; + uint64_t timestamp_max; + uint32_t limit; + uint32_t flags; +} tb_account_filter_t; + +typedef enum TB_ACCOUNT_FILTER_FLAGS { + TB_ACCOUNT_FILTER_DEBITS = 1 << 0, + TB_ACCOUNT_FILTER_CREDITS = 1 << 1, + TB_ACCOUNT_FILTER_REVERSED = 1 << 2, +} TB_ACCOUNT_FILTER_FLAGS; + +typedef struct tb_account_balance_t { + tb_uint128_t debits_pending; + tb_uint128_t debits_posted; + tb_uint128_t credits_pending; + tb_uint128_t credits_posted; + uint64_t timestamp; + uint8_t reserved[56]; +} tb_account_balance_t; + +typedef struct tb_query_filter_t { + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint32_t ledger; + uint16_t code; + uint8_t reserved[6]; + uint64_t timestamp_min; + uint64_t timestamp_max; + uint32_t limit; + uint32_t flags; +} tb_query_filter_t; + +typedef enum TB_QUERY_FILTER_FLAGS { + TB_QUERY_FILTER_REVERSED = 1 << 0, +} TB_QUERY_FILTER_FLAGS; + +// Opaque struct serving as a handle for the client instance. +// This struct must be "pinned" (not copyable or movable), as its address must remain stable +// throughout the lifetime of the client instance. +typedef struct tb_client_t { + uint64_t opaque[4]; +} tb_client_t; + +// Struct containing the state of a request submitted through the client. +// This struct must be "pinned" (not copyable or movable), as its address must remain stable +// throughout the lifetime of the request. +typedef struct tb_packet_t { + void* user_data; + void* data; + uint32_t data_size; + uint16_t user_tag; + uint8_t operation; + uint8_t status; + uint8_t opaque[64]; +} tb_packet_t; + +typedef enum TB_OPERATION { + TB_OPERATION_PULSE = 128, + TB_OPERATION_GET_CHANGE_EVENTS = 137, + TB_OPERATION_LOOKUP_ACCOUNTS = 140, + TB_OPERATION_LOOKUP_TRANSFERS = 141, + TB_OPERATION_GET_ACCOUNT_TRANSFERS = 142, + TB_OPERATION_GET_ACCOUNT_BALANCES = 143, + TB_OPERATION_QUERY_ACCOUNTS = 144, + TB_OPERATION_QUERY_TRANSFERS = 145, + TB_OPERATION_CREATE_ACCOUNTS = 146, + TB_OPERATION_CREATE_TRANSFERS = 147, +} TB_OPERATION; + +typedef enum TB_PACKET_STATUS { + TB_PACKET_OK = 0, + TB_PACKET_TOO_MUCH_DATA = 1, + TB_PACKET_CLIENT_EVICTED = 2, + TB_PACKET_CLIENT_RELEASE_TOO_LOW = 3, + TB_PACKET_CLIENT_RELEASE_TOO_HIGH = 4, + TB_PACKET_CLIENT_SHUTDOWN = 5, + TB_PACKET_INVALID_OPERATION = 6, + TB_PACKET_INVALID_DATA_SIZE = 7, +} TB_PACKET_STATUS; + +typedef enum TB_INIT_STATUS { + TB_INIT_SUCCESS = 0, + TB_INIT_UNEXPECTED = 1, + TB_INIT_OUT_OF_MEMORY = 2, + TB_INIT_ADDRESS_INVALID = 3, + TB_INIT_ADDRESS_LIMIT_EXCEEDED = 4, + TB_INIT_SYSTEM_RESOURCES = 5, + TB_INIT_NETWORK_SUBSYSTEM = 6, +} TB_INIT_STATUS; + +typedef enum TB_CLIENT_STATUS { + TB_CLIENT_OK = 0, + TB_CLIENT_INVALID = 1, +} TB_CLIENT_STATUS; + +typedef enum TB_REGISTER_LOG_CALLBACK_STATUS { + TB_REGISTER_LOG_CALLBACK_SUCCESS = 0, + TB_REGISTER_LOG_CALLBACK_ALREADY_REGISTERED = 1, + TB_REGISTER_LOG_CALLBACK_NOT_REGISTERED = 2, +} TB_REGISTER_LOG_CALLBACK_STATUS; + +typedef enum TB_LOG_LEVEL { + TB_LOG_ERR = 0, + TB_LOG_WARN = 1, + TB_LOG_INFO = 2, + TB_LOG_DEBUG = 3, +} TB_LOG_LEVEL; + +typedef struct tb_init_parameters_t { + tb_uint128_t cluster_id; + tb_uint128_t client_id; + uint8_t* addresses_ptr; + uint64_t addresses_len; +} tb_init_parameters_t; + +// Per-client callback invoked every time a `tb_client_submit` completes or is canceled. +// Use `packet->userdata` to identify the specific submission. +// `result` is null iff `packet->status != TB_PACKET_OK` +// `result` is only valid for the duration of the callback itself. +typedef void (*tb_completion_t)( + uintptr_t userdata, + tb_packet_t* packet, + uint64_t timestamp, + const uint8_t *result, // nullable + uint32_t result_size +); + +// Initialize a new TigerBeetle client which connects to the addresses provided and +// completes submitted packets by invoking the callback with the given context. +TB_INIT_STATUS tb_client_init( + tb_client_t *client_out, + // 128-bit unsigned integer represented as a 16-byte little-endian array. + const uint8_t cluster_id[16], + const char *address_ptr, + uint32_t address_len, + uintptr_t completion_ctx, + tb_completion_t completion_callback +); + +// Initialize a new TigerBeetle client that echoes back any submitted data. +TB_INIT_STATUS tb_client_init_echo( + tb_client_t *client_out, + // 128-bit unsigned integer represented as a 16-byte little-endian array. + const uint8_t cluster_id[16], + const char *address_ptr, + uint32_t address_len, + uintptr_t completion_ctx, + tb_completion_t completion_callback +); + +// Retrieve the parameters initially passed to `tb_client_init` or `tb_client_init_echo`. +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_init_parameters( + tb_client_t* client, + tb_init_parameters_t* init_parameters_out +); + +// Retrieve the callback context initially passed to `tb_client_init` or `tb_client_init_echo`. +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_completion_context( + tb_client_t* client, + uintptr_t* completion_ctx_out +); + +// Submit a packet with its `operation`, `data`, and `data_size` fields set. +// Once completed, `completion_callback` will be invoked with `completion_ctx` +// and the given packet on the `tb_client` thread (separate from the caller's thread). +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_submit( + tb_client_t *client, + tb_packet_t *packet +); + +// Closes the client, causing any previously submitted packets to be completed with +// `TB_PACKET_CLIENT_SHUTDOWN` before freeing any allocated client resources from init. +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_deinit( + tb_client_t *client +); + +// Registers or unregisters the application log callback. +TB_REGISTER_LOG_CALLBACK_STATUS tb_client_register_log_callback( + void (*callback)(TB_LOG_LEVEL, const uint8_t*, uint32_t), + bool debug +); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // TB_CLIENT_H diff --git a/ocam/src/clients/ruby/src/ext/tigerbeetle/tigerbeetle.c b/ocam/src/clients/ruby/src/ext/tigerbeetle/tigerbeetle.c new file mode 100644 index 00000000..7a701e02 --- /dev/null +++ b/ocam/src/clients/ruby/src/ext/tigerbeetle/tigerbeetle.c @@ -0,0 +1,310 @@ +// Ruby doesn't guarantee ABI stability, so we must use ruby.h present on the user's machine. +// We don't want to run a Zig compiler there though, so we have to implement this thin wrapper in C. +#include "rb_tb_gen.h" +#include "ruby.h" +#include "ruby/thread.h" +#include "tb_client.h" +#include +#include +#include +#include +#include +#include +#include + +typedef enum { + REQ_PENDING = 0, + REQ_DONE = 1, + REQ_ABANDONED = 2, +} rb_tb_request_state_t; + +typedef struct rb_tb_request { + // Set at init, read by callback. + TB_OPERATION operation; + uint8_t *send_buf; + tb_packet_t packet; + // Shared between Ruby thread and callback. + _Atomic rb_tb_request_state_t state; + // Written by callback, read by Ruby thread. + uint8_t status; + uint8_t *result; + uint32_t result_size; +} rb_tb_request_t; + +static VALUE rb_mTigerBeetle; +static VALUE rb_cRequest; +static VALUE rb_eInitError; +static VALUE rb_eClientClosedError; + +static void rb_tb_init_native_client(VALUE mTigerBeetle); + +void Init_tigerbeetle(void) { + rb_mTigerBeetle = rb_define_module("TigerBeetle"); + + rb_eInitError = rb_define_class_under(rb_mTigerBeetle, "InitError", rb_eStandardError); + rb_eClientClosedError = + rb_define_class_under(rb_mTigerBeetle, "ClientClosedError", rb_eStandardError); + rb_define_class_under(rb_mTigerBeetle, "PacketError", rb_eStandardError); + + rb_tb_init_native_client(rb_mTigerBeetle); +} + +static void rb_tb_request_free(void *ptr) { + rb_tb_request_t *req = (rb_tb_request_t *)ptr; + rb_tb_request_state_t expected = REQ_PENDING; + if (atomic_compare_exchange_strong(&req->state, &expected, REQ_ABANDONED)) { + // Callback hasn't fired yet. It will free req once it does. + return; + } + // Callback already fired (state == REQ_DONE), we own cleanup. + tb_assert(expected == REQ_DONE); + free(req->result); + free(req); +} + +static size_t rb_tb_request_size(const void *ptr) { + (void)ptr; + return sizeof(rb_tb_request_t); +} + +static const rb_data_type_t rb_tb_request_type = { + .wrap_struct_name = "TigerBeetle::Request", + .function = + { + .dmark = NULL, + .dfree = rb_tb_request_free, + .dsize = rb_tb_request_size, + }, + .flags = RUBY_TYPED_FREE_IMMEDIATELY, +}; + +// Request is only ever created from C via TypedData_Wrap_Struct. +static VALUE rb_tb_request_alloc(VALUE klass) { + (void)klass; + rb_raise(rb_eTypeError, "TigerBeetle::Request cannot be instantiated directly"); + return Qnil; // unreachable +} + +static VALUE rb_tb_request_result(VALUE self) { + rb_tb_request_t *req; + TypedData_Get_Struct(self, rb_tb_request_t, &rb_tb_request_type, req); + + VALUE result = Qnil; + if (req->status == TB_PACKET_OK) { + if (req->result_size == 0) { + result = rb_ary_new(); + } else { + if (!req->result) { + rb_raise(rb_eNoMemError, "failed to allocate result buffer"); + } + result = rb_tb_deserialize(req->operation, req->result, req->result_size); + } + } + + return rb_ary_new_from_args(2, RB_UINT2NUM(req->status), result); +} + +static void rb_tb_write_completion(int fd, rb_tb_request_t *req) { + uint64_t request_id = (uint64_t)(uintptr_t)req; + ssize_t written = write(fd, &request_id, sizeof(request_id)); + tb_assert(written == sizeof(request_id)); +} + +static void rb_tb_on_completion( + uintptr_t completion_ctx, + tb_packet_t *packet, + uint64_t timestamp, + const uint8_t *result, + uint32_t result_size +) { + (void)timestamp; + rb_tb_request_t *req = (rb_tb_request_t *)packet->user_data; + + free(req->send_buf); + req->send_buf = NULL; + req->status = packet->status; + + if (packet->status == TB_PACKET_OK) { + req->result_size = result_size; + tb_assert(result != NULL); + + if (result_size > 0) { + req->result = malloc(result_size); + if (req->result) { + memcpy(req->result, result, result_size); + } + } + } + + rb_tb_request_state_t expected = REQ_PENDING; + if (!atomic_compare_exchange_strong(&req->state, &expected, REQ_DONE)) { + // dfree already ran (state == REQ_ABANDONED). We own cleanup. + tb_assert(expected == REQ_ABANDONED); + free(req->result); + free(req); + return; + } + + rb_tb_write_completion((int)completion_ctx, req); +} + +static void rb_tb_client_free(void *ptr) { + tb_client_deinit((tb_client_t *)ptr); + ruby_xfree(ptr); +} + +static size_t rb_tb_client_size(const void *ptr) { + (void)ptr; + return sizeof(tb_client_t); +} + +static const rb_data_type_t rb_tb_client_type = { + .wrap_struct_name = "TigerBeetle::NativeClient", + .function = + { + .dmark = NULL, + .dfree = rb_tb_client_free, + .dsize = rb_tb_client_size, + }, + .flags = RUBY_TYPED_FREE_IMMEDIATELY, +}; + +static VALUE rb_tb_client_alloc(VALUE klass) { + tb_client_t *data = ruby_xmalloc(sizeof(tb_client_t)); + memset(data, 0, sizeof(tb_client_t)); + return TypedData_Wrap_Struct(klass, &rb_tb_client_type, data); +} + +static VALUE rb_tb_client_initialize( + VALUE self, VALUE cluster_id_rb, VALUE addresses_rb, VALUE completion_fd_rb +) { + tb_client_t *client; + TypedData_Get_Struct(self, tb_client_t, &rb_tb_client_type, client); + + uint8_t cluster_id_bytes[16] = {0}; + rb_tb_pack_u128(cluster_id_rb, cluster_id_bytes); + + const char *addr = StringValueCStr(addresses_rb); + uint32_t addr_len = (uint32_t)RSTRING_LEN(addresses_rb); + + int completion_fd = RB_NUM2INT(completion_fd_rb); + + TB_INIT_STATUS status = tb_client_init( + client, cluster_id_bytes, addr, addr_len, (uintptr_t)completion_fd, rb_tb_on_completion + ); + + if (status != TB_INIT_SUCCESS) { + rb_raise(rb_eInitError, "Init error: %s", rb_tb_init_error_message(status)); + } + + return self; +} + +static void *rb_tb_deinit_without_gvl(void *arg) { + tb_client_deinit((tb_client_t *)arg); + return NULL; +} + +static VALUE rb_tb_client_close(VALUE self) { + tb_client_t *client; + TypedData_Get_Struct(self, tb_client_t, &rb_tb_client_type, client); + + // deinit blocks until all in-flight callbacks complete. The GVL is released + // while waiting so other Ruby threads can run. + rb_thread_call_without_gvl(rb_tb_deinit_without_gvl, client, NULL, NULL); + return Qnil; +} + +typedef struct rb_tb_serialize_context { + TB_OPERATION operation; + VALUE items_rb; + uint8_t *buf; + long count; +} rb_tb_serialize_context_t; + +static VALUE rb_tb_serialize_protected(VALUE context_value) { + rb_tb_serialize_context_t *context = (rb_tb_serialize_context_t *)context_value; + rb_tb_serialize(context->operation, context->items_rb, context->buf, context->count); + return Qnil; +} + +static VALUE rb_tb_client_submit(VALUE self, VALUE operation_rb, VALUE items_rb) { + tb_client_t *client; + TypedData_Get_Struct(self, tb_client_t, &rb_tb_client_type, client); + + TB_OPERATION operation = (TB_OPERATION)RB_NUM2INT(operation_rb); + long count = RARRAY_LEN(items_rb); + + rb_tb_request_t *req = malloc(sizeof(rb_tb_request_t)); + if (!req) { + rb_raise(rb_eNoMemError, "failed to allocate request"); + } + memset(req, 0, sizeof(rb_tb_request_t)); + req->operation = operation; + + if (count > 0) { + size_t event_size = rb_tb_event_size(operation); + long max_count = (long)(UINT32_MAX / event_size); + if (count > max_count) { + free(req); + rb_raise(rb_eArgError, "batch size exceeds maximum request size"); + } + size_t send_size = event_size * (size_t)count; + req->send_buf = malloc(send_size); + if (!req->send_buf) { + free(req); + rb_raise(rb_eNoMemError, "failed to allocate send buffer"); + } + + rb_tb_serialize_context_t serialize_context = { + .operation = operation, + .items_rb = items_rb, + .buf = req->send_buf, + .count = count, + }; + int serialize_state = 0; + rb_protect(rb_tb_serialize_protected, (VALUE)&serialize_context, &serialize_state); + if (serialize_state) { + free(req->send_buf); + free(req); + rb_jump_tag(serialize_state); + } + + req->packet.data_size = (uint32_t)send_size; + } + + req->packet.data = req->send_buf; + req->packet.operation = (uint8_t)operation; + req->packet.user_data = req; + + TB_CLIENT_STATUS cs = tb_client_submit(client, &req->packet); + if (cs == TB_CLIENT_INVALID) { + free(req->send_buf); + free(req); + rb_raise(rb_eClientClosedError, "client is closed"); + } + + return TypedData_Wrap_Struct(rb_cRequest, &rb_tb_request_type, req); +} + +static VALUE rb_tb_request_id(VALUE self) { + rb_tb_request_t *req; + TypedData_Get_Struct(self, rb_tb_request_t, &rb_tb_request_type, req); + return RB_ULL2NUM((unsigned long long)(uintptr_t)req); +} + +static void rb_tb_init_native_client(VALUE mTigerBeetle) { + rb_define_const(mTigerBeetle, "PACKET_OK", RB_INT2NUM(TB_PACKET_OK)); + rb_define_const(mTigerBeetle, "PACKET_CLIENT_SHUTDOWN", RB_INT2NUM(TB_PACKET_CLIENT_SHUTDOWN)); + + VALUE cNativeClient = rb_define_class_under(mTigerBeetle, "NativeClient", rb_cObject); + rb_define_alloc_func(cNativeClient, rb_tb_client_alloc); + rb_define_method(cNativeClient, "initialize", rb_tb_client_initialize, 3); + rb_define_method(cNativeClient, "submit", rb_tb_client_submit, 2); + rb_define_method(cNativeClient, "close", rb_tb_client_close, 0); + + rb_cRequest = rb_define_class_under(mTigerBeetle, "Request", rb_cObject); + rb_define_alloc_func(rb_cRequest, rb_tb_request_alloc); + rb_define_method(rb_cRequest, "id", rb_tb_request_id, 0); + rb_define_method(rb_cRequest, "result", rb_tb_request_result, 0); +} diff --git a/ocam/src/clients/ruby/src/tigerbeetle.rb b/ocam/src/clients/ruby/src/tigerbeetle.rb new file mode 100644 index 00000000..a9611b08 --- /dev/null +++ b/ocam/src/clients/ruby/src/tigerbeetle.rb @@ -0,0 +1,39 @@ +require_relative "tigerbeetle/version" +require_relative "tigerbeetle/bindings" +require_relative "tigerbeetle/id" +require_relative "tigerbeetle/completion_dispatcher" +require_relative "tigerbeetle/client" + +native_extension = "tigerbeetle/tigerbeetle" + +# Since we ship a fat binary gem, enable the bundled DLL path before loading the extension. +if Gem.win_platform? + dll_path = File.expand_path("ext/tigerbeetle/lib/x86_64-windows", __dir__) + + begin + require "ruby_installer/runtime" + RubyInstaller::Runtime.add_dll_directory(dll_path) do + require native_extension + end + rescue LoadError + old_path = ENV["PATH"] + ENV["PATH"] = "#{dll_path};#{old_path}" + require native_extension + ensure + ENV["PATH"] = old_path if defined?(old_path) + end +else + require native_extension +end + +module TigerBeetle + private_constant :NativeClient + private_constant :Request + + @id_generator = ID.new + + # Generates a 128-bit, time-based, monotonically increasing ID. + def self.id + @id_generator.generate + end +end diff --git a/ocam/src/clients/ruby/src/tigerbeetle/bindings.rb b/ocam/src/clients/ruby/src/tigerbeetle/bindings.rb new file mode 100644 index 00000000..fbfdc48e --- /dev/null +++ b/ocam/src/clients/ruby/src/tigerbeetle/bindings.rb @@ -0,0 +1,355 @@ +######################################################## +## This file was auto-generated by ruby_bindings.zig ## +## Do not manually modify. ## +######################################################## + +module TigerBeetle + module AccountFlags + NONE = 0 + LINKED = 1 << 0 + DEBITS_MUST_NOT_EXCEED_CREDITS = 1 << 1 + CREDITS_MUST_NOT_EXCEED_DEBITS = 1 << 2 + HISTORY = 1 << 3 + IMPORTED = 1 << 4 + CLOSED = 1 << 5 + end + + module TransferFlags + NONE = 0 + LINKED = 1 << 0 + PENDING = 1 << 1 + POST_PENDING_TRANSFER = 1 << 2 + VOID_PENDING_TRANSFER = 1 << 3 + BALANCING_DEBIT = 1 << 4 + BALANCING_CREDIT = 1 << 5 + CLOSING_DEBIT = 1 << 6 + CLOSING_CREDIT = 1 << 7 + IMPORTED = 1 << 8 + end + + module AccountFilterFlags + NONE = 0 + DEBITS = 1 << 0 + CREDITS = 1 << 1 + REVERSED = 1 << 2 + end + + module QueryFilterFlags + NONE = 0 + REVERSED = 1 << 0 + end + + module Operation + LOOKUP_ACCOUNTS = 140 + LOOKUP_TRANSFERS = 141 + GET_ACCOUNT_TRANSFERS = 142 + GET_ACCOUNT_BALANCES = 143 + QUERY_ACCOUNTS = 144 + QUERY_TRANSFERS = 145 + CREATE_ACCOUNTS = 146 + CREATE_TRANSFERS = 147 + end + + module CreateAccountStatus + CREATED = 4294967295 + LINKED_EVENT_FAILED = 1 + LINKED_EVENT_CHAIN_OPEN = 2 + IMPORTED_EVENT_EXPECTED = 22 + IMPORTED_EVENT_NOT_EXPECTED = 23 + TIMESTAMP_MUST_BE_ZERO = 3 + IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE = 24 + IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE = 25 + RESERVED_FIELD = 4 + RESERVED_FLAG = 5 + ID_MUST_NOT_BE_ZERO = 6 + ID_MUST_NOT_BE_INT_MAX = 7 + EXISTS_WITH_DIFFERENT_FLAGS = 15 + EXISTS_WITH_DIFFERENT_USER_DATA_128 = 16 + EXISTS_WITH_DIFFERENT_USER_DATA_64 = 17 + EXISTS_WITH_DIFFERENT_USER_DATA_32 = 18 + EXISTS_WITH_DIFFERENT_LEDGER = 19 + EXISTS_WITH_DIFFERENT_CODE = 20 + EXISTS = 21 + FLAGS_ARE_MUTUALLY_EXCLUSIVE = 8 + DEBITS_PENDING_MUST_BE_ZERO = 9 + DEBITS_POSTED_MUST_BE_ZERO = 10 + CREDITS_PENDING_MUST_BE_ZERO = 11 + CREDITS_POSTED_MUST_BE_ZERO = 12 + LEDGER_MUST_NOT_BE_ZERO = 13 + CODE_MUST_NOT_BE_ZERO = 14 + IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS = 26 + end + + module CreateTransferStatus + CREATED = 4294967295 + LINKED_EVENT_FAILED = 1 + LINKED_EVENT_CHAIN_OPEN = 2 + IMPORTED_EVENT_EXPECTED = 56 + IMPORTED_EVENT_NOT_EXPECTED = 57 + TIMESTAMP_MUST_BE_ZERO = 3 + IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE = 58 + IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE = 59 + RESERVED_FLAG = 4 + ID_MUST_NOT_BE_ZERO = 5 + ID_MUST_NOT_BE_INT_MAX = 6 + EXISTS_WITH_DIFFERENT_FLAGS = 36 + EXISTS_WITH_DIFFERENT_PENDING_ID = 40 + EXISTS_WITH_DIFFERENT_TIMEOUT = 44 + EXISTS_WITH_DIFFERENT_DEBIT_ACCOUNT_ID = 37 + EXISTS_WITH_DIFFERENT_CREDIT_ACCOUNT_ID = 38 + EXISTS_WITH_DIFFERENT_AMOUNT = 39 + EXISTS_WITH_DIFFERENT_USER_DATA_128 = 41 + EXISTS_WITH_DIFFERENT_USER_DATA_64 = 42 + EXISTS_WITH_DIFFERENT_USER_DATA_32 = 43 + EXISTS_WITH_DIFFERENT_LEDGER = 67 + EXISTS_WITH_DIFFERENT_CODE = 45 + EXISTS = 46 + ID_ALREADY_FAILED = 68 + FLAGS_ARE_MUTUALLY_EXCLUSIVE = 7 + DEBIT_ACCOUNT_ID_MUST_NOT_BE_ZERO = 8 + DEBIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 9 + CREDIT_ACCOUNT_ID_MUST_NOT_BE_ZERO = 10 + CREDIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 11 + ACCOUNTS_MUST_BE_DIFFERENT = 12 + PENDING_ID_MUST_BE_ZERO = 13 + PENDING_ID_MUST_NOT_BE_ZERO = 14 + PENDING_ID_MUST_NOT_BE_INT_MAX = 15 + PENDING_ID_MUST_BE_DIFFERENT = 16 + TIMEOUT_RESERVED_FOR_PENDING_TRANSFER = 17 + CLOSING_TRANSFER_MUST_BE_PENDING = 64 + LEDGER_MUST_NOT_BE_ZERO = 19 + CODE_MUST_NOT_BE_ZERO = 20 + DEBIT_ACCOUNT_NOT_FOUND = 21 + CREDIT_ACCOUNT_NOT_FOUND = 22 + ACCOUNTS_MUST_HAVE_THE_SAME_LEDGER = 23 + TRANSFER_MUST_HAVE_THE_SAME_LEDGER_AS_ACCOUNTS = 24 + PENDING_TRANSFER_NOT_FOUND = 25 + PENDING_TRANSFER_NOT_PENDING = 26 + PENDING_TRANSFER_HAS_DIFFERENT_DEBIT_ACCOUNT_ID = 27 + PENDING_TRANSFER_HAS_DIFFERENT_CREDIT_ACCOUNT_ID = 28 + PENDING_TRANSFER_HAS_DIFFERENT_LEDGER = 29 + PENDING_TRANSFER_HAS_DIFFERENT_CODE = 30 + EXCEEDS_PENDING_TRANSFER_AMOUNT = 31 + PENDING_TRANSFER_HAS_DIFFERENT_AMOUNT = 32 + PENDING_TRANSFER_ALREADY_POSTED = 33 + PENDING_TRANSFER_ALREADY_VOIDED = 34 + PENDING_TRANSFER_EXPIRED = 35 + IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS = 60 + IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_DEBIT_ACCOUNT = 61 + IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_CREDIT_ACCOUNT = 62 + IMPORTED_EVENT_TIMEOUT_MUST_BE_ZERO = 63 + DEBIT_ACCOUNT_ALREADY_CLOSED = 65 + CREDIT_ACCOUNT_ALREADY_CLOSED = 66 + OVERFLOWS_DEBITS_PENDING = 47 + OVERFLOWS_CREDITS_PENDING = 48 + OVERFLOWS_DEBITS_POSTED = 49 + OVERFLOWS_CREDITS_POSTED = 50 + OVERFLOWS_DEBITS = 51 + OVERFLOWS_CREDITS = 52 + OVERFLOWS_TIMEOUT = 53 + EXCEEDS_CREDITS = 54 + EXCEEDS_DEBITS = 55 + end + + class Account + attr_accessor :id + attr_accessor :debits_pending + attr_accessor :debits_posted + attr_accessor :credits_pending + attr_accessor :credits_posted + attr_accessor :user_data_128 + attr_accessor :user_data_64 + attr_accessor :user_data_32 + attr_accessor :ledger + attr_accessor :code + attr_accessor :flags + attr_accessor :timestamp + + def initialize( + id: 0, + debits_pending: 0, + debits_posted: 0, + credits_pending: 0, + credits_posted: 0, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + ledger: 0, + code: 0, + flags: AccountFlags::NONE, + timestamp: 0 + ) + @id = id + @debits_pending = debits_pending + @debits_posted = debits_posted + @credits_pending = credits_pending + @credits_posted = credits_posted + @user_data_128 = user_data_128 + @user_data_64 = user_data_64 + @user_data_32 = user_data_32 + @ledger = ledger + @code = code + @flags = flags + @timestamp = timestamp + end + end + + class Transfer + attr_accessor :id + attr_accessor :debit_account_id + attr_accessor :credit_account_id + attr_accessor :amount + attr_accessor :pending_id + attr_accessor :user_data_128 + attr_accessor :user_data_64 + attr_accessor :user_data_32 + attr_accessor :timeout + attr_accessor :ledger + attr_accessor :code + attr_accessor :flags + attr_accessor :timestamp + + def initialize( + id: 0, + debit_account_id: 0, + credit_account_id: 0, + amount: 0, + pending_id: 0, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + timeout: 0, + ledger: 0, + code: 0, + flags: TransferFlags::NONE, + timestamp: 0 + ) + @id = id + @debit_account_id = debit_account_id + @credit_account_id = credit_account_id + @amount = amount + @pending_id = pending_id + @user_data_128 = user_data_128 + @user_data_64 = user_data_64 + @user_data_32 = user_data_32 + @timeout = timeout + @ledger = ledger + @code = code + @flags = flags + @timestamp = timestamp + end + end + + class AccountFilter + attr_accessor :account_id + attr_accessor :user_data_128 + attr_accessor :user_data_64 + attr_accessor :user_data_32 + attr_accessor :code + attr_accessor :timestamp_min + attr_accessor :timestamp_max + attr_accessor :limit + attr_accessor :flags + + def initialize( + account_id: 0, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + code: 0, + timestamp_min: 0, + timestamp_max: 0, + limit: 0, + flags: AccountFilterFlags::NONE + ) + @account_id = account_id + @user_data_128 = user_data_128 + @user_data_64 = user_data_64 + @user_data_32 = user_data_32 + @code = code + @timestamp_min = timestamp_min + @timestamp_max = timestamp_max + @limit = limit + @flags = flags + end + end + + class QueryFilter + attr_accessor :user_data_128 + attr_accessor :user_data_64 + attr_accessor :user_data_32 + attr_accessor :ledger + attr_accessor :code + attr_accessor :timestamp_min + attr_accessor :timestamp_max + attr_accessor :limit + attr_accessor :flags + + def initialize( + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + ledger: 0, + code: 0, + timestamp_min: 0, + timestamp_max: 0, + limit: 0, + flags: QueryFilterFlags::NONE + ) + @user_data_128 = user_data_128 + @user_data_64 = user_data_64 + @user_data_32 = user_data_32 + @ledger = ledger + @code = code + @timestamp_min = timestamp_min + @timestamp_max = timestamp_max + @limit = limit + @flags = flags + end + end + + class AccountBalance + attr_reader :debits_pending + attr_reader :debits_posted + attr_reader :credits_pending + attr_reader :credits_posted + attr_reader :timestamp + + def initialize + @debits_pending = 0 + @debits_posted = 0 + @credits_pending = 0 + @credits_posted = 0 + @timestamp = 0 + end + end + + class CreateAccountResult + attr_reader :timestamp + attr_reader :status + attr_reader :status_name + + def initialize + @timestamp = 0 + @status = 0 + end + + def to_s = + "#<#{self.class} timestamp=#{@timestamp} status_name=#{@status_name}>" + end + + class CreateTransferResult + attr_reader :timestamp + attr_reader :status + attr_reader :status_name + + def initialize + @timestamp = 0 + @status = 0 + end + + def to_s = + "#<#{self.class} timestamp=#{@timestamp} status_name=#{@status_name}>" + end + +end diff --git a/ocam/src/clients/ruby/src/tigerbeetle/client.rb b/ocam/src/clients/ruby/src/tigerbeetle/client.rb new file mode 100644 index 00000000..8cff2a20 --- /dev/null +++ b/ocam/src/clients/ruby/src/tigerbeetle/client.rb @@ -0,0 +1,129 @@ +module TigerBeetle + # TigerBeetle client. + # + # A client can be shared by concurrent threads and fibers for request methods. + # Public request methods are synchronous and return after the request + # completes. When a fiber scheduler is active, waiting for a response yields + # to the scheduler. + # + # Prefer {.open} for lifecycle management so the client is closed once after + # concurrent work completes. Instantiate multiple clients when connecting to + # more than one TigerBeetle cluster. + class Client + Client::COMPLETION_DISPATCHER = CompletionDispatcher.new + private_constant :COMPLETION_DISPATCHER + + # Opens a client for the duration of the block and closes it before + # returning. + # + # Yields the open client and returns the block return value. + def self.open(cluster_id:, replica_addresses:) + client = new(cluster_id: cluster_id, replica_addresses: replica_addresses) + yield client + ensure + client&.close + end + + # Initializes a TigerBeetle client. + # + # @raise [TigerBeetle::InitError] if the native client cannot be + # initialized. + def initialize(cluster_id:, replica_addresses:) + @native = NativeClient.new(cluster_id, replica_addresses, COMPLETION_DISPATCHER.write_fileno) + @closed = false + end + + # Closes the client. This method waits for all in-flight requests to finish. + # + # @raise [TigerBeetle::ClientClosedError] if the client is already closed. + def close + raise ClientClosedError, "client is already closed" if closed? + + @closed = true + @native.close + end + + # Returns whether the client has been closed. + def closed? + @closed + end + + # Submits a batch of new accounts to be created. + # + # The returned array contains one {TigerBeetle::CreateAccountResult} for + # each submitted account. + # + # @raise [TigerBeetle::PacketError] if the entire request fails. + # @raise [TigerBeetle::ClientClosedError] if the client is closed. + def create_accounts(accounts) = native_submit(Operation::CREATE_ACCOUNTS, accounts) + + # Submits a batch of new transfers to be created. + # + # The returned array contains one {TigerBeetle::CreateTransferResult} for + # each submitted transfer. + # + # @raise [TigerBeetle::PacketError] if the entire request fails. + # @raise [TigerBeetle::ClientClosedError] if the client is closed. + def create_transfers(transfers) = native_submit(Operation::CREATE_TRANSFERS, transfers) + + # Looks up a batch of accounts. + # + # The returned array contains all accounts found. Accounts not found are + # omitted. + # + # @raise [TigerBeetle::PacketError] if the entire request fails. + # @raise [TigerBeetle::ClientClosedError] if the client is closed. + def lookup_accounts(ids) = native_submit(Operation::LOOKUP_ACCOUNTS, ids) + + # Looks up a batch of transfers. + # + # The returned array contains all transfers found. Transfers not found are + # omitted. + # + # @raise [TigerBeetle::PacketError] if the entire request fails. + # @raise [TigerBeetle::ClientClosedError] if the client is closed. + def lookup_transfers(ids) = native_submit(Operation::LOOKUP_TRANSFERS, ids) + + # Fetches transfers from a given account. + # + # Returns transfers that match the query parameters. + # @raise [TigerBeetle::PacketError] if the entire request fails. + # @raise [TigerBeetle::ClientClosedError] if the client is closed. + def get_account_transfers(filter) = native_submit(Operation::GET_ACCOUNT_TRANSFERS, [filter]) + + # Fetches balance history from a given account. + # + # Returns balances that match the query parameters. + # @raise [TigerBeetle::PacketError] if the entire request fails. + # @raise [TigerBeetle::ClientClosedError] if the client is closed. + def get_account_balances(filter) = native_submit(Operation::GET_ACCOUNT_BALANCES, [filter]) + + # Queries accounts. + # + # Returns accounts that match the query parameters. + # @raise [TigerBeetle::PacketError] if the entire request fails. + # @raise [TigerBeetle::ClientClosedError] if the client is closed. + def query_accounts(filter) = native_submit(Operation::QUERY_ACCOUNTS, [filter]) + + # Queries transfers. + # + # Returns transfers that match the query parameters. + # @raise [TigerBeetle::PacketError] if the entire request fails. + # @raise [TigerBeetle::ClientClosedError] if the client is closed. + def query_transfers(filter) = native_submit(Operation::QUERY_TRANSFERS, [filter]) + + private + + def native_submit(operation, payload) + raise ClientClosedError if closed? + + req = COMPLETION_DISPATCHER.submit_and_wait_for(@native, operation, payload) + + status, result = req.result + raise ClientClosedError if status == PACKET_CLIENT_SHUTDOWN + raise PacketError, status unless status == PACKET_OK + + result + end + end +end diff --git a/ocam/src/clients/ruby/src/tigerbeetle/completion_dispatcher.rb b/ocam/src/clients/ruby/src/tigerbeetle/completion_dispatcher.rb new file mode 100644 index 00000000..d46c7650 --- /dev/null +++ b/ocam/src/clients/ruby/src/tigerbeetle/completion_dispatcher.rb @@ -0,0 +1,108 @@ +module TigerBeetle + class CompletionDispatcher + COMPLETION_ID_BYTES = 8 + + class Completion + def initialize + @mutex = Mutex.new + @condition = ConditionVariable.new + @done = false + end + + def done? + @done + end + + def wait + @mutex.synchronize { @condition.wait(@mutex) until done? } + end + + def complete + @mutex.synchronize do + @done = true + @condition.broadcast + end + end + end + + def initialize + @read_io, @write_io = IO.pipe + @pending = {} + @completed_ids = {} + @cancelled_ids = {} + @mutex = Mutex.new + @thread = Thread.new { dispatch_completions } + end + + def write_fileno + @write_io.fileno + end + + def completion_for(request_id) + completion = Completion.new + completion.complete if register(request_id, completion) + completion + end + + def submit_and_wait_for(native, operation, payload) + request = native.submit(operation, payload) + completion = completion_for(request.id) + completion.wait + request + ensure + unregister(request.id, completion) if request + end + + def unregister(request_id, completion) + @mutex.synchronize do + @pending.delete(request_id) + @completed_ids.delete(request_id) + @cancelled_ids[request_id] = true unless completion&.done? + end + end + + private + + def register(request_id, completion) + @mutex.synchronize do + if @completed_ids.delete(request_id) + true + else + @pending[request_id] = completion + false + end + end + end + + def dispatch_completions + loop do + request_id = read_completion_id + completion = @mutex.synchronize do + completion = @pending[request_id] + if completion + completion + elsif @cancelled_ids.delete(request_id) + nil + else + @completed_ids[request_id] = true + nil + end + end + + completion&.complete + end + + rescue IOError, ClientClosedError + nil + end + + def read_completion_id + bytes = @read_io.read(COMPLETION_ID_BYTES) + raise ClientClosedError unless bytes + + bytes.unpack1("Q") + end + end + + private_constant :CompletionDispatcher +end diff --git a/ocam/src/clients/ruby/src/tigerbeetle/id.rb b/ocam/src/clients/ruby/src/tigerbeetle/id.rb new file mode 100644 index 00000000..791eb91f --- /dev/null +++ b/ocam/src/clients/ruby/src/tigerbeetle/id.rb @@ -0,0 +1,40 @@ +require "securerandom" + +module TigerBeetle + class ID + RANDOM_MAX = 2 ** 80 + TIMESTAMP_MAX = 2 ** 48 + + def initialize + @last_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) + @random = next_random + end + + def generate + ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) + if ms <= @last_ms + ms = @last_ms + else + @last_ms = ms + @random = next_random + end + + @random += 1 + if @random >= RANDOM_MAX + @last_ms += 1 + raise "Timestamp bits overflow on monotonic increment" if @last_ms >= TIMESTAMP_MAX + @random = 0 + end + + (@last_ms << 80) | @random + end + + private + + def next_random + # See: https://docs.ruby-lang.org/en/4.0/language/packed_data_rdoc.html#label-For+Integers + lo, hi = SecureRandom.bytes(10).unpack("Q, 0) + assert_equal(TigerBeetle::CreateAccountStatus::CREATED, results[0].status) + assert_equal(:created, results[0].status_name) + assert_equal( + "#", + results[0].to_s + ) + end + + def test_create_account_duplicate + account = TigerBeetle::Account.new(id: TigerBeetle.id, ledger: 1, code: 1) + + @client.create_accounts([account]) + + results = @client.create_accounts([account]) + assert_equal(1, results.length) + assert_equal(TigerBeetle::CreateAccountStatus::EXISTS, results[0].status) + assert_equal(:exists, results[0].status_name) + end + + def test_create_account_id_zero + account = TigerBeetle::Account.new(id: 0, ledger: 1, code: 1) + + results = @client.create_accounts([account]) + assert_equal(1, results.length) + assert_equal(TigerBeetle::CreateAccountStatus::ID_MUST_NOT_BE_ZERO, results[0].status) + assert_equal(:id_must_not_be_zero, results[0].status_name) + end + + def test_create_account_ledger_zero + account = TigerBeetle::Account.new(id: TigerBeetle.id, ledger: 0, code: 1) + + results = @client.create_accounts([account]) + assert_equal(1, results.length) + assert_equal(TigerBeetle::CreateAccountStatus::LEDGER_MUST_NOT_BE_ZERO, results[0].status) + end + + def test_create_account_code_zero + account = TigerBeetle::Account.new(id: TigerBeetle.id, ledger: 1, code: 0) + + results = @client.create_accounts([account]) + assert_equal(1, results.length) + assert_equal(TigerBeetle::CreateAccountStatus::CODE_MUST_NOT_BE_ZERO, results[0].status) + end + + def test_create_account_mutually_exclusive_flags + account = TigerBeetle::Account.new( + id: TigerBeetle.id, + ledger: 1, + code: 1, + flags: TigerBeetle::AccountFlags::DEBITS_MUST_NOT_EXCEED_CREDITS | + TigerBeetle::AccountFlags::CREDITS_MUST_NOT_EXCEED_DEBITS + ) + + results = @client.create_accounts([account]) + assert_equal(1, results.length) + assert_equal(TigerBeetle::CreateAccountStatus::FLAGS_ARE_MUTUALLY_EXCLUSIVE, results[0].status) + end + + def test_lookup_account + id = TigerBeetle.id + account = TigerBeetle::Account.new(id: id, ledger: 1, code: 1) + + @client.create_accounts([account]) + + results = @client.lookup_accounts([id]) + assert_equal(1, results.length) + assert_equal(id, results[0].id) + assert_equal(1, results[0].ledger) + assert_equal(1, results[0].code) + end + + def test_lookup_account_not_found + id = TigerBeetle.id + + results = @client.lookup_accounts([id]) + assert_equal(0, results.length) + end + + def test_lookup_accounts_multiple + id1 = TigerBeetle.id + id2 = TigerBeetle.id + @client.create_accounts( + [ + TigerBeetle::Account.new(id: id1, ledger: 1, code: 1), + TigerBeetle::Account.new(id: id2, ledger: 2, code: 2) + ] + ) + + results = @client.lookup_accounts([id1, id2]) + assert_equal(2, results.length) + r1 = results.find { |result| result.id == id1 } + r2 = results.find { |result| result.id == id2 } + refute_nil(r1) + refute_nil(r2) + assert_equal(1, r1.ledger) + assert_equal(2, r2.ledger) + end + + def test_lookup_accounts_partial_match + existing_id = TigerBeetle.id + missing_id = TigerBeetle.id + @client.create_accounts( + [TigerBeetle::Account.new(id: existing_id, ledger: 1, code: 1)] + ) + + results = @client.lookup_accounts([existing_id, missing_id]) + assert_equal(1, results.length) + assert_equal(existing_id, results[0].id) + end + + def test_lookup_accounts_empty_batch + results = @client.lookup_accounts([]) + assert_equal(0, results.length) + end + + def test_lookup_account_field_roundtrip + id = TigerBeetle.id + user_data_128 = TigerBeetle.id + user_data_64 = 9_999_999_999 + user_data_32 = 12345 + @client.create_accounts( + [ + TigerBeetle::Account.new( + id: id, + ledger: 7, + code: 42, + user_data_128: user_data_128, + user_data_64: user_data_64, + user_data_32: user_data_32, + flags: TigerBeetle::AccountFlags::HISTORY + ) + ] + ) + + results = @client.lookup_accounts([id]) + assert_equal(1, results.length) + acc = results[0] + assert_equal(id, acc.id) + assert_equal(7, acc.ledger) + assert_equal(42, acc.code) + assert_equal(user_data_128, acc.user_data_128) + assert_equal(user_data_64, acc.user_data_64) + assert_equal(user_data_32, acc.user_data_32) + assert_equal(TigerBeetle::AccountFlags::HISTORY, acc.flags) + assert_operator(acc.timestamp, :>, 0) + end +end diff --git a/ocam/src/clients/ruby/tests/integration/test_account_filter.rb b/ocam/src/clients/ruby/tests/integration/test_account_filter.rb new file mode 100644 index 00000000..f407dc91 --- /dev/null +++ b/ocam/src/clients/ruby/tests/integration/test_account_filter.rb @@ -0,0 +1,157 @@ +require_relative "tiger_beetle_integration_test" + +class TestAccountFilter < TigerBeetleIntegrationTest + def test_get_account_transfers_and_balances + account_id = TigerBeetle.id + debit_account_id = TigerBeetle.id + credit_account_id = TigerBeetle.id + @client.create_accounts( + [ + account( + id: account_id, + flags: TigerBeetle::AccountFlags::HISTORY + ), + account(id: debit_account_id), + account(id: credit_account_id) + ] + ) + + transfer_ids = Array.new(4) { TigerBeetle.id } + @client.create_transfers( + [ + transfer( + id: transfer_ids[0], + debit_account_id: account_id, + credit_account_id: credit_account_id, + amount: 10 + ), + transfer( + id: transfer_ids[1], + debit_account_id: debit_account_id, + credit_account_id: account_id, + amount: 20 + ), + transfer( + id: transfer_ids[2], + debit_account_id: account_id, + credit_account_id: credit_account_id, + amount: 30, + code: 2 + ), + transfer( + id: transfer_ids[3], + debit_account_id: debit_account_id, + credit_account_id: account_id, + amount: 40, + code: 2 + ) + ] + ) + + filter = TigerBeetle::AccountFilter.new( + account_id: account_id, + limit: 10, + flags: TigerBeetle::AccountFilterFlags::DEBITS | TigerBeetle::AccountFilterFlags::CREDITS + ) + + transfers = @client.get_account_transfers(filter) + balances = @client.get_account_balances(filter) + + assert_equal(transfer_ids, transfers.map(&:id)) + assert_equal(transfers.map(&:timestamp), balances.map(&:timestamp)) + assert_equal([10, 10, 40, 40], balances.map(&:debits_posted)) + assert_equal([0, 20, 20, 60], balances.map(&:credits_posted)) + end + + def test_get_account_transfers_filters_debits_and_credits + account_id = TigerBeetle.id + debit_account_id = TigerBeetle.id + credit_account_id = TigerBeetle.id + @client.create_accounts( + [ + account(id: account_id), + account(id: debit_account_id), + account(id: credit_account_id) + ] + ) + + debit_transfer_id = TigerBeetle.id + credit_transfer_id = TigerBeetle.id + @client.create_transfers( + [ + transfer( + id: debit_transfer_id, + debit_account_id: account_id, + credit_account_id: credit_account_id, + amount: 10 + ), + transfer( + id: credit_transfer_id, + debit_account_id: debit_account_id, + credit_account_id: account_id, + amount: 20 + ) + ] + ) + + debit_transfers = @client.get_account_transfers( + TigerBeetle::AccountFilter.new( + account_id: account_id, + limit: 10, + flags: TigerBeetle::AccountFilterFlags::DEBITS + ) + ) + credit_transfers = @client.get_account_transfers( + TigerBeetle::AccountFilter.new( + account_id: account_id, + limit: 10, + flags: TigerBeetle::AccountFilterFlags::CREDITS + ) + ) + + assert_equal([debit_transfer_id], debit_transfers.map(&:id)) + assert_equal([credit_transfer_id], credit_transfers.map(&:id)) + end + + def test_get_account_transfers_empty_result + results = @client.get_account_transfers( + TigerBeetle::AccountFilter.new(account_id: TigerBeetle.id, limit: 10) + ) + + assert_empty(results) + end + + def test_get_account_balances_empty_result + results = @client.get_account_balances( + TigerBeetle::AccountFilter.new(account_id: TigerBeetle.id, limit: 10) + ) + + assert_empty(results) + end + + def test_account_filter_operations_raise_after_close + client = TigerBeetle::Client.new(cluster_id: 0, replica_addresses: @tb_address) + client.close + filter = TigerBeetle::AccountFilter.new(account_id: TigerBeetle.id, limit: 1) + + assert_raises(TigerBeetle::ClientClosedError) { client.get_account_transfers(filter) } + assert_raises(TigerBeetle::ClientClosedError) { client.get_account_balances(filter) } + end + + private + + def account(id: TigerBeetle.id, ledger: 1, code: 1, flags: TigerBeetle::AccountFlags::NONE) + TigerBeetle::Account.new(id:, ledger:, code:, flags:) + end + + def transfer( + id: TigerBeetle.id, + debit_account_id:, + credit_account_id:, + amount:, + ledger: 1, + code: 1 + ) + TigerBeetle::Transfer.new(id:, debit_account_id:, credit_account_id:, amount:, ledger:, code:) + end +end diff --git a/ocam/src/clients/ruby/tests/integration/test_client_lifecycle.rb b/ocam/src/clients/ruby/tests/integration/test_client_lifecycle.rb new file mode 100644 index 00000000..47c60336 --- /dev/null +++ b/ocam/src/clients/ruby/tests/integration/test_client_lifecycle.rb @@ -0,0 +1,135 @@ +require "minitest/autorun" +require "tigerbeetle" + +class TestClientLifecycle < Minitest::Test + def setup + @tb_address = ENV.fetch("TB_ADDRESS", "3000") + end + + def test_connect_and_close + client = TigerBeetle::Client.new(cluster_id: 0, replica_addresses: @tb_address) + refute_predicate(client, :closed?) + + client.close + assert_predicate(client, :closed?) + end + + def test_double_close_raises + client = TigerBeetle::Client.new(cluster_id: 0, replica_addresses: @tb_address) + client.close + assert_raises(TigerBeetle::ClientClosedError) { client.close } + end + + def test_native_submit_after_close_raises + read_io, write_io = IO.pipe + # NativeClient is hidden from users, but we still want some tests around it. + native_client = TigerBeetle.const_get(:NativeClient) + native = native_client.new(0, @tb_address, write_io.fileno) + native.close + + assert_raises(TigerBeetle::ClientClosedError) do + native.submit(TigerBeetle::Operation::LOOKUP_ACCOUNTS, [1]) + end + + ensure + read_io&.close + write_io&.close + end + + def test_open_closes_after_block + client = nil + TigerBeetle::Client.open(cluster_id: 0, replica_addresses: @tb_address) do |c| + client = c + refute_predicate(client, :closed?) + end + + assert_predicate(client, :closed?) + end + + def test_open_closes_after_block_raises + client = nil + assert_raises(RuntimeError) do + TigerBeetle::Client.open(cluster_id: 0, replica_addresses: @tb_address) do |c| + client = c + raise "oops" + end + end + + assert_predicate(client, :closed?) + end + + def test_invalid_address_raises + err = assert_raises(TigerBeetle::InitError) do + TigerBeetle::Client.new(cluster_id: 0, replica_addresses: "not-an-address") + end + + assert_equal("Init error: address_invalid", err.message) + end + + def test_replica_addresses_must_be_a_string + assert_raises(TypeError) do + TigerBeetle::Client.new(cluster_id: 0, replica_addresses: [@tb_address]) + end + end + + def test_multiple_clients_submit_from_multiple_threads + clients = Array.new(2) do + TigerBeetle::Client.new(cluster_id: 0, replica_addresses: @tb_address) + end + + accounts = clients.map do + TigerBeetle::Account.new(id: TigerBeetle.id, ledger: 1, code: 1) + end + + threads = clients.zip(accounts).map do |client, account| + Thread.new { client.create_accounts([account]) } + end + + results = threads.map(&:value) + results.each do |result| + assert_equal(1, result.length) + assert_equal(TigerBeetle::CreateAccountStatus::CREATED, result[0].status) + end + + ensure + clients&.each(&:close) + end + + def test_concurrent_close_create_and_lookup_does_not_crash_or_hang + client_count = 5 + action_count = 50 + clients = Array.new(client_count) do + TigerBeetle::Client.new(cluster_id: 0, replica_addresses: @tb_address) + end + account_ids = [] + + threads = Array.new(action_count) do + Thread.new do + Thread.current.report_on_exception = false + sleep(rand < 0.2 ? 0 : rand / 1000.0) + client = clients.sample + + begin + if rand < 0.1 + client.close + elsif rand < 0.7 + id = TigerBeetle.id + account_ids << id + account = TigerBeetle::Account.new(id: id, ledger: 1, code: 1) + client.create_accounts([account]) + else + id = rand < 0.2 || account_ids.empty? ? TigerBeetle.id : account_ids.sample + client.lookup_accounts([id]) + end + rescue TigerBeetle::ClientClosedError + nil + end + end + end + + threads.each(&:value) + + ensure + clients&.each { |client| client.close unless client.closed? } + end +end diff --git a/ocam/src/clients/ruby/tests/integration/test_query_filter.rb b/ocam/src/clients/ruby/tests/integration/test_query_filter.rb new file mode 100644 index 00000000..ae11e713 --- /dev/null +++ b/ocam/src/clients/ruby/tests/integration/test_query_filter.rb @@ -0,0 +1,229 @@ +require_relative "tiger_beetle_integration_test" + +class TestQueryFilter < TigerBeetleIntegrationTest + BATCH_MAX = 8_189 + UINT64_MAX = (1 << 64) - 1 + + def test_query_accounts_filters_and_paginates + ledger = 1 + code = 999 + + accounts = Array.new(10) do |i| + TigerBeetle::Account.new( + id: TigerBeetle.id, + user_data_128: i.even? ? 1000 : 2000, + user_data_64: i.even? ? 100 : 200, + user_data_32: i.even? ? 10 : 20, + ledger: ledger, + code: code + ) + end + + results = @client.create_accounts(accounts) + assert_equal(10, results.length) + assert_all(results) { |result| result.status == TigerBeetle::CreateAccountStatus::CREATED } + + ascending = @client.query_accounts( + TigerBeetle::QueryFilter.new( + user_data_128: 1000, + user_data_64: 100, + user_data_32: 10, + ledger: ledger, + code: code, + limit: BATCH_MAX + ) + ) + assert_equal(5, ascending.length) + assert_strictly_ascending(ascending.map(&:timestamp)) + assert_all(ascending) { |account| account.user_data_128 == 1000 } + assert_all(ascending) { |account| account.user_data_64 == 100 } + assert_all(ascending) { |account| account.user_data_32 == 10 } + assert_all(ascending) { |account| account.ledger == ledger } + assert_all(ascending) { |account| account.code == code } + + reversed = @client.query_accounts( + TigerBeetle::QueryFilter.new( + user_data_128: 2000, + user_data_64: 200, + user_data_32: 20, + ledger: ledger, + code: code, + limit: BATCH_MAX, + flags: TigerBeetle::QueryFilterFlags::REVERSED + ) + ) + assert_equal(5, reversed.length) + assert_strictly_descending(reversed.map(&:timestamp)) + + page_filter = TigerBeetle::QueryFilter.new( + ledger: ledger, + code: code, + limit: 5, + flags: TigerBeetle::QueryFilterFlags::REVERSED + ) + first_page = @client.query_accounts(page_filter) + assert_equal(5, first_page.length) + assert_all(first_page) { |account| account.ledger == ledger } + assert_all(first_page) { |account| account.code == code } + + page_filter.timestamp_max = first_page.last.timestamp - 1 + second_page = @client.query_accounts(page_filter) + assert_equal(5, second_page.length) + assert_all(second_page) { |account| account.ledger == ledger } + assert_all(second_page) { |account| account.code == code } + + page_filter.timestamp_max = second_page.last.timestamp - 1 + assert_empty(@client.query_accounts(page_filter)) + end + + def test_query_transfers_filters_and_paginates + ledger = 1 + code = 999 + debit_account_id = TigerBeetle.id + credit_account_id = TigerBeetle.id + + @client.create_accounts( + [ + TigerBeetle::Account.new(id: debit_account_id, ledger: ledger, code: 1), + TigerBeetle::Account.new(id: credit_account_id, ledger: ledger, code: 1) + ] + ) + + transfers = Array.new(10) do |i| + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: debit_account_id, + credit_account_id: credit_account_id, + amount: 100, + user_data_128: i.even? ? 1000 : 2000, + user_data_64: i.even? ? 100 : 200, + user_data_32: i.even? ? 10 : 20, + ledger: ledger, + code: code + ) + end + + results = @client.create_transfers(transfers) + assert_equal(10, results.length) + assert_all(results) { |result| result.status == TigerBeetle::CreateTransferStatus::CREATED } + + ascending = @client.query_transfers( + TigerBeetle::QueryFilter.new( + user_data_128: 1000, + user_data_64: 100, + user_data_32: 10, + ledger: ledger, + code: code, + limit: BATCH_MAX + ) + ) + assert_equal(5, ascending.length) + assert_strictly_ascending(ascending.map(&:timestamp)) + assert_all(ascending) { |transfer| transfer.user_data_128 == 1000 } + assert_all(ascending) { |transfer| transfer.user_data_64 == 100 } + assert_all(ascending) { |transfer| transfer.user_data_32 == 10 } + assert_all(ascending) { |transfer| transfer.ledger == ledger } + assert_all(ascending) { |transfer| transfer.code == code } + + reversed = @client.query_transfers( + TigerBeetle::QueryFilter.new( + user_data_128: 2000, + user_data_64: 200, + user_data_32: 20, + ledger: ledger, + code: code, + limit: BATCH_MAX, + flags: TigerBeetle::QueryFilterFlags::REVERSED + ) + ) + assert_equal(5, reversed.length) + assert_strictly_descending(reversed.map(&:timestamp)) + + page_filter = TigerBeetle::QueryFilter.new( + ledger: ledger, + code: code, + limit: 5, + flags: TigerBeetle::QueryFilterFlags::REVERSED + ) + first_page = @client.query_transfers(page_filter) + assert_equal(5, first_page.length) + assert_all(first_page) { |transfer| transfer.ledger == ledger } + assert_all(first_page) { |transfer| transfer.code == code } + + page_filter.timestamp_max = first_page.last.timestamp - 1 + second_page = @client.query_transfers(page_filter) + assert_equal(5, second_page.length) + assert_all(second_page) { |transfer| transfer.ledger == ledger } + assert_all(second_page) { |transfer| transfer.code == code } + + page_filter.timestamp_max = second_page.last.timestamp - 1 + assert_empty(@client.query_transfers(page_filter)) + end + + def test_query_operations_return_empty_results + filter = TigerBeetle::QueryFilter.new( + user_data_128: TigerBeetle.id, + ledger: 1, + code: 999, + limit: BATCH_MAX + ) + + assert_empty(@client.query_accounts(filter)) + assert_empty(@client.query_transfers(filter)) + end + + def test_invalid_query_filters + filter = TigerBeetle::QueryFilter.new(timestamp_min: UINT64_MAX, limit: BATCH_MAX) + assert_empty(@client.query_accounts(filter)) + assert_empty(@client.query_transfers(filter)) + + filter = TigerBeetle::QueryFilter.new(timestamp_max: UINT64_MAX, limit: BATCH_MAX) + assert_empty(@client.query_accounts(filter)) + assert_empty(@client.query_transfers(filter)) + + filter = TigerBeetle::QueryFilter.new( + timestamp_min: UINT64_MAX - 1, + timestamp_max: 1, + limit: BATCH_MAX + ) + assert_empty(@client.query_accounts(filter)) + assert_empty(@client.query_transfers(filter)) + + filter = TigerBeetle::QueryFilter.new(limit: 0) + assert_empty(@client.query_accounts(filter)) + assert_empty(@client.query_transfers(filter)) + + filter = TigerBeetle::QueryFilter.new(limit: BATCH_MAX, flags: 0xFFFF) + assert_empty(@client.query_accounts(filter)) + assert_empty(@client.query_transfers(filter)) + + too_much_data = TigerBeetle::QueryFilter.new(limit: 10_000) + assert_raises(TigerBeetle::PacketError) { @client.query_accounts(too_much_data) } + assert_raises(TigerBeetle::PacketError) { @client.query_transfers(too_much_data) } + end + + def test_query_operations_raise_after_close + client = TigerBeetle::Client.new(cluster_id: 0, replica_addresses: @tb_address) + client.close + filter = TigerBeetle::QueryFilter.new(limit: 1) + + assert_raises(TigerBeetle::ClientClosedError) { client.query_accounts(filter) } + assert_raises(TigerBeetle::ClientClosedError) { client.query_transfers(filter) } + end + + private + + def assert_all(collection) + collection.each_with_index do |item, index| + assert(yield(item), "Expected item #{index} to match") + end + end + + def assert_strictly_ascending(values) + values.each_cons(2) { |a, b| assert_operator(a, :<, b) } + end + + def assert_strictly_descending(values) + values.each_cons(2) { |a, b| assert_operator(a, :>, b) } + end +end diff --git a/ocam/src/clients/ruby/tests/integration/test_transfer.rb b/ocam/src/clients/ruby/tests/integration/test_transfer.rb new file mode 100644 index 00000000..397904b9 --- /dev/null +++ b/ocam/src/clients/ruby/tests/integration/test_transfer.rb @@ -0,0 +1,286 @@ +require_relative "tiger_beetle_integration_test" + +class TestTransfers < TigerBeetleIntegrationTest + def setup + super + + @a1_id = TigerBeetle.id + @a2_id = TigerBeetle.id + @client.create_accounts( + [ + TigerBeetle::Account.new(id: @a1_id, ledger: 1, code: 1), + TigerBeetle::Account.new(id: @a2_id, ledger: 1, code: 1) + ] + ) + end + + def test_create_transfer + results = @client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: @a1_id, + credit_account_id: @a2_id, + amount: 100, + ledger: 1, + code: 1 + ) + ] + ) + assert_equal(1, results.length) + assert_operator(results[0].timestamp, :>, 0) + assert_equal(TigerBeetle::CreateTransferStatus::CREATED, results[0].status) + assert_equal(:created, results[0].status_name) + end + + def test_create_transfer_duplicate + transfer = TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: @a1_id, + credit_account_id: @a2_id, + amount: 10, + ledger: 1, + code: 1 + ) + + @client.create_transfers([transfer]) + + results = @client.create_transfers([transfer]) + assert_equal(1, results.length) + assert_equal(TigerBeetle::CreateTransferStatus::EXISTS, results[0].status) + assert_equal(:exists, results[0].status_name) + end + + def test_create_transfer_id_zero + results = @client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: 0, + debit_account_id: @a1_id, + credit_account_id: @a2_id, + amount: 10, + ledger: 1, + code: 1 + ) + ] + ) + assert_equal(1, results.length) + assert_equal(TigerBeetle::CreateTransferStatus::ID_MUST_NOT_BE_ZERO, results[0].status) + end + + def test_create_transfer_debit_account_id_zero + results = @client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: 0, + credit_account_id: @a2_id, + amount: 10, + ledger: 1, + code: 1 + ) + ] + ) + assert_equal(1, results.length) + assert_equal( + TigerBeetle::CreateTransferStatus::DEBIT_ACCOUNT_ID_MUST_NOT_BE_ZERO, + results[0].status + ) + assert_equal(:debit_account_id_must_not_be_zero, results[0].status_name) + end + + def test_create_transfer_credit_account_id_zero + results = @client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: @a1_id, + credit_account_id: 0, + amount: 10, + ledger: 1, + code: 1 + ) + ] + ) + assert_equal(1, results.length) + assert_equal( + TigerBeetle::CreateTransferStatus::CREDIT_ACCOUNT_ID_MUST_NOT_BE_ZERO, + results[0].status + ) + end + + def test_create_transfer_accounts_must_be_different + results = @client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: @a1_id, + credit_account_id: @a1_id, + amount: 10, + ledger: 1, + code: 1 + ) + ] + ) + assert_equal(1, results.length) + assert_equal(TigerBeetle::CreateTransferStatus::ACCOUNTS_MUST_BE_DIFFERENT, results[0].status) + end + + def test_create_transfer_ledger_zero + results = @client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: @a1_id, + credit_account_id: @a2_id, + amount: 10, + ledger: 0, + code: 1 + ) + ] + ) + assert_equal(1, results.length) + assert_equal(TigerBeetle::CreateTransferStatus::LEDGER_MUST_NOT_BE_ZERO, results[0].status) + end + + def test_create_transfer_code_zero + results = @client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: TigerBeetle.id, + debit_account_id: @a1_id, + credit_account_id: @a2_id, + amount: 10, + ledger: 1, + code: 0 + ) + ] + ) + assert_equal(1, results.length) + assert_equal(TigerBeetle::CreateTransferStatus::CODE_MUST_NOT_BE_ZERO, results[0].status) + end + + def test_lookup_transfer + id = TigerBeetle.id + @client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: id, + debit_account_id: @a1_id, + credit_account_id: @a2_id, + amount: 42, + ledger: 1, + code: 1 + ) + ] + ) + + results = @client.lookup_transfers([id]) + assert_equal(1, results.length) + assert_equal(id, results[0].id) + assert_equal(42, results[0].amount) + end + + def test_lookup_transfer_not_found + id = TigerBeetle.id + + results = @client.lookup_transfers([id]) + assert_equal(0, results.length) + end + + def test_lookup_transfers_multiple + id1 = TigerBeetle.id + id2 = TigerBeetle.id + @client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: id1, + debit_account_id: @a1_id, + credit_account_id: @a2_id, + amount: 10, + ledger: 1, + code: 1 + ), + TigerBeetle::Transfer.new( + id: id2, + debit_account_id: @a1_id, + credit_account_id: @a2_id, + amount: 20, + ledger: 1, + code: 1 + ) + ] + ) + + results = @client.lookup_transfers([id1, id2]) + assert_equal(2, results.length) + r1 = results.find { |result| result.id == id1 } + r2 = results.find { |result| result.id == id2 } + refute_nil(r1) + refute_nil(r2) + assert_equal(10, r1.amount) + assert_equal(20, r2.amount) + end + + def test_lookup_transfers_partial_match + existing_id = TigerBeetle.id + missing_id = TigerBeetle.id + @client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: existing_id, + debit_account_id: @a1_id, + credit_account_id: @a2_id, + amount: 5, + ledger: 1, + code: 1 + ) + ] + ) + + results = @client.lookup_transfers([existing_id, missing_id]) + assert_equal(1, results.length) + assert_equal(existing_id, results[0].id) + end + + def test_lookup_transfers_empty_batch + results = @client.lookup_transfers([]) + assert_equal(0, results.length) + end + + def test_lookup_transfer_field_roundtrip + id = TigerBeetle.id + user_data_128 = TigerBeetle.id + user_data_64 = 8_888_888_888 + user_data_32 = 54321 + @client.create_transfers( + [ + TigerBeetle::Transfer.new( + id: id, + debit_account_id: @a1_id, + credit_account_id: @a2_id, + amount: 99, + ledger: 1, + code: 7, + user_data_128: user_data_128, + user_data_64: user_data_64, + user_data_32: user_data_32 + ) + ] + ) + + results = @client.lookup_transfers([id]) + assert_equal(1, results.length) + t = results[0] + assert_equal(id, t.id) + assert_equal(@a1_id, t.debit_account_id) + assert_equal(@a2_id, t.credit_account_id) + assert_equal(99, t.amount) + assert_equal(1, t.ledger) + assert_equal(7, t.code) + assert_equal(user_data_128, t.user_data_128) + assert_equal(user_data_64, t.user_data_64) + assert_equal(user_data_32, t.user_data_32) + assert_operator(t.timestamp, :>, 0) + end +end diff --git a/ocam/src/clients/ruby/tests/integration/test_two_phase_transfer.rb b/ocam/src/clients/ruby/tests/integration/test_two_phase_transfer.rb new file mode 100644 index 00000000..e35e62f7 --- /dev/null +++ b/ocam/src/clients/ruby/tests/integration/test_two_phase_transfer.rb @@ -0,0 +1,252 @@ +require_relative "tiger_beetle_integration_test" + +class TestTwoPhaseTransfers < TigerBeetleIntegrationTest + ACCOUNT_A_ID = 17 + ACCOUNT_B_ID = 19 + AMOUNT_MAX = (1 << 128) - 1 + + # Minitest randomizes test order by default. There are two ways to match the + # test structure of Node and Python: + # 1. Use the `i_suck_and_my_tests_are_order_dependent!` class method. + # 2. Have one large order-dependent test. + # We opted for option 2 here. + def test_two_phase_transfers + # Node/Python: create accounts. + account_results = @client.create_accounts( + [ + TigerBeetle::Account.new(id: ACCOUNT_A_ID, ledger: 1, code: 718) + ] + ) + + assert_equal(1, account_results.length) + assert_operator(account_results[0].timestamp, :>, 0) + assert_equal(TigerBeetle::CreateAccountStatus::CREATED, account_results[0].status) + + account_results = @client.create_accounts( + [ + TigerBeetle::Account.new(id: ACCOUNT_A_ID, ledger: 1, code: 718), + TigerBeetle::Account.new(id: ACCOUNT_B_ID, ledger: 1, code: 719) + ] + ) + + assert_equal(2, account_results.length) + assert_operator(account_results[0].timestamp, :>, 0) + assert_equal(TigerBeetle::CreateAccountStatus::EXISTS, account_results[0].status) + assert_operator(account_results[1].timestamp, :>, 0) + assert_equal(TigerBeetle::CreateAccountStatus::CREATED, account_results[1].status) + + # Node/Python: can create a transfer. + transfer = TigerBeetle::Transfer.new( + id: 1, + debit_account_id: ACCOUNT_B_ID, + credit_account_id: ACCOUNT_A_ID, + amount: 100, + ledger: 1, + code: 1 + ) + + transfers_results = @client.create_transfers([transfer]) + assert_equal(1, transfers_results.length) + assert_operator(transfers_results[0].timestamp, :>, 0) + assert_equal(TigerBeetle::CreateTransferStatus::CREATED, transfers_results[0].status) + + accounts = @client.lookup_accounts([ACCOUNT_A_ID, ACCOUNT_B_ID]) + assert_equal(2, accounts.length) + assert_equal(100, accounts[0].credits_posted) + assert_equal(0, accounts[0].credits_pending) + assert_equal(0, accounts[0].debits_posted) + assert_equal(0, accounts[0].debits_pending) + + assert_equal(0, accounts[1].credits_posted) + assert_equal(0, accounts[1].credits_pending) + assert_equal(100, accounts[1].debits_posted) + assert_equal(0, accounts[1].debits_pending) + + # Node/Python: can create a two-phase transfer. + transfer = TigerBeetle::Transfer.new( + id: 2, + debit_account_id: ACCOUNT_B_ID, + credit_account_id: ACCOUNT_A_ID, + amount: 50, + pending_id: 0, + timeout: 2_000_000_000, + ledger: 1, + code: 1, + flags: TigerBeetle::TransferFlags::PENDING, + timestamp: 0 + ) + + transfers_results = @client.create_transfers([transfer]) + assert_equal(1, transfers_results.length) + assert_operator(transfers_results[0].timestamp, :>, 0) + assert_equal(TigerBeetle::CreateTransferStatus::CREATED, transfers_results[0].status) + + accounts = @client.lookup_accounts([ACCOUNT_A_ID, ACCOUNT_B_ID]) + assert_equal(2, accounts.length) + assert_equal(100, accounts[0].credits_posted) + assert_equal(50, accounts[0].credits_pending) + assert_equal(0, accounts[0].debits_posted) + assert_equal(0, accounts[0].debits_pending) + + assert_equal(0, accounts[1].credits_posted) + assert_equal(0, accounts[1].credits_pending) + assert_equal(100, accounts[1].debits_posted) + assert_equal(50, accounts[1].debits_pending) + + transfers = @client.lookup_transfers([transfer.id]) + assert_equal(1, transfers.length) + assert_equal(2, transfers[0].id) + assert_equal(ACCOUNT_B_ID, transfers[0].debit_account_id) + assert_equal(ACCOUNT_A_ID, transfers[0].credit_account_id) + assert_equal(50, transfers[0].amount) + assert_equal(0, transfers[0].user_data_128) + assert_equal(0, transfers[0].user_data_64) + assert_equal(0, transfers[0].user_data_32) + assert_operator(transfers[0].timeout, :>, 0) + assert_equal(1, transfers[0].code) + assert_equal(2, transfers[0].flags) + assert_equal(transfers_results[0].timestamp, transfers[0].timestamp) + assert_operator(transfers[0].timestamp, :>, 0) + + # Node/Python: can post a two-phase transfer. + commit = TigerBeetle::Transfer.new( + id: 3, + debit_account_id: 0, + credit_account_id: 0, + amount: AMOUNT_MAX, + pending_id: 2, + timeout: 0, + ledger: 1, + code: 1, + flags: TigerBeetle::TransferFlags::POST_PENDING_TRANSFER, + timestamp: 0 + ) + + transfers_results = @client.create_transfers([commit]) + assert_equal(1, transfers_results.length) + assert_operator(transfers_results[0].timestamp, :>, 0) + assert_equal(TigerBeetle::CreateTransferStatus::CREATED, transfers_results[0].status) + + accounts = @client.lookup_accounts([ACCOUNT_A_ID, ACCOUNT_B_ID]) + assert_equal(2, accounts.length) + assert_equal(150, accounts[0].credits_posted) + assert_equal(0, accounts[0].credits_pending) + assert_equal(0, accounts[0].debits_posted) + assert_equal(0, accounts[0].debits_pending) + + assert_equal(0, accounts[1].credits_posted) + assert_equal(0, accounts[1].credits_pending) + assert_equal(150, accounts[1].debits_posted) + assert_equal(0, accounts[1].debits_pending) + + # Node/Python: can reject a two-phase transfer. + transfer = TigerBeetle::Transfer.new( + id: 4, + debit_account_id: ACCOUNT_B_ID, + credit_account_id: ACCOUNT_A_ID, + amount: 50, + pending_id: 0, + timeout: 1_000_000_000, + ledger: 1, + code: 1, + flags: TigerBeetle::TransferFlags::PENDING, + timestamp: 0 + ) + transfers_results = @client.create_transfers([transfer]) + assert_equal(1, transfers_results.length) + assert_operator(transfers_results[0].timestamp, :>, 0) + assert_equal(TigerBeetle::CreateTransferStatus::CREATED, transfers_results[0].status) + + reject = TigerBeetle::Transfer.new( + id: 5, + debit_account_id: 0, + credit_account_id: 0, + amount: 0, + pending_id: 4, + timeout: 0, + ledger: 1, + code: 1, + flags: TigerBeetle::TransferFlags::VOID_PENDING_TRANSFER, + timestamp: 0 + ) + + transfers_results = @client.create_transfers([reject]) + assert_equal(1, transfers_results.length) + assert_operator(transfers_results[0].timestamp, :>, 0) + assert_equal(TigerBeetle::CreateTransferStatus::CREATED, transfers_results[0].status) + + accounts = @client.lookup_accounts([ACCOUNT_A_ID, ACCOUNT_B_ID]) + assert_equal(2, accounts.length) + assert_equal(150, accounts[0].credits_posted) + assert_equal(0, accounts[0].credits_pending) + assert_equal(0, accounts[0].debits_posted) + assert_equal(0, accounts[0].debits_pending) + + assert_equal(0, accounts[1].credits_posted) + assert_equal(0, accounts[1].credits_pending) + assert_equal(150, accounts[1].debits_posted) + assert_equal(0, accounts[1].debits_pending) + + # Node/Python: cannot void an expired transfer. + transfer = TigerBeetle::Transfer.new( + id: 6, + debit_account_id: ACCOUNT_B_ID, + credit_account_id: ACCOUNT_A_ID, + amount: 50, + pending_id: 0, + timeout: 1, + ledger: 1, + code: 1, + flags: TigerBeetle::TransferFlags::PENDING, + timestamp: 0 + ) + transfers_results = @client.create_transfers([transfer]) + assert_equal(1, transfers_results.length) + assert_operator(transfers_results[0].timestamp, :>, 0) + assert_equal(TigerBeetle::CreateTransferStatus::CREATED, transfers_results[0].status) + + accounts = @client.lookup_accounts([ACCOUNT_A_ID, ACCOUNT_B_ID]) + assert_equal(2, accounts.length) + assert_equal(150, accounts[0].credits_posted) + assert_equal(50, accounts[0].credits_pending) + assert_equal(0, accounts[0].debits_posted) + assert_equal(0, accounts[0].debits_pending) + + assert_equal(0, accounts[1].credits_posted) + assert_equal(0, accounts[1].credits_pending) + assert_equal(150, accounts[1].debits_posted) + assert_equal(50, accounts[1].debits_pending) + + sleep(1.5) + + accounts = @client.lookup_accounts([ACCOUNT_A_ID, ACCOUNT_B_ID]) + assert_equal(2, accounts.length) + assert_equal(150, accounts[0].credits_posted) + assert_equal(0, accounts[0].credits_pending) + assert_equal(0, accounts[0].debits_posted) + assert_equal(0, accounts[0].debits_pending) + + assert_equal(0, accounts[1].credits_posted) + assert_equal(0, accounts[1].credits_pending) + assert_equal(150, accounts[1].debits_posted) + assert_equal(0, accounts[1].debits_pending) + + reject = TigerBeetle::Transfer.new( + id: 7, + debit_account_id: 0, + credit_account_id: 0, + amount: 0, + pending_id: 6, + timeout: 0, + ledger: 1, + code: 1, + flags: TigerBeetle::TransferFlags::VOID_PENDING_TRANSFER, + timestamp: 0 + ) + + transfers_results = @client.create_transfers([reject]) + assert_equal(1, transfers_results.length) + assert_operator(transfers_results[0].timestamp, :>, 0) + assert_equal(TigerBeetle::CreateTransferStatus::PENDING_TRANSFER_EXPIRED, transfers_results[0].status) + end +end diff --git a/ocam/src/clients/ruby/tests/integration/test_uint128_range.rb b/ocam/src/clients/ruby/tests/integration/test_uint128_range.rb new file mode 100644 index 00000000..9c591847 --- /dev/null +++ b/ocam/src/clients/ruby/tests/integration/test_uint128_range.rb @@ -0,0 +1,51 @@ +require_relative "tiger_beetle_integration_test" + +class TestUInt128Range < TigerBeetleIntegrationTest + UINT128_MAX = (1 << 128) - 1 + UINT128_OVERFLOW = 1 << 128 + UINT128_TOO_BIG = 9_999_999_999_999_999_999_999_999_999_999_999_999_999 + UINT128_RANGE_ERROR = "integer must be between 0 and 2**128 - 1" + + def test_range_check_u128_accepts_max + assert_equal([], @client.lookup_accounts([UINT128_MAX])) + end + + def test_range_check_u128_cannot_exceed + assert_u128_range_error do + @client.lookup_accounts([UINT128_OVERFLOW]) + end + end + + def test_range_check_u128_cannot_be_too_big + assert_u128_range_error do + @client.lookup_accounts([UINT128_TOO_BIG]) + end + end + + def test_range_check_u128_cannot_be_negative + assert_u128_range_error do + @client.lookup_accounts([-1]) + end + end + + def test_range_check_u128_struct_field_cannot_exceed + account = TigerBeetle::Account.new(id: UINT128_OVERFLOW, ledger: 1, code: 1) + + assert_u128_range_error do + @client.create_accounts([account]) + end + end + + def test_range_check_u128_cluster_id_cannot_exceed + assert_u128_range_error do + TigerBeetle::Client.new(cluster_id: UINT128_OVERFLOW, replica_addresses: @tb_address) + end + end + + private + + def assert_u128_range_error(&block) + error = assert_raises(RangeError, &block) + assert_equal(UINT128_RANGE_ERROR, error.message) + end +end diff --git a/ocam/src/clients/ruby/tests/integration/tiger_beetle_integration_test.rb b/ocam/src/clients/ruby/tests/integration/tiger_beetle_integration_test.rb new file mode 100644 index 00000000..6bf29f6d --- /dev/null +++ b/ocam/src/clients/ruby/tests/integration/tiger_beetle_integration_test.rb @@ -0,0 +1,13 @@ +require "minitest/autorun" +require "tigerbeetle" + +class TigerBeetleIntegrationTest < Minitest::Test + def setup + @tb_address = ENV.fetch("TB_ADDRESS", "3000") + @client = TigerBeetle::Client.new(cluster_id: 0, replica_addresses: @tb_address) + end + + def teardown + @client.close if @client && !@client.closed? + end +end diff --git a/ocam/src/clients/ruby/tests/unit/test_completion_dispatcher.rb b/ocam/src/clients/ruby/tests/unit/test_completion_dispatcher.rb new file mode 100644 index 00000000..6527306f --- /dev/null +++ b/ocam/src/clients/ruby/tests/unit/test_completion_dispatcher.rb @@ -0,0 +1,106 @@ +require "minitest/autorun" +require "timeout" +require "tigerbeetle" + +class TestCompletionDispatcher < Minitest::Test + TestRequest = Struct.new(:id, keyword_init: true) + + # Dummy native client that just returns the unmodified request + Native = Struct.new(:request, keyword_init: true) do + def submit(_operation, _payload) + request + end + end + + def setup + # The dispatcher is hidden from users of the client but we want to run some tests against it so + # we fetch the private constant with const_get. + @dispatcher = TigerBeetle.const_get(:CompletionDispatcher).new + end + + def teardown + @dispatcher.instance_variable_get(:@write_io).close + @dispatcher.instance_variable_get(:@read_io).close + @dispatcher.instance_variable_get(:@thread).join(1) + end + + def test_submit_and_wait_unregisters_request_when_waiting_thread_is_interrupted + request = TestRequest.new(id: 1) + thread = Thread.new do + Thread.current.report_on_exception = false + @dispatcher.submit_and_wait_for( + Native.new(request: request), + TigerBeetle::Operation::LOOKUP_ACCOUNTS, + [1] + ) + end + + wait_until { pending_ids.include?(request.id) } + thread.raise("interrupted while waiting") + wait_for_interrupted_thread(thread) + + refute_includes(pending_ids, request.id) + assert_includes(cancelled_ids, request.id) + + write_completion_id(request.id) + wait_until { !cancelled_ids.include?(request.id) } + + refute_includes(cancelled_ids, request.id) + refute_includes(completed_ids, request.id) + refute_includes(pending_ids, request.id) + end + + def test_submit_and_wait_cleans_bookkeeping_after_normal_completion + request = TestRequest.new(id: 1) + thread = Thread.new do + @dispatcher.submit_and_wait_for( + Native.new(request: request), + TigerBeetle::Operation::LOOKUP_ACCOUNTS, + [1] + ) + end + + wait_until { pending_ids.include?(request.id) } + write_completion_id(request.id) + + assert_same(request, thread.value) + refute_includes(pending_ids, request.id) + refute_includes(cancelled_ids, request.id) + refute_includes(completed_ids, request.id) + end + + private + + def write_completion_id(request_id) + write_io = @dispatcher.instance_variable_get(:@write_io) + write_io.write([request_id].pack("Q")) + end + + def wait_for_interrupted_thread(thread) + thread.value + rescue RuntimeError + nil + end + + def pending_ids + @dispatcher.instance_variable_get(:@pending).keys + end + + def completed_ids + @dispatcher.instance_variable_get(:@completed_ids).keys + end + + def cancelled_ids + @dispatcher.instance_variable_get(:@cancelled_ids).keys + end + + def wait_until + Timeout.timeout(1) do + loop do + return if yield + + Thread.pass + end + end + end +end diff --git a/ocam/src/clients/ruby/tests/unit/test_id.rb b/ocam/src/clients/ruby/tests/unit/test_id.rb new file mode 100644 index 00000000..2406b144 --- /dev/null +++ b/ocam/src/clients/ruby/tests/unit/test_id.rb @@ -0,0 +1,59 @@ +require "minitest/autorun" +require "tigerbeetle" + +class TestID < Minitest::Test + def test_unique + ids = Array.new(1000) { TigerBeetle.id } + assert_equal(ids.length, ids.uniq.length) + end + + def test_monotonic + ids = Array.new(100) { TigerBeetle.id } + assert_equal(ids, ids.sort) + end + + def test_fits_in_128_bits + 1000.times do + id = TigerBeetle.id + assert(id >= 0) + assert(id < 2 ** 128) + end + end + + def test_random_overflow_advances_timestamp + gen = generator_in_future_with_random(TigerBeetle::ID::RANDOM_MAX - 1) + last_ms = gen.instance_variable_get(:@last_ms) + + id = gen.generate + + new_ms = gen.instance_variable_get(:@last_ms) + assert_equal(last_ms + 1, new_ms, "timestamp must advance on random overflow") + assert_equal(0, gen.instance_variable_get(:@random)) + assert_equal(new_ms << 80, id) + end + + def test_random_near_max_does_not_raise + gen = generator_in_future_with_random(TigerBeetle::ID::RANDOM_MAX - 2) + + assert_equal(TigerBeetle::ID::RANDOM_MAX - 1, gen.generate & (TigerBeetle::ID::RANDOM_MAX - 1)) + end + + def test_ids_monotonic_across_random_overflow + gen = generator_in_future_with_random(TigerBeetle::ID::RANDOM_MAX - 2) + + ids = Array.new(5) { gen.generate } + assert_equal(ids, ids.sort) + assert_equal(ids.length, ids.uniq.length) + end + + private + + # Creates an ID generator in the future so the ms don't increment before the random overflows. + def generator_in_future_with_random(random) + future_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) + 1_000_000 + TigerBeetle::ID.new.tap do |gen| + gen.instance_variable_set(:@last_ms, future_ms) + gen.instance_variable_set(:@random, random) + end + end +end diff --git a/ocam/src/clients/ruby/tests/unit/test_tb_alias.rb b/ocam/src/clients/ruby/tests/unit/test_tb_alias.rb new file mode 100644 index 00000000..75341862 --- /dev/null +++ b/ocam/src/clients/ruby/tests/unit/test_tb_alias.rb @@ -0,0 +1,13 @@ +require "minitest/autorun" + +class TestTBAlias < Minitest::Test + def test_tb_alias_needs_to_be_explicitly_required + require "tigerbeetle" + + assert_equal(Object.const_defined?(:TB), false) + + require "tigerbeetle/tb" + + assert_equal(Object.const_defined?(:TB), true) + end +end diff --git a/ocam/src/clients/ruby/tigerbeetle.gemspec b/ocam/src/clients/ruby/tigerbeetle.gemspec new file mode 100644 index 00000000..dc939d93 --- /dev/null +++ b/ocam/src/clients/ruby/tigerbeetle.gemspec @@ -0,0 +1,33 @@ +require_relative "src/tigerbeetle/version" + +Gem::Specification.new do |spec| + spec.name = "tigerbeetle" + spec.version = TigerBeetle::VERSION + spec.summary = "The TigerBeetle client for Ruby." + spec.authors = ["TigerBeetle, Inc"] + spec.license = "Apache-2.0" + spec.homepage = "https://github.com/tigerbeetle/tigerbeetle" + + spec.metadata = { + "source_code_uri" => "https://github.com/tigerbeetle/tigerbeetle", + "bug_tracker_uri" => "https://github.com/tigerbeetle/tigerbeetle/issues", + } + + spec.require_paths = ["src"] + spec.extensions = ["src/ext/tigerbeetle/extconf.rb"] + spec.files = Dir[ + "src/**/*.rb", + "src/ext/tigerbeetle/extconf.rb", + "src/ext/tigerbeetle/rb_tb_gen.h", + "src/ext/tigerbeetle/tb_client.h", + "src/ext/tigerbeetle/tigerbeetle.c", + "src/ext/tigerbeetle/lib/**/*", + "sig/**/*.rbs", + "LICENSE", + "README.md", + "docs/migration.md", + ] + + spec.required_ruby_version = ">= 3.3" + +end diff --git a/ocam/src/clients/rust/.gitignore b/ocam/src/clients/rust/.gitignore new file mode 100644 index 00000000..c0215b27 --- /dev/null +++ b/ocam/src/clients/rust/.gitignore @@ -0,0 +1,3 @@ +target/ +assets/lib/ +Cargo.lock \ No newline at end of file diff --git a/ocam/src/clients/rust/Cargo.toml b/ocam/src/clients/rust/Cargo.toml new file mode 100644 index 00000000..606b3eec --- /dev/null +++ b/ocam/src/clients/rust/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "tigerbeetle" +version = "0.0.0" +edition = "2021" +description = "TigerBeetle client for Rust" +license = "Apache-2.0" +authors = ["TigerBeetle, Inc"] +homepage = "https://tigerbeetle.com" +repository = "https://github.com/tigerbeetle/tigerbeetle" +documentation = "https://docs.rs/tigerbeetle" +keywords = ["finance", "accounting", "ledger", "database", "transactions"] +categories = ["finance", "database"] +rust-version = "1.63" +# Need to do this to pick up the assets directory and exclude zig files +include = [ + "build.rs", + "src/*.rs", + "assets/tb_client.h", + "assets/lib/*/{*.a,*.lib}", +] + +[profile.release] +# It is *strongly* recommended that Rust applications using TigerBeetle +# enable overflow checks, because the nature of accounting makes overflow +# errors catastrophic. +# +# Note that the following line enables the checks only for the tests in +# this crate. In other words, overflow checks must be enabled in the +# Cargo.toml of the end application. +overflow-checks = true + +[dev-dependencies] +futures = { version = "0.3.31", default-features = false, features = ["executor"] } + diff --git a/ocam/src/clients/rust/README.md b/ocam/src/clients/rust/README.md new file mode 100644 index 00000000..0b8d5f98 --- /dev/null +++ b/ocam/src/clients/rust/README.md @@ -0,0 +1,770 @@ + +# tigerbeetle-rust + +The TigerBeetle client for Rust. + +[![crates.io](https://img.shields.io/crates/v/tigerbeetle)](https://crates.io/crates/tigerbeetle) +[![docs.rs](https://img.shields.io/docsrs/tigerbeetle)](https://docs.rs/tigerbeetle) + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Rust 1.68+ + +## Setup + +First, create a directory for your project and `cd` into the directory. + +Then create `Cargo.toml` and copy this into it: + +```toml +[package] +name = "tigerbeetle-test" +version = "0.1.0" +edition = "2024" + +[dependencies] +tigerbeetle.path = "../.." +futures = "0.3" +``` + +Now, create `src/main.rs` and copy this into it: + +```rust +use tigerbeetle as tb; + +fn main() -> Result<(), Box> { + futures::executor::block_on(main_async()) +} + +async fn main_async() -> Result<(), Box> { + println!("hello world"); + Ok(()) +} +``` + +Finally, build and run: + +```console +cargo run +``` + +Now that all prerequisites and dependencies are correctly set +up, let's dig into using TigerBeetle. + +## Sample projects + +This document is primarily a reference guide to +the client. Below are various sample projects demonstrating +features of TigerBeetle. + +* [Basic](/src/clients/rust/samples/basic/): Create two accounts and transfer an amount between them. +* [Two-Phase Transfer](/src/clients/rust/samples/two-phase/): Create two accounts and start a pending transfer between +them, then post the transfer. +* [Many Two-Phase Transfers](/src/clients/rust/samples/two-phase-many/): Create two accounts and start a number of pending transfers +between them, posting and voiding alternating transfers. + +## Creating a Client + +A client is created with a cluster ID and replica +addresses for all replicas in the cluster. The cluster +ID and replica addresses are both chosen by the system that +starts the TigerBeetle cluster. + +Clients are thread-safe and a single instance should be shared +between multiple concurrent tasks. This allows events to be +[automatically batched](https://docs.tigerbeetle.com/coding/requests/#batching-events). + +Multiple clients are useful when connecting to more than +one TigerBeetle cluster. + +In this example the cluster ID is `0` and there is one +replica. The address is read from the `TB_ADDRESS` +environment variable and defaults to port `3000`. + +```rust +let cluster_id = 0; +let replica_address = std::env::var("TB_ADDRESS") + .ok() + .unwrap_or_else(|| String::from("3000")); +let client = tb::Client::new(cluster_id, &replica_address)?; +``` + +The following are valid addresses: +* `3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1:3000` (interpreted as `127.0.0.1:3000`) +* `127.0.0.1` (interpreted as `127.0.0.1:3001`, `3001` is the default port) + +## Creating Accounts + +See details for account fields in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account). + +```rust +let account_results = client + .create_accounts(&[tb::Account { + id: tb::id(), + ledger: 1, + code: 718, + ..Default::default() + }])? + .await?; +// Result handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +### Account Flags + +The account flags value is a bitfield. See details for +these flags in the [Accounts +reference](https://docs.tigerbeetle.com/reference/account#flags). + +To toggle behavior for an account, use the `AccountFlags` bitflags. +You can combine multiple flags using the `|` operator. Here are a +few examples: + +* `AccountFlags::Linked` +* `AccountFlags::DebitsMustNotExceedCredits` +* `AccountFlags::CreditsMustNotExceedDebits` +* `AccountFlags::History` +* `AccountFlags::Linked | AccountFlags::History` + +For example, to link two accounts where the first account +additionally has the `debits_must_not_exceed_credits` constraint: + +```rust +let account0 = tb::Account { + id: 100, + ledger: 1, + code: 718, + flags: tb::AccountFlags::DebitsMustNotExceedCredits | tb::AccountFlags::Linked, + ..Default::default() +}; +let account1 = tb::Account { + id: 101, + ledger: 1, + code: 718, + flags: tb::AccountFlags::History, + ..Default::default() +}; + +let account_results = client.create_accounts(&[account0, account1])?.await?; +// Result handling omitted. +``` + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each account in the request batch: +- Successfully created accounts with the status + [`created`](https://docs.tigerbeetle.com/reference/requests/create_accounts#created) + return the timestamp assigned to the `Account` object. +- Already existing accounts with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists) + return the timestamp of the original existing object. +- Failed accounts return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_accounts reference](https://docs.tigerbeetle.com/reference/requests/create_accounts#status). + +```rust +let account0 = tb::Account { + id: 102, + ledger: 1, + code: 718, + ..Default::default() +}; +let account1 = tb::Account { + id: 103, + ledger: 1, + code: 718, + ..Default::default() +}; +let account2 = tb::Account { + id: 104, + ledger: 1, + code: 718, + ..Default::default() +}; + +let account_results = client + .create_accounts(&[account0, account1, account2])? + .await?; + +assert!(account_results.len() == 3); +for (index, result) in account_results.into_iter().enumerate() { + match result.status { + tb::CreateAccountStatus::Created => { + println!( + "Batch account at {} successfully created with timestamp {}", + index, result.timestamp + ); + } + tb::CreateAccountStatus::Exists => { + println!( + "Batch account at {} already exists with timestamp {}.", + index, result.timestamp + ); + } + _ => { + eprintln!( + "Batch account at {} failed to create: {:?}", + index, result.status + ); + } + } +} +``` + +## Account Lookup + +Account lookup is batched, like account creation. Pass +in all IDs to fetch. The account for each matched ID is returned. + +If no account matches an ID, no object is returned for +that account. So the order of accounts in the response is +not necessarily the same as the order of IDs in the +request. You can refer to the ID field in the response to +distinguish accounts. + +```rust +let accounts = client.lookup_accounts(&[100, 101])?.await?; +``` + +## Create Transfers + +This creates a journal entry between two accounts. + +See details for transfer fields in the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer). + +```rust +let transfers = vec![tb::Transfer { + id: tb::id(), + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() +}]; + +let transfer_results = client.create_transfers(&transfers)?.await?; +// Result handling omitted. +``` + +See details for the recommended ID scheme in +[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + +### Response and Errors + +The response is an array containing the _status code_ and the _timestamp_ of +each transfer in the request batch: +- Successfully created transfers with the result + [`created`](https://docs.tigerbeetle.com/reference/requests/create_transfers#created) + return the timestamp assigned to the `Transfer` object. +- Already existing transfers with the result + [`exists`](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists) + return the timestamp of the original existing object. +- Failed transfers return the status code along with the timestamp when the validation + occurred. See all error conditions in the + [create_transfers reference](https://docs.tigerbeetle.com/reference/requests/create_transfers#status). + +```rust +let transfers = vec![ + tb::Transfer { + id: 1, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() + }, + tb::Transfer { + id: 2, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() + }, + tb::Transfer { + id: 3, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() + }, +]; + +let transfer_results = client.create_transfers(&transfers)?.await?; +assert!(transfer_results.len() == transfers.len()); +for (index, result) in transfer_results.into_iter().enumerate() { + match result.status { + tb::CreateTransferStatus::Created => { + println!( + "Batch transfer at {} successfully created with timestamp {}", + index, result.timestamp + ); + } + tb::CreateTransferStatus::Exists => { + println!( + "Batch transfer at {} already exists with timestamp {}.", + index, result.timestamp + ); + } + _ => { + eprintln!( + "Batch transfer at {} failed to create: {:?}", + index, result.status + ); + } + } +} +``` + +## Batching + +TigerBeetle performance is maximized when you batch +API requests. + +A client instance shared across multiple threads/tasks can automatically +batch concurrent requests, but the application must still send as many events +as possible in a single call. + +For example, if you insert 1 million transfers sequentially, one at a time, +the insert rate will be a *fraction* of the potential, because the client will +wait for a reply between each one. +Instead, **always batch as much as you can**. + +The maximum batch size is set in the TigerBeetle server. The default is 8189. + +```rust +let transfers: Vec = vec![]; +const BATCH_SIZE: usize = 8189; +for batch in transfers.chunks(BATCH_SIZE) { + let transfer_results = client.create_transfers(batch)?.await?; + // Result handling omitted. +} +``` + +### Queues and Workers + +If you are making requests to TigerBeetle from workers +pulling jobs from a queue, you can batch requests to +TigerBeetle by having the worker act on multiple jobs from +the queue at once rather than one at a time. i.e. pulling +multiple jobs from the queue rather than just one. + +## Transfer Flags + +The transfer `flags` value is a bitfield. See details for these flags in +the [Transfers +reference](https://docs.tigerbeetle.com/reference/transfer#flags). + +To toggle behavior for a transfer, use the `TransferFlags` bitflags. +You can combine multiple flags using the `|` operator. Here are a +few examples: + +* `TransferFlags::Linked` +* `TransferFlags::Pending` +* `TransferFlags::PostPendingTransfer` +* `TransferFlags::VoidPendingTransfer` +* `TransferFlags::Linked | TransferFlags::Pending` + +For example, to link `transfer0` and `transfer1`: + +```rust +let transfer0 = tb::Transfer { + id: 4, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + flags: tb::TransferFlags::Linked, + ..Default::default() +}; +let transfer1 = tb::Transfer { + id: 5, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() +}; + +let transfer_results = client.create_transfers(&[transfer0, transfer1])?.await?; +// Result handling omitted. +``` + +### Two-Phase Transfers + +Two-phase transfers are supported natively by toggling the appropriate +flag. TigerBeetle will then adjust the `credits_pending` and +`debits_pending` fields of the appropriate accounts. A corresponding +post pending transfer then needs to be sent to post or void the +transfer. + +#### Post a Pending Transfer + +With `flags` set to `post_pending_transfer`, +TigerBeetle will post the transfer. TigerBeetle will atomically roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and apply them to the `debits_posted` and +`credits_posted` balances. + +```rust +let transfer0 = tb::Transfer { + id: 6, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() +}; + +let transfer_results = client.create_transfers(&[transfer0])?.await?; +// Result handling omitted. + +let transfer1 = tb::Transfer { + id: 7, + amount: u128::MAX, + pending_id: 6, + flags: tb::TransferFlags::PostPendingTransfer, + ..Default::default() +}; + +let transfer_results = client.create_transfers(&[transfer1])?.await?; +// Result handling omitted. +``` + +#### Void a Pending Transfer + +In contrast, with `flags` set to `void_pending_transfer`, +TigerBeetle will void the transfer. TigerBeetle will roll +back the changes to `debits_pending` and `credits_pending` of the +appropriate accounts and **not** apply them to the `debits_posted` and +`credits_posted` balances. + +```rust +let transfer0 = tb::Transfer { + id: 8, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() +}; + +let transfer_results = client.create_transfers(&[transfer0])?.await?; +// Result handling omitted. + +let transfer1 = tb::Transfer { + id: 9, + amount: 0, + pending_id: 8, + flags: tb::TransferFlags::VoidPendingTransfer, + ..Default::default() +}; + +let transfer_results = client.create_transfers(&[transfer1])?.await?; +// Result handling omitted. +``` + +## Transfer Lookup + +NOTE: While transfer lookup exists, it is not a flexible query API. We +are developing query APIs and there will be new methods for querying +transfers in the future. + +Transfer lookup is batched, like transfer creation. Pass in all `id`s to +fetch, and matched transfers are returned. + +If no transfer matches an `id`, no object is returned for that +transfer. So the order of transfers in the response is not necessarily +the same as the order of `id`s in the request. You can refer to the +`id` field in the response to distinguish transfers. + +```rust +let transfers = client.lookup_transfers(&[1, 2])?.await?; +``` + +## Get Account Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the transfers involving a given account, allowing basic filter and pagination +capabilities. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```rust +let filter = tb::AccountFilter { + account_id: 2, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + code: 0, + reserved: Default::default(), + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: tb::AccountFilterFlags::Debits + | tb::AccountFilterFlags::Credits + | tb::AccountFilterFlags::Reversed, +}; + +let transfers = client.get_account_transfers(filter)?.await?; +``` + +## Get Account Balances + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Fetches the point-in-time balances of a given account, allowing basic filter and +pagination capabilities. + +Only accounts created with the flag +[`history`](https://docs.tigerbeetle.com/reference/account#flagshistory) set retain +[historical balances](https://docs.tigerbeetle.com/reference/requests/get_account_balances). + +The balances in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```rust +let filter = tb::AccountFilter { + account_id: 2, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + code: 0, + reserved: Default::default(), + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: tb::AccountFilterFlags::Debits + | tb::AccountFilterFlags::Credits + | tb::AccountFilterFlags::Reversed, +}; + +let account_balances = client.get_account_balances(filter)?.await?; +``` + +## Query Accounts + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query accounts by the intersection of some fields and by timestamp range. + +The accounts in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```rust +let filter = tb::QueryFilter { + user_data_128: 1000, + user_data_64: 100, + user_data_32: 10, + code: 1, + ledger: 0, + reserved: Default::default(), + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: tb::QueryFilterFlags::Reversed, +}; + +let accounts = client.query_accounts(filter)?.await?; +``` + +## Query Transfers + +NOTE: This is a preview API that is subject to breaking changes once we have +a stable querying API. + +Query transfers by the intersection of some fields and by timestamp range. + +The transfers in the response are sorted by `timestamp` in chronological or +reverse-chronological order. + +```rust +let filter = tb::QueryFilter { + user_data_128: 1000, + user_data_64: 100, + user_data_32: 10, + code: 1, + ledger: 0, + reserved: Default::default(), + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: tb::QueryFilterFlags::Reversed, +}; + +let transfers = client.query_transfers(filter)?.await?; +``` + +## Linked Events + +When the `linked` flag is specified for an account when creating accounts or +a transfer when creating transfers, it links that event with the next event in the +batch, to create a chain of events, of arbitrary length, which all +succeed or fail together. The tail of a chain is denoted by the first +event without this flag. The last event in a batch may therefore never +have the `linked` flag set as this would leave a chain +open-ended. Multiple chains or individual events may coexist within a +batch to succeed or fail independently. + +Events within a chain are executed within order, or are rolled back on +error, so that the effect of each event in the chain is visible to the +next, and so that the chain is either visible or invisible as a unit +to subsequent events after the chain. The event that was the first to +break the chain will have a unique error result. Other events in the +chain will have their error result set to `linked_event_failed`. + +```rust +let mut batch = vec![]; +let linked_flag = tb::TransferFlags::Linked; + +// An individual transfer (successful): +batch.push(tb::Transfer { + id: 1, + ..Default::default() +}); + +// A chain of 4 transfers (the last transfer in the chain closes the chain with linked=false): +batch.push(tb::Transfer { + id: 2, + flags: linked_flag, + ..Default::default() +}); +batch.push(tb::Transfer { + id: 3, + flags: linked_flag, + ..Default::default() +}); +batch.push(tb::Transfer { + id: 2, + flags: linked_flag, + ..Default::default() +}); +batch.push(tb::Transfer { + id: 4, + ..Default::default() +}); + +// An individual transfer (successful): +// This should not see any effect from the failed chain above. +batch.push(tb::Transfer { + id: 2, + ..Default::default() +}); + +// A chain of 2 transfers (the first transfer fails the chain): +batch.push(tb::Transfer { + id: 2, + flags: linked_flag, + ..Default::default() +}); +batch.push(tb::Transfer { + id: 3, + ..Default::default() +}); + +// A chain of 2 transfers (successful): +batch.push(tb::Transfer { + id: 3, + flags: linked_flag, + ..Default::default() +}); +batch.push(tb::Transfer { + id: 4, + ..Default::default() +}); + +let transfer_results = client.create_transfers(&batch)?.await?; +// Result handling omitted. +``` + +## Imported Events + +When the `imported` flag is specified for an account when creating accounts or +a transfer when creating transfers, it allows importing historical events with +a user-defined timestamp. + +The entire batch of events must be set with the flag `imported`. + +It's recommended to submit the whole batch as a `linked` chain of events, ensuring that +if any event fails, none of them are committed, preserving the last timestamp unchanged. +This approach gives the application a chance to correct failed imported events, re-submitting +the batch again with the same user-defined timestamps. + +```rust +// External source of time. +let mut historical_timestamp: u64 = 0; +let historical_accounts: Vec = vec![]; // Loaded from an external source. +let historical_transfers: Vec = vec![]; // Loaded from an external source. + +// First, load and import all accounts with their timestamps from the historical source. +let mut accounts_batch = vec![]; +for (index, mut account) in historical_accounts.into_iter().enumerate() { + // Set a unique and strictly increasing timestamp. + historical_timestamp += 1; + account.timestamp = historical_timestamp; + + account.flags = if index < accounts_batch.len() - 1 { + tb::AccountFlags::Imported | tb::AccountFlags::Linked + } else { + tb::AccountFlags::Imported + }; + + accounts_batch.push(account); +} + +let account_results = client.create_accounts(&accounts_batch)?.await?; +// Result handling omitted. + +// Then, load and import all transfers with their timestamps from the historical source. +let mut transfers_batch = vec![]; +for (index, mut transfer) in historical_transfers.into_iter().enumerate() { + // Set a unique and strictly increasing timestamp. + historical_timestamp += 1; + transfer.timestamp = historical_timestamp; + + transfer.flags = if index < transfers_batch.len() - 1 { + tb::TransferFlags::Imported | tb::TransferFlags::Linked + } else { + tb::TransferFlags::Imported + }; + + transfers_batch.push(transfer); +} + +let transfer_results = client.create_transfers(&transfers_batch)?.await?; +// Result handling omitted. +// Since it is a linked chain, in case of any error the entire batch is rolled back and can be retried +// with the same historical timestamps without regressing the cluster timestamp. +``` + +## Timeouts And Cancellation + +The Client retries indefinitely and doesn't impose any per-request timeout. Cancellation is +provided as a mechanism, and the specific cancellation policy is left to the +application. A Client instance can be closed at any time. On close, all in-flight +requests are canceled and return an error to the caller. Even if an error is returned, +a request might still be processed by the TigerBeetle server. +[Reliable transaction submission](https://docs.tigerbeetle.com/coding/reliable-transaction-submission/) +explains how to make transfers retry-proof using IDs for end-to-end idempotency. diff --git a/ocam/src/clients/rust/assets/tb_client.h b/ocam/src/clients/rust/assets/tb_client.h new file mode 100644 index 00000000..bb4554e6 --- /dev/null +++ b/ocam/src/clients/rust/assets/tb_client.h @@ -0,0 +1,388 @@ + ////////////////////////////////////////////////////////// + // This file was auto-generated by tb_client_header.zig // + // Do not manually modify. // + ////////////////////////////////////////////////////////// + +#ifndef TB_CLIENT_H +#define TB_CLIENT_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +typedef __uint128_t tb_uint128_t; + +typedef enum TB_ACCOUNT_FLAGS { + TB_ACCOUNT_LINKED = 1 << 0, + TB_ACCOUNT_DEBITS_MUST_NOT_EXCEED_CREDITS = 1 << 1, + TB_ACCOUNT_CREDITS_MUST_NOT_EXCEED_DEBITS = 1 << 2, + TB_ACCOUNT_HISTORY = 1 << 3, + TB_ACCOUNT_IMPORTED = 1 << 4, + TB_ACCOUNT_CLOSED = 1 << 5, +} TB_ACCOUNT_FLAGS; + +typedef struct tb_account_t { + tb_uint128_t id; + tb_uint128_t debits_pending; + tb_uint128_t debits_posted; + tb_uint128_t credits_pending; + tb_uint128_t credits_posted; + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint32_t reserved; + uint32_t ledger; + uint16_t code; + uint16_t flags; + uint64_t timestamp; +} tb_account_t; + +typedef enum TB_TRANSFER_FLAGS { + TB_TRANSFER_LINKED = 1 << 0, + TB_TRANSFER_PENDING = 1 << 1, + TB_TRANSFER_POST_PENDING_TRANSFER = 1 << 2, + TB_TRANSFER_VOID_PENDING_TRANSFER = 1 << 3, + TB_TRANSFER_BALANCING_DEBIT = 1 << 4, + TB_TRANSFER_BALANCING_CREDIT = 1 << 5, + TB_TRANSFER_CLOSING_DEBIT = 1 << 6, + TB_TRANSFER_CLOSING_CREDIT = 1 << 7, + TB_TRANSFER_IMPORTED = 1 << 8, +} TB_TRANSFER_FLAGS; + +typedef struct tb_transfer_t { + tb_uint128_t id; + tb_uint128_t debit_account_id; + tb_uint128_t credit_account_id; + tb_uint128_t amount; + tb_uint128_t pending_id; + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint32_t timeout; + uint32_t ledger; + uint16_t code; + uint16_t flags; + uint64_t timestamp; +} tb_transfer_t; + +typedef enum TB_CREATE_ACCOUNT_STATUS { + TB_CREATE_ACCOUNT_CREATED = 0xFFFFFFFF, + TB_CREATE_ACCOUNT_LINKED_EVENT_FAILED = 1, + TB_CREATE_ACCOUNT_LINKED_EVENT_CHAIN_OPEN = 2, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_EXPECTED = 22, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_NOT_EXPECTED = 23, + TB_CREATE_ACCOUNT_TIMESTAMP_MUST_BE_ZERO = 3, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE = 24, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE = 25, + TB_CREATE_ACCOUNT_RESERVED_FIELD = 4, + TB_CREATE_ACCOUNT_RESERVED_FLAG = 5, + TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_ZERO = 6, + TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 7, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_FLAGS = 15, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_128 = 16, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_64 = 17, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_32 = 18, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_LEDGER = 19, + TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_CODE = 20, + TB_CREATE_ACCOUNT_EXISTS = 21, + TB_CREATE_ACCOUNT_FLAGS_ARE_MUTUALLY_EXCLUSIVE = 8, + TB_CREATE_ACCOUNT_DEBITS_PENDING_MUST_BE_ZERO = 9, + TB_CREATE_ACCOUNT_DEBITS_POSTED_MUST_BE_ZERO = 10, + TB_CREATE_ACCOUNT_CREDITS_PENDING_MUST_BE_ZERO = 11, + TB_CREATE_ACCOUNT_CREDITS_POSTED_MUST_BE_ZERO = 12, + TB_CREATE_ACCOUNT_LEDGER_MUST_NOT_BE_ZERO = 13, + TB_CREATE_ACCOUNT_CODE_MUST_NOT_BE_ZERO = 14, + TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS = 26, +} TB_CREATE_ACCOUNT_STATUS; + +typedef enum TB_CREATE_TRANSFER_STATUS { + TB_CREATE_TRANSFER_CREATED = 0xFFFFFFFF, + TB_CREATE_TRANSFER_LINKED_EVENT_FAILED = 1, + TB_CREATE_TRANSFER_LINKED_EVENT_CHAIN_OPEN = 2, + TB_CREATE_TRANSFER_IMPORTED_EVENT_EXPECTED = 56, + TB_CREATE_TRANSFER_IMPORTED_EVENT_NOT_EXPECTED = 57, + TB_CREATE_TRANSFER_TIMESTAMP_MUST_BE_ZERO = 3, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE = 58, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE = 59, + TB_CREATE_TRANSFER_RESERVED_FLAG = 4, + TB_CREATE_TRANSFER_ID_MUST_NOT_BE_ZERO = 5, + TB_CREATE_TRANSFER_ID_MUST_NOT_BE_INT_MAX = 6, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_FLAGS = 36, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_PENDING_ID = 40, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_TIMEOUT = 44, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_DEBIT_ACCOUNT_ID = 37, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CREDIT_ACCOUNT_ID = 38, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_AMOUNT = 39, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_128 = 41, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_64 = 42, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_32 = 43, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_LEDGER = 67, + TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CODE = 45, + TB_CREATE_TRANSFER_EXISTS = 46, + TB_CREATE_TRANSFER_ID_ALREADY_FAILED = 68, + TB_CREATE_TRANSFER_FLAGS_ARE_MUTUALLY_EXCLUSIVE = 7, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_ZERO = 8, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 9, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_ZERO = 10, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX = 11, + TB_CREATE_TRANSFER_ACCOUNTS_MUST_BE_DIFFERENT = 12, + TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_ZERO = 13, + TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_ZERO = 14, + TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_INT_MAX = 15, + TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_DIFFERENT = 16, + TB_CREATE_TRANSFER_TIMEOUT_RESERVED_FOR_PENDING_TRANSFER = 17, + TB_CREATE_TRANSFER_CLOSING_TRANSFER_MUST_BE_PENDING = 64, + TB_CREATE_TRANSFER_LEDGER_MUST_NOT_BE_ZERO = 19, + TB_CREATE_TRANSFER_CODE_MUST_NOT_BE_ZERO = 20, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_NOT_FOUND = 21, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_NOT_FOUND = 22, + TB_CREATE_TRANSFER_ACCOUNTS_MUST_HAVE_THE_SAME_LEDGER = 23, + TB_CREATE_TRANSFER_TRANSFER_MUST_HAVE_THE_SAME_LEDGER_AS_ACCOUNTS = 24, + TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_FOUND = 25, + TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_PENDING = 26, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_DEBIT_ACCOUNT_ID = 27, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CREDIT_ACCOUNT_ID = 28, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_LEDGER = 29, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CODE = 30, + TB_CREATE_TRANSFER_EXCEEDS_PENDING_TRANSFER_AMOUNT = 31, + TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_AMOUNT = 32, + TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_POSTED = 33, + TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_VOIDED = 34, + TB_CREATE_TRANSFER_PENDING_TRANSFER_EXPIRED = 35, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS = 60, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_DEBIT_ACCOUNT = 61, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_CREDIT_ACCOUNT = 62, + TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMEOUT_MUST_BE_ZERO = 63, + TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ALREADY_CLOSED = 65, + TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ALREADY_CLOSED = 66, + TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_PENDING = 47, + TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_PENDING = 48, + TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_POSTED = 49, + TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_POSTED = 50, + TB_CREATE_TRANSFER_OVERFLOWS_DEBITS = 51, + TB_CREATE_TRANSFER_OVERFLOWS_CREDITS = 52, + TB_CREATE_TRANSFER_OVERFLOWS_TIMEOUT = 53, + TB_CREATE_TRANSFER_EXCEEDS_CREDITS = 54, + TB_CREATE_TRANSFER_EXCEEDS_DEBITS = 55, +} TB_CREATE_TRANSFER_STATUS; + +typedef struct tb_create_account_result_t { + uint64_t timestamp; + uint32_t status; + uint32_t reserved; +} tb_create_account_result_t; + +typedef struct tb_create_transfer_result_t { + uint64_t timestamp; + uint32_t status; + uint32_t reserved; +} tb_create_transfer_result_t; + +typedef struct tb_account_filter_t { + tb_uint128_t account_id; + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint16_t code; + uint8_t reserved[58]; + uint64_t timestamp_min; + uint64_t timestamp_max; + uint32_t limit; + uint32_t flags; +} tb_account_filter_t; + +typedef enum TB_ACCOUNT_FILTER_FLAGS { + TB_ACCOUNT_FILTER_DEBITS = 1 << 0, + TB_ACCOUNT_FILTER_CREDITS = 1 << 1, + TB_ACCOUNT_FILTER_REVERSED = 1 << 2, +} TB_ACCOUNT_FILTER_FLAGS; + +typedef struct tb_account_balance_t { + tb_uint128_t debits_pending; + tb_uint128_t debits_posted; + tb_uint128_t credits_pending; + tb_uint128_t credits_posted; + uint64_t timestamp; + uint8_t reserved[56]; +} tb_account_balance_t; + +typedef struct tb_query_filter_t { + tb_uint128_t user_data_128; + uint64_t user_data_64; + uint32_t user_data_32; + uint32_t ledger; + uint16_t code; + uint8_t reserved[6]; + uint64_t timestamp_min; + uint64_t timestamp_max; + uint32_t limit; + uint32_t flags; +} tb_query_filter_t; + +typedef enum TB_QUERY_FILTER_FLAGS { + TB_QUERY_FILTER_REVERSED = 1 << 0, +} TB_QUERY_FILTER_FLAGS; + +// Opaque struct serving as a handle for the client instance. +// This struct must be "pinned" (not copyable or movable), as its address must remain stable +// throughout the lifetime of the client instance. +typedef struct tb_client_t { + uint64_t opaque[4]; +} tb_client_t; + +// Struct containing the state of a request submitted through the client. +// This struct must be "pinned" (not copyable or movable), as its address must remain stable +// throughout the lifetime of the request. +typedef struct tb_packet_t { + void* user_data; + void* data; + uint32_t data_size; + uint16_t user_tag; + uint8_t operation; + uint8_t status; + uint8_t opaque[64]; +} tb_packet_t; + +typedef enum TB_OPERATION { + TB_OPERATION_PULSE = 128, + TB_OPERATION_GET_CHANGE_EVENTS = 137, + TB_OPERATION_LOOKUP_ACCOUNTS = 140, + TB_OPERATION_LOOKUP_TRANSFERS = 141, + TB_OPERATION_GET_ACCOUNT_TRANSFERS = 142, + TB_OPERATION_GET_ACCOUNT_BALANCES = 143, + TB_OPERATION_QUERY_ACCOUNTS = 144, + TB_OPERATION_QUERY_TRANSFERS = 145, + TB_OPERATION_CREATE_ACCOUNTS = 146, + TB_OPERATION_CREATE_TRANSFERS = 147, +} TB_OPERATION; + +typedef enum TB_PACKET_STATUS { + TB_PACKET_OK = 0, + TB_PACKET_TOO_MUCH_DATA = 1, + TB_PACKET_CLIENT_EVICTED = 2, + TB_PACKET_CLIENT_RELEASE_TOO_LOW = 3, + TB_PACKET_CLIENT_RELEASE_TOO_HIGH = 4, + TB_PACKET_CLIENT_SHUTDOWN = 5, + TB_PACKET_INVALID_OPERATION = 6, + TB_PACKET_INVALID_DATA_SIZE = 7, +} TB_PACKET_STATUS; + +typedef enum TB_INIT_STATUS { + TB_INIT_SUCCESS = 0, + TB_INIT_UNEXPECTED = 1, + TB_INIT_OUT_OF_MEMORY = 2, + TB_INIT_ADDRESS_INVALID = 3, + TB_INIT_ADDRESS_LIMIT_EXCEEDED = 4, + TB_INIT_SYSTEM_RESOURCES = 5, + TB_INIT_NETWORK_SUBSYSTEM = 6, +} TB_INIT_STATUS; + +typedef enum TB_CLIENT_STATUS { + TB_CLIENT_OK = 0, + TB_CLIENT_INVALID = 1, +} TB_CLIENT_STATUS; + +typedef enum TB_REGISTER_LOG_CALLBACK_STATUS { + TB_REGISTER_LOG_CALLBACK_SUCCESS = 0, + TB_REGISTER_LOG_CALLBACK_ALREADY_REGISTERED = 1, + TB_REGISTER_LOG_CALLBACK_NOT_REGISTERED = 2, +} TB_REGISTER_LOG_CALLBACK_STATUS; + +typedef enum TB_LOG_LEVEL { + TB_LOG_ERR = 0, + TB_LOG_WARN = 1, + TB_LOG_INFO = 2, + TB_LOG_DEBUG = 3, +} TB_LOG_LEVEL; + +typedef struct tb_init_parameters_t { + tb_uint128_t cluster_id; + tb_uint128_t client_id; + uint8_t* addresses_ptr; + uint64_t addresses_len; +} tb_init_parameters_t; + +// Per-client callback invoked every time a `tb_client_submit` completes or is canceled. +// Use `packet->userdata` to identify the specific submission. +// `result` is null iff `packet->status != TB_PACKET_OK` +// `result` is only valid for the duration of the callback itself. +typedef void (*tb_completion_t)( + uintptr_t userdata, + tb_packet_t* packet, + uint64_t timestamp, + const uint8_t *result, // nullable + uint32_t result_size +); + +// Initialize a new TigerBeetle client which connects to the addresses provided and +// completes submitted packets by invoking the callback with the given context. +TB_INIT_STATUS tb_client_init( + tb_client_t *client_out, + // 128-bit unsigned integer represented as a 16-byte little-endian array. + const uint8_t cluster_id[16], + const char *address_ptr, + uint32_t address_len, + uintptr_t completion_ctx, + tb_completion_t completion_callback +); + +// Initialize a new TigerBeetle client that echoes back any submitted data. +TB_INIT_STATUS tb_client_init_echo( + tb_client_t *client_out, + // 128-bit unsigned integer represented as a 16-byte little-endian array. + const uint8_t cluster_id[16], + const char *address_ptr, + uint32_t address_len, + uintptr_t completion_ctx, + tb_completion_t completion_callback +); + +// Retrieve the parameters initially passed to `tb_client_init` or `tb_client_init_echo`. +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_init_parameters( + tb_client_t* client, + tb_init_parameters_t* init_parameters_out +); + +// Retrieve the callback context initially passed to `tb_client_init` or `tb_client_init_echo`. +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_completion_context( + tb_client_t* client, + uintptr_t* completion_ctx_out +); + +// Submit a packet with its `operation`, `data`, and `data_size` fields set. +// Once completed, `completion_callback` will be invoked with `completion_ctx` +// and the given packet on the `tb_client` thread (separate from the caller's thread). +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_submit( + tb_client_t *client, + tb_packet_t *packet +); + +// Closes the client, causing any previously submitted packets to be completed with +// `TB_PACKET_CLIENT_SHUTDOWN` before freeing any allocated client resources from init. +// Return value: `TB_CLIENT_OK` on success, or `TB_CLIENT_INVALID` if the client handle was +// not initialized or has already been closed. +TB_CLIENT_STATUS tb_client_deinit( + tb_client_t *client +); + +// Registers or unregisters the application log callback. +TB_REGISTER_LOG_CALLBACK_STATUS tb_client_register_log_callback( + void (*callback)(TB_LOG_LEVEL, const uint8_t*, uint32_t), + bool debug +); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif // TB_CLIENT_H diff --git a/ocam/src/clients/rust/build.rs b/ocam/src/clients/rust/build.rs new file mode 100644 index 00000000..2dcdc1d2 --- /dev/null +++ b/ocam/src/clients/rust/build.rs @@ -0,0 +1,65 @@ +use std::{env, error::Error, path::Path}; + +fn main() -> Result<(), Box> { + let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR")?; + + if !Path::new(&format!("{cargo_manifest_dir}/assets/tb_client.h")).try_exists()? { + panic!( + "\n\ + TigerBeetle assets not found for in-tree build.\n\ + Run `zig/zig build clients:rust -Drelease` first.\n" + ); + } + + assert!(Path::new(&format!("{cargo_manifest_dir}/src/tb_client.rs")).try_exists()?); + + println!("cargo:rerun-if-changed={cargo_manifest_dir}/assets/tb_client.h"); + + let unix = env::var("CARGO_CFG_UNIX").is_ok(); + let windows = env::var("CARGO_CFG_WINDOWS").is_ok(); + + let target_arch = env::var("CARGO_CFG_TARGET_ARCH")?; + let target_os = env::var("CARGO_CFG_TARGET_OS")?; + let target_env = env::var("CARGO_CFG_TARGET_ENV")?; + + let target_arch = target_arch.as_ref(); + let target_os = target_os.as_ref(); + let target_env = target_env.as_ref(); + + let libprefix = format!("{cargo_manifest_dir}/assets/lib"); + let archpath = match (target_arch, target_os, target_env) { + ("aarch64", "linux", "gnu") => "aarch64-linux-gnu.2.27", + ("aarch64", "linux", "musl") => "aarch64-linux-musl", + ("aarch64", "macos", "") => "aarch64-macos", + ("x86_64", "linux", "gnu") => "x86_64-linux-gnu.2.27", + ("x86_64", "linux", "musl") => "x86_64-linux-musl", + ("x86_64", "macos", "") => "x86_64-macos", + ("x86_64", "windows", "msvc") => "x86_64-windows", + (arch, os, abi) => todo!("Unsupported platform {arch}-{os}-{abi}"), + }; + + let libdir = format!("{libprefix}/{archpath}"); + let libname = "tb_client"; + + println!("cargo:rustc-link-search=native={libdir}"); + println!("cargo:rustc-link-lib=static={libname}"); + + let libfile = if unix { + format!("lib{libname}.a") + } else if windows { + format!("{libname}.lib") + } else { + todo!() + }; + let libpath = format!("{libdir}/{libfile}"); + + assert!(Path::new(&libpath).try_exists()?); + println!("cargo:rerun-if-changed={libpath}"); + + if windows { + // tb_client needs access to the random number generator in here. + println!("cargo:rustc-link-lib=advapi32"); + } + + Ok(()) +} diff --git a/ocam/src/clients/rust/ci.zig b/ocam/src/clients/rust/ci.zig new file mode 100644 index 00000000..97a3e87b --- /dev/null +++ b/ocam/src/clients/rust/ci.zig @@ -0,0 +1,140 @@ +const std = @import("std"); +const log = std.log; +const assert = std.debug.assert; + +const Shell = @import("stdx").Shell; +const TmpTigerBeetle = @import("../../testing/tmp_tigerbeetle.zig"); + +pub fn tests(shell: *Shell, gpa: std.mem.Allocator) !void { + try shell.exec_zig("build clients:rust -Drelease", .{}); + try shell.exec("cargo test --all", .{}); + try shell.exec("cargo fmt --check", .{}); + try shell.exec("cargo clippy -- -D clippy::all", .{}); + + inline for (.{ "basic", "two-phase", "two-phase-many", "walkthrough" }) |sample| { + try shell.pushd("./samples/" ++ sample); + defer shell.popd(); + + try shell.exec("cargo fmt --check", .{}); + try shell.exec("cargo clippy -- -D clippy::all", .{}); + assert(try file_contains(shell, gpa, "Cargo.toml", .{ + .needle = "overflow-checks = true", + .line_length_max = 100, + })); + + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + try shell.exec("cargo run", .{}); + } +} + +fn file_contains( + shell: *Shell, + gpa: std.mem.Allocator, + path: []const u8, + options: struct { + needle: []const u8, + line_length_max: u32 = 100, + }, +) !bool { + const file = try shell.cwd.openFile(path, .{}); + defer file.close(); + + const line_buffer = try gpa.alloc(u8, options.line_length_max + 1); + defer gpa.free(line_buffer); + + const reader = file.reader(); + while (try reader.readUntilDelimiterOrEof(line_buffer, '\n')) |line| { + if (std.mem.indexOf(u8, line, options.needle) != null) return true; + } + + return false; +} + +pub fn validate_release_package(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, +}) !void { + _ = shell; + _ = gpa; + _ = options; +} + +pub fn validate_release_sample(shell: *Shell, gpa: std.mem.Allocator, options: struct { + release: []const u8, + tigerbeetle: []const u8, +}) !void { + const tmp_dir = try shell.create_tmp_dir(); + defer shell.cwd.deleteTree(tmp_dir) catch {}; + + const base_dir = shell.cwd; + try shell.pushd(tmp_dir); + defer shell.popd(); + + try shell.exec("cargo init --name test_tigerbeetle", .{}); + + for (0..9) |_| { + if (shell.exec("cargo add tigerbeetle@{release}", .{ + .release = options.release, + })) { + break; + } else |_| { + log.warn("waiting for 5 minutes for the {s} version to appear on crates.io", .{ + options.release, + }); + std.time.sleep(5 * std.time.ns_per_min); + } + } else { + shell.exec("cargo add tigerbeetle@{release}", .{ + .release = options.release, + }) catch |err| { + log.err("package is not available on crates.io", .{}); + return err; + }; + } + + try shell.exec("cargo add futures@0.3", .{}); + + var tmp_beetle = try TmpTigerBeetle.init(gpa, .{ + .development = true, + .prebuilt = options.tigerbeetle, + }); + defer tmp_beetle.deinit(gpa); + errdefer tmp_beetle.log_stderr(); + + try shell.env.put("TB_ADDRESS", tmp_beetle.port_str); + + try Shell.copy_path( + base_dir, + "src/clients/rust/samples/basic/src/main.rs", + shell.cwd, + "src/main.rs", + ); + try shell.exec("cargo run", .{}); +} + +pub fn release_published_latest(shell: *Shell) ![]const u8 { + const CratesResponse = struct { + crate: struct { + max_version: []const u8, + }, + }; + + const response_body = try shell.http_get( + "https://crates.io/api/v1/crates/tigerbeetle", + .{}, + ); + + const crates_result = try std.json.parseFromSliceLeaky( + CratesResponse, + shell.arena.allocator(), + response_body, + .{ .ignore_unknown_fields = true }, + ); + + return crates_result.crate.max_version; +} diff --git a/ocam/src/clients/rust/docs.zig b/ocam/src/clients/rust/docs.zig new file mode 100644 index 00000000..6f605df9 --- /dev/null +++ b/ocam/src/clients/rust/docs.zig @@ -0,0 +1,96 @@ +const Docs = @import("../docs_types.zig").Docs; + +pub const RustDocs = Docs{ + .directory = "rust", + + .markdown_name = "rust", + .extension = "rs", + .proper_name = "Rust", + + .test_source_path = "src/", + + .name = "tigerbeetle-rust", + .description = + \\The TigerBeetle client for Rust. + \\ + \\[![crates.io](https://img.shields.io/crates/v/tigerbeetle)](https://crates.io/crates/tigerbeetle) + \\[![docs.rs](https://img.shields.io/docsrs/tigerbeetle)](https://docs.rs/tigerbeetle) + , + + .prerequisites = + \\* Rust 1.68+ + , + + .project_file_name = "Cargo.toml", + .project_file = + \\[package] + \\name = "tigerbeetle-test" + \\version = "0.1.0" + \\edition = "2024" + \\ + \\[dependencies] + \\tigerbeetle.path = "../.." + \\futures = "0.3" + , + + .test_file_name = "main", + + .install_commands = "", + + .run_commands = "cargo run", + + .examples = "", + + .client_object_documentation = "", + + .create_accounts_documentation = "", + + .account_flags_documentation = + \\To toggle behavior for an account, use the `AccountFlags` bitflags. + \\You can combine multiple flags using the `|` operator. Here are a + \\few examples: + \\ + \\* `AccountFlags::Linked` + \\* `AccountFlags::DebitsMustNotExceedCredits` + \\* `AccountFlags::CreditsMustNotExceedDebits` + \\* `AccountFlags::History` + \\* `AccountFlags::Linked | AccountFlags::History` + , + + .create_accounts_errors_documentation = "", + + .create_transfers_documentation = + \\Transfers support various types including regular, pending, linked, + \\and two-phase transfers. Use `TransferFlags` to specify behavior: + \\ + \\```rust + \\let transfer = Transfer { + \\ id: tb::id(), + \\ debit_account_id: account1_id, + \\ credit_account_id: account2_id, + \\ amount: 100, + \\ ledger: 1, + \\ code: 1, + \\ flags: TransferFlags::Pending, + \\ ..Default::default() + \\}; + \\``` + \\ + \\For linked transfers, set the `Linked` flag on all transfers in the + \\chain except the last one. If any transfer in a linked chain fails, + \\the entire chain is rolled back. + , + .create_transfers_errors_documentation = "", + + .transfer_flags_documentation = + \\To toggle behavior for a transfer, use the `TransferFlags` bitflags. + \\You can combine multiple flags using the `|` operator. Here are a + \\few examples: + \\ + \\* `TransferFlags::Linked` + \\* `TransferFlags::Pending` + \\* `TransferFlags::PostPendingTransfer` + \\* `TransferFlags::VoidPendingTransfer` + \\* `TransferFlags::Linked | TransferFlags::Pending` + , +}; diff --git a/ocam/src/clients/rust/rust_bindings.zig b/ocam/src/clients/rust/rust_bindings.zig new file mode 100644 index 00000000..c1ffcc7c --- /dev/null +++ b/ocam/src/clients/rust/rust_bindings.zig @@ -0,0 +1,355 @@ +const std = @import("std"); +const vsr = @import("vsr"); +const exports = vsr.tb_client.exports; +const assert = std.debug.assert; +const stdx = vsr.stdx; + +const type_mappings = .{ + .{ exports.tb_account_flags, "AccountFlags" }, + .{ exports.tb_account_t, "tb_account_t" }, + .{ exports.tb_transfer_flags, "TransferFlags" }, + .{ exports.tb_transfer_t, "tb_transfer_t" }, + .{ exports.tb_create_account_status, "TB_CREATE_ACCOUNT_STATUS" }, + .{ exports.tb_create_transfer_status, "TB_CREATE_TRANSFER_STATUS" }, + .{ exports.tb_create_account_result_t, "tb_create_account_result_t" }, + .{ exports.tb_create_transfer_result_t, "tb_create_transfer_result_t" }, + .{ exports.tb_account_filter_t, "tb_account_filter_t" }, + .{ exports.tb_account_filter_flags, "AccountFilterFlags" }, + .{ exports.tb_account_balance_t, "tb_account_balance_t" }, + .{ exports.tb_query_filter_t, "tb_query_filter_t" }, + .{ exports.tb_query_filter_flags, "QueryFilterFlags" }, + .{ + exports.tb_client_t, "tb_client_t", + \\// Opaque struct serving as a handle for the client instance. + \\// This struct must be "pinned" (not copyable or movable), as its address must remain stable + \\// throughout the lifetime of the client instance. + }, + .{ + exports.tb_packet_t, "tb_packet_t", + \\// Struct containing the state of a request submitted through the client. + \\// This struct must be "pinned" (not copyable or movable), as its address must remain stable + \\// throughout the lifetime of the request. + }, + .{ exports.tb_operation, "TB_OPERATION" }, + .{ exports.tb_packet_status, "TB_PACKET_STATUS" }, + .{ exports.tb_init_status, "TB_INIT_STATUS" }, + .{ exports.tb_client_status, "TB_CLIENT_STATUS" }, + .{ exports.tb_register_log_callback_status, "TB_REGISTER_LOG_CALLBACK_STATUS" }, + .{ exports.tb_log_level, "TB_LOG_LEVEL" }, +}; + +fn resolve_rust_type(comptime Type: type) []const u8 { + switch (@typeInfo(Type)) { + .array => |info| return resolve_rust_type(info.child), + .@"enum" => |info| return resolve_rust_type(info.tag_type), + .@"struct" => return resolve_rust_type(std.meta.Int(.unsigned, @bitSizeOf(Type))), + .bool => return "u8", // todo "bool" + .int => |info| { + assert(info.signedness == .unsigned); + return switch (info.bits) { + 8 => "u8", + 16 => "u16", + 32 => "u32", + 64 => "u64", + 128 => "u128", + else => @compileError("invalid int type"), + }; + }, + .optional => |info| switch (@typeInfo(info.child)) { + .pointer => return resolve_rust_type(info.child), + else => @compileError("Unsupported optional type: " ++ @typeName(Type)), + }, + .pointer => |info| { + assert(info.size != .slice); + assert(!info.is_allowzero); + + inline for (type_mappings) |type_mapping| { + const ZigType = type_mapping[0]; + const c_name = type_mapping[1]; + + if (info.child == ZigType) { + return "*mut " ++ c_name; + } + } + + return comptime "*mut " ++ resolve_rust_type(info.child); + }, + .void, .@"opaque" => return "::std::os::raw::c_void", + else => @compileError("Unhandled type: " ++ @typeName(Type)), + } +} + +fn emit_bitflags( + writer: anytype, + comptime Type: type, + comptime type_info: std.builtin.Type.Struct, + comptime rust_name: []const u8, + comptime skip_fields: []const []const u8, +) !void { + assert(@typeInfo(Type).@"struct".layout == .@"packed"); + assert(std.mem.count(u8, rust_name, "_") == 0); + assert(rust_name[0] >= 'A' and rust_name[0] <= 'Z'); + + const backing_type_text = switch (@typeInfo(type_info.backing_integer.?)) { + .int => |i| brk: { + break :brk switch (i.bits) { + 32 => switch (i.signedness) { + .unsigned => "u32", + .signed => "i32", + }, + 16 => "u16", + 8 => "u8", + else => @panic("unexpected"), + }; + }, + else => @panic("unexpected"), + }; + + try writer.print( + \\#[derive(Copy, Clone, Debug, Default)] + \\#[derive(Eq, PartialEq, Ord, PartialOrd, Hash)] + \\#[repr(transparent)] + \\pub struct {[rust_name]s}(pub {[backing_type_text]s}); + \\ + , .{ .rust_name = rust_name, .backing_type_text = backing_type_text }); + + try writer.print("impl {s} {{\n", .{rust_name}); + { + inline for (type_info.fields, 0..) |field, bit_index| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + comptime var skip = false; + inline for (skip_fields) |sf| { + skip = skip or comptime std.mem.eql(u8, sf, field.name); + } + if (skip) continue; + + assert(field.type == bool); + const field_name = stdx.to_case(field.name, .PascalCase); + try writer.print(" pub const {s}: {s} = {s}(1 << {});\n", .{ + field_name, + rust_name, + rust_name, + bit_index, + }); + } + try writer.print("\n", .{}); + try writer.print(" pub fn empty() -> Self {{ {s}(0) }}\n", .{rust_name}); + } + try writer.print("}}\n\n", .{}); + + try writer.print( + \\impl std::ops::BitOr for {[rust_name]s} {{ + \\ type Output = {[rust_name]s}; + \\ fn bitor(self, rhs: Self) -> Self::Output {{ + \\ Self(self.0 | rhs.0) + \\ }} + \\}} + \\ + \\ + , .{ .rust_name = rust_name }); +} + +fn emit_enum( + writer: anytype, + comptime Type: type, + comptime type_info: std.builtin.Type.Enum, + comptime rust_name: []const u8, + comptime skip_fields: []const []const u8, +) !void { + var suffix_pos = std.mem.lastIndexOf(u8, rust_name, "_").?; + if (std.mem.count(u8, rust_name, "_") == 1) suffix_pos = rust_name.len; + + const backing_type_text = switch (@typeInfo(type_info.tag_type)) { + .int => |i| brk: { + break :brk switch (i.bits) { + 32 => switch (i.signedness) { + .unsigned => "u32", + .signed => "i32", + }, + 16 => "u16", + 8 => "u8", + else => @panic("unexpected"), + }; + }, + else => @panic("unexpected"), + }; + + try writer.print("pub type {s} = {s};\n", .{ rust_name, backing_type_text }); + + inline for (type_info.fields) |field| { + if (comptime std.mem.startsWith(u8, field.name, "deprecated_")) continue; + comptime var skip = false; + inline for (skip_fields) |sf| { + skip = skip or comptime std.mem.eql(u8, sf, field.name); + } + if (skip) continue; + + const field_name = stdx.to_case(field.name, .UPPER_CASE); + const int_value = @intFromEnum(@field(Type, field.name)); + try writer.print("pub const {s}_{s}_{s}: {s} = {s};\n", .{ + rust_name, + rust_name[0..suffix_pos], + field_name, + rust_name, + if (int_value == std.math.maxInt(@TypeOf(int_value))) + std.fmt.comptimePrint("0x{X}", .{int_value}) + else + std.fmt.comptimePrint("{}", .{int_value}), + }); + } + + try writer.print("\n", .{}); +} + +fn emit_struct( + writer: anytype, + comptime type_info: anytype, + comptime rust_name: []const u8, +) !void { + try writer.print("#[repr(C)]\n", .{}); + try writer.print("#[derive(Debug, Copy, Clone)]\n", .{}); + try writer.print("pub struct {s} {{\n", .{rust_name}); + + inline for (type_info.fields) |field| { + switch (@typeInfo(field.type)) { + .array => |array| { + try writer.print(" pub {s}: [{s}; {}]", .{ + field.name, + resolve_rust_type(field.type), + array.len, + }); + }, + else => { + try writer.print(" pub {s}: {s}", .{ + field.name, + resolve_rust_type(field.type), + }); + }, + } + + try writer.print(",\n", .{}); + } + + try writer.print("}}\n\n", .{}); +} + +pub fn main() !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var buffer = std.ArrayList(u8).init(allocator); + var writer = buffer.writer(); + try writer.print( + \\ /////////////////////////////////////////////////////// + \\ // This file was auto-generated by rust_bindings.zig // + \\ // Do not manually modify. // + \\ /////////////////////////////////////////////////////// + \\ + \\ + , .{}); + + inline for (type_mappings) |type_mapping| { + const ZigType = type_mapping[0]; + const rust_name = type_mapping[1]; + if (type_mapping.len == 3) { + const comments: []const u8 = type_mapping[2]; + try writer.print(comments, .{}); + try writer.print("\n", .{}); + } + + switch (@typeInfo(ZigType)) { + .@"struct" => |info| switch (info.layout) { + .auto => @compileError("Invalid C struct type: " ++ @typeName(ZigType)), + .@"packed" => try emit_bitflags(writer, ZigType, info, rust_name, &.{"padding"}), + .@"extern" => try emit_struct(writer, info, rust_name), + }, + .@"enum" => |info| { + try emit_enum(writer, ZigType, info, rust_name, &.{}); + }, + else => try writer.print("pub type {s} = {s};\n\n", .{ + rust_name, + resolve_rust_type(ZigType), + }), + } + } + + try writer.print( + \\extern "C" {{ + \\ // Initialize a new TigerBeetle client which connects to the addresses provided and + \\ // completes submitted packets by invoking the callback with the given context. + \\ pub fn tb_client_init( + \\ client_out: *mut tb_client_t, + \\ // 128-bit unsigned integer represented as a 16-byte little-endian array. + \\ cluster_id: *const [u8; 16], + \\ address_ptr: *const ::std::os::raw::c_char, + \\ address_len: u32, + \\ completion_ctx: usize, + \\ completion_callback: ::std::option::Option< + \\ unsafe extern "C" fn( + \\ arg1: usize, + \\ arg3: *mut tb_packet_t, + \\ arg4: u64, + \\ arg5: *const u8, + \\ arg6: u32, + \\ ), + \\ >, + \\ ) -> TB_INIT_STATUS; + \\ + \\ // Initialize a new TigerBeetle client which echos back any data submitted. + \\ pub fn tb_client_init_echo( + \\ client_out: *mut tb_client_t, + \\ // 128-bit unsigned integer represented as a 16-byte little-endian array. + \\ cluster_id: *const [u8; 16], + \\ address_ptr: *const ::std::os::raw::c_char, + \\ address_len: u32, + \\ completion_ctx: usize, + \\ completion_callback: ::std::option::Option< + \\ unsafe extern "C" fn( + \\ arg1: usize, + \\ arg3: *mut tb_packet_t, + \\ arg4: u64, + \\ arg5: *const u8, + \\ arg6: u32, + \\ ), + \\ >, + \\ ) -> TB_INIT_STATUS; + \\ + \\ // Retrieve the callback context initially passed into `tb_client_init` or + \\ // `tb_client_init_echo`. + \\ pub fn tb_client_completion_context( + \\ client: *mut tb_client_t, + \\ completion_ctx_out: *mut usize, + \\ ) -> TB_CLIENT_STATUS; + \\ + \\ // Submit a packet with its operation, data, and data_size fields set. + \\ // Once completed, `on_completion` will be invoked with `on_completion_ctx` and the given + \\ // packet on the `tb_client` thread (separate from caller's thread). + \\ pub fn tb_client_submit( + \\ client: *mut tb_client_t, + \\ packet: *mut tb_packet_t, + \\ ) -> TB_CLIENT_STATUS; + \\ + \\ // Closes the client, causing any previously submitted packets to be completed with + \\ // `TB_PACKET_CLIENT_SHUTDOWN` before freeing any allocated client resources from init. + \\ // It is undefined behavior to use any functions on the client once deinit is called. + \\ pub fn tb_client_deinit( + \\ client: *mut tb_client_t, + \\ ) -> TB_CLIENT_STATUS; + \\ + \\ // Registers or unregisters the application log callback. + \\ pub fn register_log_callback( + \\ callback: ::std::option::Option< + \\ unsafe extern "C" fn( + \\ TB_LOG_LEVEL, + \\ *const u8, + \\ u32, + \\ ), + \\ >, + \\ debug: bool, + \\ ) -> TB_REGISTER_LOG_CALLBACK_STATUS; + \\}} + , .{}); + + try std.io.getStdOut().writeAll(buffer.items); +} diff --git a/ocam/src/clients/rust/samples/basic/Cargo.toml b/ocam/src/clients/rust/samples/basic/Cargo.toml new file mode 100644 index 00000000..d2f10af6 --- /dev/null +++ b/ocam/src/clients/rust/samples/basic/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "tigerbeetle-sample-basic" +version = "0.1.0" +edition = "2021" + +[dependencies] +futures = { version = "0.3.31", default-features = false, features = ["executor"] } +tigerbeetle.path = "../.." + +[profile.release] +# It is *strongly* recommended that Rust applications using TigerBeetle +# enable overflow checks, because the nature of accounting makes overflow +# errors catastrophic. +# +# Note that the following line enables the checks only for the tests in +# this crate. In other words, overflow checks must be enabled in the +# Cargo.toml of the end application. +overflow-checks = true diff --git a/ocam/src/clients/rust/samples/basic/README.md b/ocam/src/clients/rust/samples/basic/README.md new file mode 100644 index 00000000..26af8dde --- /dev/null +++ b/ocam/src/clients/rust/samples/basic/README.md @@ -0,0 +1,57 @@ + +# Basic Rust Sample + +Code for this sample is in [./src/main.rs](./src/main.rs). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Rust 1.68+ + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/rust/samples/basic`. + +Then, install the TigerBeetle client: + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +cargo run +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create a transfer + +Then it transfers `10` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate account balances + +Then it fetches both accounts, checks they both exist, and +checks that **account `1`** has: + * `debits_posted = 10` + * and `credits_posted = 0` + +And that **account `2`** has: + * `debits_posted= 0` + * and `credits_posted = 10` diff --git a/ocam/src/clients/rust/samples/basic/src/main.rs b/ocam/src/clients/rust/samples/basic/src/main.rs new file mode 100644 index 00000000..fa15032d --- /dev/null +++ b/ocam/src/clients/rust/samples/basic/src/main.rs @@ -0,0 +1,65 @@ +use tigerbeetle as tb; + +fn main() -> Result<(), Box> { + futures::executor::block_on(main_async()) +} + +async fn main_async() -> Result<(), Box> { + let port = std::env::var("TB_ADDRESS").unwrap_or_else(|_| "3000".to_string()); + let client = tb::Client::new(0, &port)?; + + // Create two accounts + let account_results = client + .create_accounts(&[ + tb::Account { + id: 1, + ledger: 1, + code: 1, + ..Default::default() + }, + tb::Account { + id: 2, + ledger: 1, + code: 1, + ..Default::default() + }, + ])? + .await?; + + assert!(account_results.len() == 2); + assert!(account_results[0].status == tb::CreateAccountStatus::Created); + assert!(account_results[1].status == tb::CreateAccountStatus::Created); + + let transfer_results = client + .create_transfers(&[tb::Transfer { + id: 1, + debit_account_id: 1, + credit_account_id: 2, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() + }])? + .await?; + + assert!(transfer_results.len() == 1); + assert!(transfer_results[0].status == tb::CreateTransferStatus::Created); + + // Check the sums for both accounts + let accounts = client.lookup_accounts(&[1, 2])?.await?; + assert_eq!(accounts.len(), 2); + + for account in accounts { + if account.id == 1 { + assert_eq!(account.debits_posted, 10); + assert_eq!(account.credits_posted, 0); + } else if account.id == 2 { + assert_eq!(account.debits_posted, 0); + assert_eq!(account.credits_posted, 10); + } else { + panic!("Unexpected account"); + } + } + + Ok(()) +} diff --git a/ocam/src/clients/rust/samples/two-phase-many/Cargo.toml b/ocam/src/clients/rust/samples/two-phase-many/Cargo.toml new file mode 100644 index 00000000..ad3fc840 --- /dev/null +++ b/ocam/src/clients/rust/samples/two-phase-many/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "tigerbeetle-sample-two-phase-many" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio.version = "=1.38.1" +tokio.features = ["rt-multi-thread"] +tigerbeetle.path = "../.." + +[profile.release] +# It is *strongly* recommended that Rust applications using TigerBeetle +# enable overflow checks, because the nature of accounting makes overflow +# errors catastrophic. +# +# Note that the following line enables the checks only for the tests in +# this crate. In other words, overflow checks must be enabled in the +# Cargo.toml of the end application. +overflow-checks = true diff --git a/ocam/src/clients/rust/samples/two-phase-many/README.md b/ocam/src/clients/rust/samples/two-phase-many/README.md new file mode 100644 index 00000000..2080ea5e --- /dev/null +++ b/ocam/src/clients/rust/samples/two-phase-many/README.md @@ -0,0 +1,87 @@ + +# Many Two-Phase Transfers Rust Sample + +Code for this sample is in [./src/main.rs](./src/main.rs). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Rust 1.68+ + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/rust/samples/two-phase-many`. + +Then, install the TigerBeetle client: + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +cargo run +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfers + +Then it begins 5 pending transfers of amounts `100` to +`500`, incrementing by `100` for each transfer. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 1500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 1500` + +(This is because a pending transfer only affects **pending** +credits and debits on accounts, not **posted** credits and +debits.) + +## 4. Post and void alternating transfers + +Then it alternatively posts and voids each transfer, +checking account balances after each transfer. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +solely *posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 900` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 900` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/rust/samples/two-phase-many/src/main.rs b/ocam/src/clients/rust/samples/two-phase-many/src/main.rs new file mode 100644 index 00000000..091073c2 --- /dev/null +++ b/ocam/src/clients/rust/samples/two-phase-many/src/main.rs @@ -0,0 +1,383 @@ +use tigerbeetle as tb; + +fn main() -> Result<(), Box> { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap() + .block_on(main_async()) +} + +async fn main_async() -> Result<(), Box> { + let port = std::env::var("TB_ADDRESS").unwrap_or_else(|_| "3000".to_string()); + let client = tb::Client::new(0, &port)?; + + // Create two accounts + let account_results = client + .create_accounts(&[ + tb::Account { + id: 1, + ledger: 1, + code: 1, + ..Default::default() + }, + tb::Account { + id: 2, + ledger: 1, + code: 1, + ..Default::default() + }, + ])? + .await?; + + assert!(account_results.len() == 2); + assert!(account_results[0].status == tb::CreateAccountStatus::Created); + assert!(account_results[1].status == tb::CreateAccountStatus::Created); + + // Start five pending transfers + let transfers = vec![ + tb::Transfer { + id: 1, + debit_account_id: 1, + credit_account_id: 2, + amount: 100, + ledger: 1, + code: 1, + flags: tb::TransferFlags::Pending, + ..Default::default() + }, + tb::Transfer { + id: 2, + debit_account_id: 1, + credit_account_id: 2, + amount: 200, + ledger: 1, + code: 1, + flags: tb::TransferFlags::Pending, + ..Default::default() + }, + tb::Transfer { + id: 3, + debit_account_id: 1, + credit_account_id: 2, + amount: 300, + ledger: 1, + code: 1, + flags: tb::TransferFlags::Pending, + ..Default::default() + }, + tb::Transfer { + id: 4, + debit_account_id: 1, + credit_account_id: 2, + amount: 400, + ledger: 1, + code: 1, + flags: tb::TransferFlags::Pending, + ..Default::default() + }, + tb::Transfer { + id: 5, + debit_account_id: 1, + credit_account_id: 2, + amount: 500, + ledger: 1, + code: 1, + flags: tb::TransferFlags::Pending, + ..Default::default() + }, + ]; + + let transfer_results = client.create_transfers(&transfers)?.await?; + assert!(transfer_results.len() == transfers.len()); + for result in &transfer_results { + assert!(result.status == tb::CreateTransferStatus::Created); + } + + // Validate accounts pending and posted debits/credits before finishing the two-phase transfer + assert_account_balances( + &client, + &[ + tb::Account { + id: 1, + debits_posted: 0, + credits_posted: 0, + debits_pending: 1500, + credits_pending: 0, + ..Default::default() + }, + tb::Account { + id: 2, + debits_posted: 0, + credits_posted: 0, + debits_pending: 0, + credits_pending: 1500, + ..Default::default() + }, + ], + "after starting 5 pending transfers", + ) + .await?; + + // Create a 6th transfer posting the 1st transfer + let transfer_results = client + .create_transfers(&[tb::Transfer { + id: 6, + debit_account_id: 1, + credit_account_id: 2, + amount: 100, + pending_id: 1, + ledger: 1, + code: 1, + flags: tb::TransferFlags::PostPendingTransfer, + ..Default::default() + }])? + .await?; + + assert!(transfer_results.len() == 1); + assert!(transfer_results[0].status == tb::CreateTransferStatus::Created); + + // Validate account balances after posting 1st pending transfer + assert_account_balances( + &client, + &[ + tb::Account { + id: 1, + debits_posted: 100, + credits_posted: 0, + debits_pending: 1400, + credits_pending: 0, + ..Default::default() + }, + tb::Account { + id: 2, + debits_posted: 0, + credits_posted: 100, + debits_pending: 0, + credits_pending: 1400, + ..Default::default() + }, + ], + "after completing 1 pending transfer", + ) + .await?; + + // Create a 7th transfer voiding the 2nd transfer + let transfer_results = client + .create_transfers(&[tb::Transfer { + id: 7, + debit_account_id: 1, + credit_account_id: 2, + amount: 200, + pending_id: 2, + ledger: 1, + code: 1, + flags: tb::TransferFlags::VoidPendingTransfer, + ..Default::default() + }])? + .await?; + + assert!(transfer_results.len() == 1); + assert!(transfer_results[0].status == tb::CreateTransferStatus::Created); + + // Validate account balances after voiding 2nd pending transfer + assert_account_balances( + &client, + &[ + tb::Account { + id: 1, + debits_posted: 100, + credits_posted: 0, + debits_pending: 1200, + credits_pending: 0, + ..Default::default() + }, + tb::Account { + id: 2, + debits_posted: 0, + credits_posted: 100, + debits_pending: 0, + credits_pending: 1200, + ..Default::default() + }, + ], + "after completing 2 pending transfers", + ) + .await?; + + // Create a 8th transfer posting the 3rd transfer + let transfer_results = client + .create_transfers(&[tb::Transfer { + id: 8, + debit_account_id: 1, + credit_account_id: 2, + amount: 300, + pending_id: 3, + ledger: 1, + code: 1, + flags: tb::TransferFlags::PostPendingTransfer, + ..Default::default() + }])? + .await?; + + assert!(transfer_results.len() == 1); + assert!(transfer_results[0].status == tb::CreateTransferStatus::Created); + + // Validate account balances after posting 3rd pending transfer + assert_account_balances( + &client, + &[ + tb::Account { + id: 1, + debits_posted: 400, + credits_posted: 0, + debits_pending: 900, + credits_pending: 0, + ..Default::default() + }, + tb::Account { + id: 2, + debits_posted: 0, + credits_posted: 400, + debits_pending: 0, + credits_pending: 900, + ..Default::default() + }, + ], + "after completing 3 pending transfers", + ) + .await?; + + // Create a 9th transfer voiding the 4th transfer + let transfer_results = client + .create_transfers(&[tb::Transfer { + id: 9, + debit_account_id: 1, + credit_account_id: 2, + amount: 400, + pending_id: 4, + ledger: 1, + code: 1, + flags: tb::TransferFlags::VoidPendingTransfer, + ..Default::default() + }])? + .await?; + + assert!(transfer_results.len() == 1); + assert!(transfer_results[0].status == tb::CreateTransferStatus::Created); + + // Validate account balances after voiding 4th pending transfer + assert_account_balances( + &client, + &[ + tb::Account { + id: 1, + debits_posted: 400, + credits_posted: 0, + debits_pending: 500, + credits_pending: 0, + ..Default::default() + }, + tb::Account { + id: 2, + debits_posted: 0, + credits_posted: 400, + debits_pending: 0, + credits_pending: 500, + ..Default::default() + }, + ], + "after completing 4 pending transfers", + ) + .await?; + + // Create a 10th transfer posting the 5th transfer + let transfer_results = client + .create_transfers(&[tb::Transfer { + id: 10, + debit_account_id: 1, + credit_account_id: 2, + amount: 500, + pending_id: 5, + ledger: 1, + code: 1, + flags: tb::TransferFlags::PostPendingTransfer, + ..Default::default() + }])? + .await?; + + assert!(transfer_results.len() == 1); + assert!(transfer_results[0].status == tb::CreateTransferStatus::Created); + + // Validate account balances after posting 5th pending transfer + assert_account_balances( + &client, + &[ + tb::Account { + id: 1, + debits_posted: 900, + credits_posted: 0, + debits_pending: 0, + credits_pending: 0, + ..Default::default() + }, + tb::Account { + id: 2, + debits_posted: 0, + credits_posted: 900, + debits_pending: 0, + credits_pending: 0, + ..Default::default() + }, + ], + "after completing 5 pending transfers", + ) + .await?; + + Ok(()) +} + +async fn assert_account_balances( + client: &tb::Client, + expected_accounts: &[tb::Account], + debug_msg: &str, +) -> Result<(), Box> { + let ids: Vec = expected_accounts.iter().map(|a| a.id).collect(); + let found_accounts = client.lookup_accounts(&ids)?.await?; + assert_eq!(expected_accounts.len(), found_accounts.len(), "accounts"); + + for found_account in &found_accounts { + let mut requested = false; + for expected_account in expected_accounts { + if expected_account.id == found_account.id { + requested = true; + assert_eq!( + expected_account.debits_posted, found_account.debits_posted, + "account {} debits, {}", + expected_account.id, debug_msg + ); + assert_eq!( + expected_account.credits_posted, found_account.credits_posted, + "account {} credits, {}", + expected_account.id, debug_msg + ); + assert_eq!( + expected_account.debits_pending, found_account.debits_pending, + "account {} debits pending, {}", + expected_account.id, debug_msg + ); + assert_eq!( + expected_account.credits_pending, found_account.credits_pending, + "account {} credits pending, {}", + expected_account.id, debug_msg + ); + } + } + + if !requested { + panic!("Unexpected account: {}", found_account.id); + } + } + + Ok(()) +} diff --git a/ocam/src/clients/rust/samples/two-phase/Cargo.toml b/ocam/src/clients/rust/samples/two-phase/Cargo.toml new file mode 100644 index 00000000..96489a72 --- /dev/null +++ b/ocam/src/clients/rust/samples/two-phase/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "tigerbeetle-sample-two-phase" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio.version = "=1.38.1" +tokio.features = ["rt-multi-thread"] +tigerbeetle.path = "../.." + +[profile.release] +# It is *strongly* recommended that Rust applications using TigerBeetle +# enable overflow checks, because the nature of accounting makes overflow +# errors catastrophic. +# +# Note that the following line enables the checks only for the tests in +# this crate. In other words, overflow checks must be enabled in the +# Cargo.toml of the end application. +overflow-checks = true diff --git a/ocam/src/clients/rust/samples/two-phase/README.md b/ocam/src/clients/rust/samples/two-phase/README.md new file mode 100644 index 00000000..c8512c05 --- /dev/null +++ b/ocam/src/clients/rust/samples/two-phase/README.md @@ -0,0 +1,96 @@ + +# Two-Phase Transfer Rust Sample + +Code for this sample is in [./src/main.rs](./src/main.rs). + +## Prerequisites + +Linux >= 5.6 is the only production environment we +support. But for ease of development we also support macOS and Windows. +* Rust 1.68+ + +## Setup + +First, clone this repo and `cd` into `tigerbeetle/src/clients/rust/samples/two-phase`. + +Then, install the TigerBeetle client: + +## Start the TigerBeetle server + +Follow steps in the repo README to [run +TigerBeetle](/README.md#running-tigerbeetle). + +If you are not running on port `localhost:3000`, set +the environment variable `TB_ADDRESS` to the full +address of the TigerBeetle server you started. + +## Run this sample + +Now you can run this sample: + +```console +cargo run +``` + +## Walkthrough + +Here's what this project does. + +## 1. Create accounts + +This project starts by creating two accounts (`1` and `2`). + +## 2. Create pending transfer + +Then it begins a +pending transfer of `500` of an amount from account `1` to +account `2`. + +## 3. Fetch and validate pending account balances + +Then it fetches both accounts and validates that **account `1`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 500` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 500` + +(This is because a pending +transfer only affects **pending** credits and debits on accounts, +not **posted** credits and debits.) + +## 4. Post pending transfer + +Then it creates a second transfer that marks the first +transfer as posted. + +## 5. Fetch and validate transfers + +Then it fetches both transfers, validates +that the two transfers exist, validates that the first +transfer had (and still has) a `pending` flag, and validates +that the second transfer had (and still has) a +`post_pending_transfer` flag. + +## 6. Fetch and validate final account balances + +Finally, it fetches both accounts, validates that both exist, +and checks that credits and debits for both accounts are now +*posted*, not pending. + +Specifically, that **account `1`** has: + * `debits_posted = 500` + * `credits_posted = 0` + * `debits_pending = 0` + * and `credits_pending = 0` + +And that **account `2`** has: + * `debits_posted = 0` + * `credits_posted = 500` + * `debits_pending = 0` + * and `credits_pending = 0` diff --git a/ocam/src/clients/rust/samples/two-phase/src/main.rs b/ocam/src/clients/rust/samples/two-phase/src/main.rs new file mode 100644 index 00000000..b14baf0d --- /dev/null +++ b/ocam/src/clients/rust/samples/two-phase/src/main.rs @@ -0,0 +1,159 @@ +use tigerbeetle as tb; + +fn main() -> Result<(), Box> { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap() + .block_on(main_async()) +} + +async fn main_async() -> Result<(), Box> { + let port = std::env::var("TB_ADDRESS").unwrap_or_else(|_| "3000".to_string()); + let client = tb::Client::new(0, &port)?; + + // Create two accounts + let account_results = client + .create_accounts(&[ + tb::Account { + id: 1, + ledger: 1, + code: 1, + ..Default::default() + }, + tb::Account { + id: 2, + ledger: 1, + code: 1, + ..Default::default() + }, + ])? + .await?; + + assert!(account_results.len() == 2); + assert!(account_results[0].status == tb::CreateAccountStatus::Created); + assert!(account_results[1].status == tb::CreateAccountStatus::Created); + + // Start a pending transfer + let transfer_results = client + .create_transfers(&[tb::Transfer { + id: 1, + debit_account_id: 1, + credit_account_id: 2, + amount: 500, + ledger: 1, + code: 1, + flags: tb::TransferFlags::Pending, + ..Default::default() + }])? + .await?; + + assert!(transfer_results.len() == 1); + assert!(transfer_results[0].status == tb::CreateTransferStatus::Created); + + // Validate accounts pending and posted debits/credits before finishing the two-phase transfer + let accounts = client.lookup_accounts(&[1, 2])?.await?; + assert_eq!(accounts.len(), 2); + + for account in &accounts { + if account.id == 1 { + assert_eq!(account.debits_posted, 0, "account 1 debits, before posted"); + assert_eq!( + account.credits_posted, 0, + "account 1 credits, before posted" + ); + assert_eq!( + account.debits_pending, 500, + "account 1 debits pending, before posted" + ); + assert_eq!( + account.credits_pending, 0, + "account 1 credits pending, before posted" + ); + } else if account.id == 2 { + assert_eq!(account.debits_posted, 0, "account 2 debits, before posted"); + assert_eq!( + account.credits_posted, 0, + "account 2 credits, before posted" + ); + assert_eq!( + account.debits_pending, 0, + "account 2 debits pending, before posted" + ); + assert_eq!( + account.credits_pending, 500, + "account 2 credits pending, before posted" + ); + } else { + panic!("Unexpected account: {}", account.id); + } + } + + // Create a second transfer simply posting the first transfer + let transfer_results = client + .create_transfers(&[tb::Transfer { + id: 2, + debit_account_id: 1, + credit_account_id: 2, + amount: 500, + pending_id: 1, + ledger: 1, + code: 1, + flags: tb::TransferFlags::PostPendingTransfer, + ..Default::default() + }])? + .await?; + + assert!(transfer_results.len() == 1); + assert!(transfer_results[0].status == tb::CreateTransferStatus::Created); + + // Validate the contents of all transfers + let transfers = client.lookup_transfers(&[1, 2])?.await?; + assert_eq!(transfers.len(), 2); + + for transfer in &transfers { + if transfer.id == 1 { + assert!( + transfer.flags.0 & tb::TransferFlags::Pending.0 != 0, + "transfer 1 pending" + ); + assert!( + transfer.flags.0 & tb::TransferFlags::PostPendingTransfer.0 == 0, + "transfer 1 post_pending_transfer" + ); + } else if transfer.id == 2 { + assert!( + transfer.flags.0 & tb::TransferFlags::Pending.0 == 0, + "transfer 2 pending" + ); + assert!( + transfer.flags.0 & tb::TransferFlags::PostPendingTransfer.0 != 0, + "transfer 2 post_pending_transfer" + ); + } else { + panic!("Unknown transfer: {}", transfer.id); + } + } + + // Validate accounts pending and posted debits/credits after finishing the two-phase transfer + let accounts = client.lookup_accounts(&[1, 2])?.await?; + assert_eq!(accounts.len(), 2); + + for account in &accounts { + if account.id == 1 { + assert_eq!(account.debits_posted, 500, "account 1 debits"); + assert_eq!(account.credits_posted, 0, "account 1 credits"); + assert_eq!(account.debits_pending, 0, "account 1 debits pending"); + assert_eq!(account.credits_pending, 0, "account 1 credits pending"); + } else if account.id == 2 { + assert_eq!(account.debits_posted, 0, "account 2 debits"); + assert_eq!(account.credits_posted, 500, "account 2 credits"); + assert_eq!(account.debits_pending, 0, "account 2 debits pending"); + assert_eq!(account.credits_pending, 0, "account 2 credits pending"); + } else { + panic!("Unexpected account: {}", account.id); + } + } + + Ok(()) +} diff --git a/ocam/src/clients/rust/samples/walkthrough/Cargo.toml b/ocam/src/clients/rust/samples/walkthrough/Cargo.toml new file mode 100644 index 00000000..1bbfb83d --- /dev/null +++ b/ocam/src/clients/rust/samples/walkthrough/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "tigerbeetle-sample-walkthrough" +version = "0.1.0" +edition = "2021" + +[dependencies] +futures = { version = "0.3.31", default-features = false, features = ["executor"] } +tigerbeetle.path = "../.." + +[profile.release] +# It is *strongly* recommended that Rust applications using TigerBeetle +# enable overflow checks, because the nature of accounting makes overflow +# errors catastrophic. +# +# Note that the following line enables the checks only for the tests in +# this crate. In other words, overflow checks must be enabled in the +# Cargo.toml of the end application. +overflow-checks = true diff --git a/ocam/src/clients/rust/samples/walkthrough/README.md b/ocam/src/clients/rust/samples/walkthrough/README.md new file mode 100644 index 00000000..e69de29b diff --git a/ocam/src/clients/rust/samples/walkthrough/src/main.rs b/ocam/src/clients/rust/samples/walkthrough/src/main.rs new file mode 100644 index 00000000..6eff4fd4 --- /dev/null +++ b/ocam/src/clients/rust/samples/walkthrough/src/main.rs @@ -0,0 +1,489 @@ +#![allow(unused)] + +// section:imports +use tigerbeetle as tb; + +fn main() -> Result<(), Box> { + futures::executor::block_on(main_async()) +} + +async fn main_async() -> Result<(), Box> { + println!("hello world"); + // endsection:imports + + // section:client + let cluster_id = 0; + let replica_address = std::env::var("TB_ADDRESS") + .ok() + .unwrap_or_else(|| String::from("3000")); + let client = tb::Client::new(cluster_id, &replica_address)?; + // endsection:client + + { + // section:create-accounts + let account_results = client + .create_accounts(&[tb::Account { + id: tb::id(), + ledger: 1, + code: 718, + ..Default::default() + }])? + .await?; + // Result handling omitted. + // endsection:create-accounts + } + + { + // section:account-flags + let account0 = tb::Account { + id: 100, + ledger: 1, + code: 718, + flags: tb::AccountFlags::DebitsMustNotExceedCredits | tb::AccountFlags::Linked, + ..Default::default() + }; + let account1 = tb::Account { + id: 101, + ledger: 1, + code: 718, + flags: tb::AccountFlags::History, + ..Default::default() + }; + + let account_results = client.create_accounts(&[account0, account1])?.await?; + // Result handling omitted. + // endsection:account-flags + } + + { + // section:create-accounts-errors + let account0 = tb::Account { + id: 102, + ledger: 1, + code: 718, + ..Default::default() + }; + let account1 = tb::Account { + id: 103, + ledger: 1, + code: 718, + ..Default::default() + }; + let account2 = tb::Account { + id: 104, + ledger: 1, + code: 718, + ..Default::default() + }; + + let account_results = client + .create_accounts(&[account0, account1, account2])? + .await?; + + assert!(account_results.len() == 3); + for (index, result) in account_results.into_iter().enumerate() { + match result.status { + tb::CreateAccountStatus::Created => { + println!( + "Batch account at {} successfully created with timestamp {}", + index, result.timestamp + ); + } + tb::CreateAccountStatus::Exists => { + println!( + "Batch account at {} already exists with timestamp {}.", + index, result.timestamp + ); + } + _ => { + eprintln!( + "Batch account at {} failed to create: {:?}", + index, result.status + ); + } + } + } + // endsection:create-accounts-errors + } + + { + // section:lookup-accounts + let accounts = client.lookup_accounts(&[100, 101])?.await?; + // endsection:lookup-accounts + } + + { + // section:create-transfers + let transfers = vec![tb::Transfer { + id: tb::id(), + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() + }]; + + let transfer_results = client.create_transfers(&transfers)?.await?; + // Result handling omitted. + // endsection:create-transfers + } + + { + // section:create-transfers-errors + let transfers = vec![ + tb::Transfer { + id: 1, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() + }, + tb::Transfer { + id: 2, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() + }, + tb::Transfer { + id: 3, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() + }, + ]; + + let transfer_results = client.create_transfers(&transfers)?.await?; + assert!(transfer_results.len() == transfers.len()); + for (index, result) in transfer_results.into_iter().enumerate() { + match result.status { + tb::CreateTransferStatus::Created => { + println!( + "Batch transfer at {} successfully created with timestamp {}", + index, result.timestamp + ); + } + tb::CreateTransferStatus::Exists => { + println!( + "Batch transfer at {} already exists with timestamp {}.", + index, result.timestamp + ); + } + _ => { + eprintln!( + "Batch transfer at {} failed to create: {:?}", + index, result.status + ); + } + } + } + // endsection:create-transfers-errors + } + + { + // section:batch + let transfers: Vec = vec![]; + const BATCH_SIZE: usize = 8189; + for batch in transfers.chunks(BATCH_SIZE) { + let transfer_results = client.create_transfers(batch)?.await?; + // Result handling omitted. + } + // endsection:batch + } + + { + // section:transfer-flags-link + let transfer0 = tb::Transfer { + id: 4, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + flags: tb::TransferFlags::Linked, + ..Default::default() + }; + let transfer1 = tb::Transfer { + id: 5, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() + }; + + let transfer_results = client.create_transfers(&[transfer0, transfer1])?.await?; + // Result handling omitted. + // endsection:transfer-flags-link + } + + { + // section:transfer-flags-post + let transfer0 = tb::Transfer { + id: 6, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() + }; + + let transfer_results = client.create_transfers(&[transfer0])?.await?; + // Result handling omitted. + + let transfer1 = tb::Transfer { + id: 7, + amount: u128::MAX, + pending_id: 6, + flags: tb::TransferFlags::PostPendingTransfer, + ..Default::default() + }; + + let transfer_results = client.create_transfers(&[transfer1])?.await?; + // Result handling omitted. + // endsection:transfer-flags-post + } + + { + // section:transfer-flags-void + let transfer0 = tb::Transfer { + id: 8, + debit_account_id: 101, + credit_account_id: 102, + amount: 10, + ledger: 1, + code: 1, + ..Default::default() + }; + + let transfer_results = client.create_transfers(&[transfer0])?.await?; + // Result handling omitted. + + let transfer1 = tb::Transfer { + id: 9, + amount: 0, + pending_id: 8, + flags: tb::TransferFlags::VoidPendingTransfer, + ..Default::default() + }; + + let transfer_results = client.create_transfers(&[transfer1])?.await?; + // Result handling omitted. + // endsection:transfer-flags-void + } + + { + // section:lookup-transfers + let transfers = client.lookup_transfers(&[1, 2])?.await?; + // endsection:lookup-transfers + } + + { + // section:get-account-transfers + let filter = tb::AccountFilter { + account_id: 2, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + code: 0, + reserved: Default::default(), + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: tb::AccountFilterFlags::Debits + | tb::AccountFilterFlags::Credits + | tb::AccountFilterFlags::Reversed, + }; + + let transfers = client.get_account_transfers(filter)?.await?; + // endsection:get-account-transfers + } + + { + // section:get-account-balances + let filter = tb::AccountFilter { + account_id: 2, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + code: 0, + reserved: Default::default(), + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: tb::AccountFilterFlags::Debits + | tb::AccountFilterFlags::Credits + | tb::AccountFilterFlags::Reversed, + }; + + let account_balances = client.get_account_balances(filter)?.await?; + // endsection:get-account-balances + } + + { + // section:query-accounts + let filter = tb::QueryFilter { + user_data_128: 1000, + user_data_64: 100, + user_data_32: 10, + code: 1, + ledger: 0, + reserved: Default::default(), + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: tb::QueryFilterFlags::Reversed, + }; + + let accounts = client.query_accounts(filter)?.await?; + // endsection:query-accounts + } + + { + // section:query-transfers + let filter = tb::QueryFilter { + user_data_128: 1000, + user_data_64: 100, + user_data_32: 10, + code: 1, + ledger: 0, + reserved: Default::default(), + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: tb::QueryFilterFlags::Reversed, + }; + + let transfers = client.query_transfers(filter)?.await?; + // endsection:query-transfers + } + + { + // section:linked-events + let mut batch = vec![]; + let linked_flag = tb::TransferFlags::Linked; + + // An individual transfer (successful): + batch.push(tb::Transfer { + id: 1, + ..Default::default() + }); + + // A chain of 4 transfers (the last transfer in the chain closes the chain with linked=false): + batch.push(tb::Transfer { + id: 2, + flags: linked_flag, + ..Default::default() + }); + batch.push(tb::Transfer { + id: 3, + flags: linked_flag, + ..Default::default() + }); + batch.push(tb::Transfer { + id: 2, + flags: linked_flag, + ..Default::default() + }); + batch.push(tb::Transfer { + id: 4, + ..Default::default() + }); + + // An individual transfer (successful): + // This should not see any effect from the failed chain above. + batch.push(tb::Transfer { + id: 2, + ..Default::default() + }); + + // A chain of 2 transfers (the first transfer fails the chain): + batch.push(tb::Transfer { + id: 2, + flags: linked_flag, + ..Default::default() + }); + batch.push(tb::Transfer { + id: 3, + ..Default::default() + }); + + // A chain of 2 transfers (successful): + batch.push(tb::Transfer { + id: 3, + flags: linked_flag, + ..Default::default() + }); + batch.push(tb::Transfer { + id: 4, + ..Default::default() + }); + + let transfer_results = client.create_transfers(&batch)?.await?; + // Result handling omitted. + // endsection:linked-events + } + + { + // section:imported-events + // External source of time. + let mut historical_timestamp: u64 = 0; + let historical_accounts: Vec = vec![]; // Loaded from an external source. + let historical_transfers: Vec = vec![]; // Loaded from an external source. + + // First, load and import all accounts with their timestamps from the historical source. + let mut accounts_batch = vec![]; + for (index, mut account) in historical_accounts.into_iter().enumerate() { + // Set a unique and strictly increasing timestamp. + historical_timestamp += 1; + account.timestamp = historical_timestamp; + + account.flags = if index < accounts_batch.len() - 1 { + tb::AccountFlags::Imported | tb::AccountFlags::Linked + } else { + tb::AccountFlags::Imported + }; + + accounts_batch.push(account); + } + + let account_results = client.create_accounts(&accounts_batch)?.await?; + // Result handling omitted. + + // Then, load and import all transfers with their timestamps from the historical source. + let mut transfers_batch = vec![]; + for (index, mut transfer) in historical_transfers.into_iter().enumerate() { + // Set a unique and strictly increasing timestamp. + historical_timestamp += 1; + transfer.timestamp = historical_timestamp; + + transfer.flags = if index < transfers_batch.len() - 1 { + tb::TransferFlags::Imported | tb::TransferFlags::Linked + } else { + tb::TransferFlags::Imported + }; + + transfers_batch.push(transfer); + } + + let transfer_results = client.create_transfers(&transfers_batch)?.await?; + // Result handling omitted. + // Since it is a linked chain, in case of any error the entire batch is rolled back and can be retried + // with the same historical timestamps without regressing the cluster timestamp. + // endsection:imported-events + } + + // section:imports + Ok(()) +} +// endsection:imports diff --git a/ocam/src/clients/rust/src/conversions.rs b/ocam/src/clients/rust/src/conversions.rs new file mode 100644 index 00000000..3916dc6a --- /dev/null +++ b/ocam/src/clients/rust/src/conversions.rs @@ -0,0 +1,307 @@ +pub use super::*; + +#[rustfmt::skip] +impl From for CreateAccountStatus { + fn from(other: u32) -> CreateAccountStatus { + use tbc::*; + use CreateAccountStatus::*; + + match other { + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_CREATED => Created, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_LINKED_EVENT_FAILED => LinkedEventFailed, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_LINKED_EVENT_CHAIN_OPEN => LinkedEventChainOpen, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_EXPECTED => ImportedEventExpected, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_NOT_EXPECTED => ImportedEventNotExpected, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_TIMESTAMP_MUST_BE_ZERO => TimestampMustBeZero, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE => ImportedEventTimestampOutOfRange, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE => ImportedEventTimestampMustNotAdvance, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_RESERVED_FIELD => ReservedField, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_RESERVED_FLAG => ReservedFlag, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_ZERO => IdMustNotBeZero, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_INT_MAX => IdMustNotBeIntMax, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_FLAGS => ExistsWithDifferentFlags, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_128 => ExistsWithDifferentUserData128, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_64 => ExistsWithDifferentUserData64, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_32 => ExistsWithDifferentUserData32, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_LEDGER => ExistsWithDifferentLedger, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_CODE => ExistsWithDifferentCode, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS => Exists, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_FLAGS_ARE_MUTUALLY_EXCLUSIVE => FlagsAreMutuallyExclusive, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_DEBITS_PENDING_MUST_BE_ZERO => DebitsPendingMustBeZero, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_DEBITS_POSTED_MUST_BE_ZERO => DebitsPostedMustBeZero, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_CREDITS_PENDING_MUST_BE_ZERO => CreditsPendingMustBeZero, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_CREDITS_POSTED_MUST_BE_ZERO => CreditsPostedMustBeZero, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_LEDGER_MUST_NOT_BE_ZERO => LedgerMustNotBeZero, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_CODE_MUST_NOT_BE_ZERO => CodeMustNotBeZero, + TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS => ImportedEventTimestampMustNotRegress, + v => panic!("Unknown CreateAccountStatus: {v}"), + } + } +} + +#[rustfmt::skip] +impl From for u32 { + fn from(other: CreateAccountStatus) -> u32 { + use tbc::*; + use CreateAccountStatus::*; + + match other { + Created => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_CREATED, + LinkedEventFailed => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_LINKED_EVENT_FAILED, + LinkedEventChainOpen => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_LINKED_EVENT_CHAIN_OPEN, + ImportedEventExpected => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_EXPECTED, + ImportedEventNotExpected => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_NOT_EXPECTED, + TimestampMustBeZero => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_TIMESTAMP_MUST_BE_ZERO, + ImportedEventTimestampOutOfRange => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE, + ImportedEventTimestampMustNotAdvance => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE, + ReservedField => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_RESERVED_FIELD, + ReservedFlag => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_RESERVED_FLAG, + IdMustNotBeZero => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_ZERO, + IdMustNotBeIntMax => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_INT_MAX, + ExistsWithDifferentFlags => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_FLAGS, + ExistsWithDifferentUserData128 => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_128, + ExistsWithDifferentUserData64 => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_64, + ExistsWithDifferentUserData32 => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_32, + ExistsWithDifferentLedger => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_LEDGER, + ExistsWithDifferentCode => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_CODE, + Exists => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS, + FlagsAreMutuallyExclusive => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_FLAGS_ARE_MUTUALLY_EXCLUSIVE, + DebitsPendingMustBeZero => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_DEBITS_PENDING_MUST_BE_ZERO, + DebitsPostedMustBeZero => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_DEBITS_POSTED_MUST_BE_ZERO, + CreditsPendingMustBeZero => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_CREDITS_PENDING_MUST_BE_ZERO, + CreditsPostedMustBeZero => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_CREDITS_POSTED_MUST_BE_ZERO, + LedgerMustNotBeZero => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_LEDGER_MUST_NOT_BE_ZERO, + CodeMustNotBeZero => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_CODE_MUST_NOT_BE_ZERO, + ImportedEventTimestampMustNotRegress => TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS, + } + } +} + +#[rustfmt::skip] +impl From for CreateTransferStatus { + fn from(other: u32) -> CreateTransferStatus { + use tbc::*; + use CreateTransferStatus::*; + + match other { + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREATED => Created, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_LINKED_EVENT_FAILED => LinkedEventFailed, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_LINKED_EVENT_CHAIN_OPEN => LinkedEventChainOpen, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_EXPECTED => ImportedEventExpected, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_NOT_EXPECTED => ImportedEventNotExpected, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_TIMESTAMP_MUST_BE_ZERO => TimestampMustBeZero, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE => ImportedEventTimestampOutOfRange, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE => ImportedEventTimestampMustNotAdvance, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_RESERVED_FLAG => ReservedFlag, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ID_MUST_NOT_BE_ZERO => IdMustNotBeZero, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ID_MUST_NOT_BE_INT_MAX => IdMustNotBeIntMax, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_FLAGS => ExistsWithDifferentFlags, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_PENDING_ID => ExistsWithDifferentPendingId, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_TIMEOUT => ExistsWithDifferentTimeout, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_DEBIT_ACCOUNT_ID => ExistsWithDifferentDebitAccountId, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CREDIT_ACCOUNT_ID => ExistsWithDifferentCreditAccountId, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_AMOUNT => ExistsWithDifferentAmount, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_128 => ExistsWithDifferentUserData128, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_64 => ExistsWithDifferentUserData64, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_32 => ExistsWithDifferentUserData32, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_LEDGER => ExistsWithDifferentLedger, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CODE => ExistsWithDifferentCode, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS => Exists, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ID_ALREADY_FAILED => IdAlreadyFailed, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_FLAGS_ARE_MUTUALLY_EXCLUSIVE => FlagsAreMutuallyExclusive, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_ZERO => DebitAccountIdMustNotBeZero, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX => DebitAccountIdMustNotBeIntMax, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_ZERO => CreditAccountIdMustNotBeZero, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX => CreditAccountIdMustNotBeIntMax, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ACCOUNTS_MUST_BE_DIFFERENT => AccountsMustBeDifferent, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_ZERO => PendingIdMustBeZero, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_ZERO => PendingIdMustNotBeZero, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_INT_MAX => PendingIdMustNotBeIntMax, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_DIFFERENT => PendingIdMustBeDifferent, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_TIMEOUT_RESERVED_FOR_PENDING_TRANSFER => TimeoutReservedForPendingTransfer, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CLOSING_TRANSFER_MUST_BE_PENDING => ClosingTransferMustBePending, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_LEDGER_MUST_NOT_BE_ZERO => LedgerMustNotBeZero, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CODE_MUST_NOT_BE_ZERO => CodeMustNotBeZero, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_DEBIT_ACCOUNT_NOT_FOUND => DebitAccountNotFound, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREDIT_ACCOUNT_NOT_FOUND => CreditAccountNotFound, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ACCOUNTS_MUST_HAVE_THE_SAME_LEDGER => AccountsMustHaveTheSameLedger, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_TRANSFER_MUST_HAVE_THE_SAME_LEDGER_AS_ACCOUNTS => TransferMustHaveTheSameLedgerAsAccounts, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_FOUND => PendingTransferNotFound, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_PENDING => PendingTransferNotPending, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_DEBIT_ACCOUNT_ID => PendingTransferHasDifferentDebitAccountId, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CREDIT_ACCOUNT_ID => PendingTransferHasDifferentCreditAccountId, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_LEDGER => PendingTransferHasDifferentLedger, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CODE => PendingTransferHasDifferentCode, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXCEEDS_PENDING_TRANSFER_AMOUNT => ExceedsPendingTransferAmount, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_AMOUNT => PendingTransferHasDifferentAmount, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_POSTED => PendingTransferAlreadyPosted, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_VOIDED => PendingTransferAlreadyVoided, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_EXPIRED => PendingTransferExpired, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS => ImportedEventTimestampMustNotRegress, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_DEBIT_ACCOUNT => ImportedEventTimestampMustPostdateDebitAccount, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_CREDIT_ACCOUNT => ImportedEventTimestampMustPostdateCreditAccount, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMEOUT_MUST_BE_ZERO => ImportedEventTimeoutMustBeZero, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ALREADY_CLOSED => DebitAccountAlreadyClosed, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ALREADY_CLOSED => CreditAccountAlreadyClosed, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_PENDING => OverflowsDebitsPending, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_PENDING => OverflowsCreditsPending, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_POSTED => OverflowsDebitsPosted, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_POSTED => OverflowsCreditsPosted, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_DEBITS => OverflowsDebits, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_CREDITS => OverflowsCredits, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_TIMEOUT => OverflowsTimeout, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXCEEDS_CREDITS => ExceedsCredits, + TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXCEEDS_DEBITS => ExceedsDebits, + v => panic!("Unknown CreateTransferStatus: {v}"), + } + } +} + +#[rustfmt::skip] +impl From for u32 { + fn from(other: CreateTransferStatus) -> u32 { + use tbc::*; + use CreateTransferStatus::*; + + match other { + Created => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREATED, + LinkedEventFailed => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_LINKED_EVENT_FAILED, + LinkedEventChainOpen => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_LINKED_EVENT_CHAIN_OPEN, + ImportedEventExpected => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_EXPECTED, + ImportedEventNotExpected => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_NOT_EXPECTED, + TimestampMustBeZero => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_TIMESTAMP_MUST_BE_ZERO, + ImportedEventTimestampOutOfRange => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE, + ImportedEventTimestampMustNotAdvance => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE, + ReservedFlag => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_RESERVED_FLAG, + IdMustNotBeZero => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ID_MUST_NOT_BE_ZERO, + IdMustNotBeIntMax => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ID_MUST_NOT_BE_INT_MAX, + ExistsWithDifferentFlags => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_FLAGS, + ExistsWithDifferentPendingId => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_PENDING_ID, + ExistsWithDifferentTimeout => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_TIMEOUT, + ExistsWithDifferentDebitAccountId => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_DEBIT_ACCOUNT_ID, + ExistsWithDifferentCreditAccountId => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CREDIT_ACCOUNT_ID, + ExistsWithDifferentAmount => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_AMOUNT, + ExistsWithDifferentUserData128 => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_128, + ExistsWithDifferentUserData64 => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_64, + ExistsWithDifferentUserData32 => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_32, + ExistsWithDifferentLedger => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_LEDGER, + ExistsWithDifferentCode => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CODE, + Exists => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS, + IdAlreadyFailed => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ID_ALREADY_FAILED, + FlagsAreMutuallyExclusive => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_FLAGS_ARE_MUTUALLY_EXCLUSIVE, + DebitAccountIdMustNotBeZero => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_ZERO, + DebitAccountIdMustNotBeIntMax => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX, + CreditAccountIdMustNotBeZero => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_ZERO, + CreditAccountIdMustNotBeIntMax => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX, + AccountsMustBeDifferent => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ACCOUNTS_MUST_BE_DIFFERENT, + PendingIdMustBeZero => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_ZERO, + PendingIdMustNotBeZero => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_ZERO, + PendingIdMustNotBeIntMax => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_INT_MAX, + PendingIdMustBeDifferent => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_DIFFERENT, + TimeoutReservedForPendingTransfer => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_TIMEOUT_RESERVED_FOR_PENDING_TRANSFER, + ClosingTransferMustBePending => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CLOSING_TRANSFER_MUST_BE_PENDING, + LedgerMustNotBeZero => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_LEDGER_MUST_NOT_BE_ZERO, + CodeMustNotBeZero => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CODE_MUST_NOT_BE_ZERO, + DebitAccountNotFound => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_DEBIT_ACCOUNT_NOT_FOUND, + CreditAccountNotFound => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREDIT_ACCOUNT_NOT_FOUND, + AccountsMustHaveTheSameLedger => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ACCOUNTS_MUST_HAVE_THE_SAME_LEDGER, + TransferMustHaveTheSameLedgerAsAccounts => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_TRANSFER_MUST_HAVE_THE_SAME_LEDGER_AS_ACCOUNTS, + PendingTransferNotFound => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_FOUND, + PendingTransferNotPending => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_PENDING, + PendingTransferHasDifferentDebitAccountId => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_DEBIT_ACCOUNT_ID, + PendingTransferHasDifferentCreditAccountId => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CREDIT_ACCOUNT_ID, + PendingTransferHasDifferentLedger => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_LEDGER, + PendingTransferHasDifferentCode => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CODE, + ExceedsPendingTransferAmount => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXCEEDS_PENDING_TRANSFER_AMOUNT, + PendingTransferHasDifferentAmount => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_AMOUNT, + PendingTransferAlreadyPosted => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_POSTED, + PendingTransferAlreadyVoided => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_VOIDED, + PendingTransferExpired => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_EXPIRED, + ImportedEventTimestampMustNotRegress => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS, + ImportedEventTimestampMustPostdateDebitAccount => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_DEBIT_ACCOUNT, + ImportedEventTimestampMustPostdateCreditAccount => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_CREDIT_ACCOUNT, + ImportedEventTimeoutMustBeZero => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMEOUT_MUST_BE_ZERO, + DebitAccountAlreadyClosed => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ALREADY_CLOSED, + CreditAccountAlreadyClosed => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ALREADY_CLOSED, + OverflowsDebitsPending => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_PENDING, + OverflowsCreditsPending => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_PENDING, + OverflowsDebitsPosted => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_POSTED, + OverflowsCreditsPosted => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_POSTED, + OverflowsDebits => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_DEBITS, + OverflowsCredits => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_CREDITS, + OverflowsTimeout => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_TIMEOUT, + ExceedsCredits => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXCEEDS_CREDITS, + ExceedsDebits => TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXCEEDS_DEBITS, + } + } +} + +impl From for InitStatus { + fn from(other: i32) -> InitStatus { + use tbc::*; + use InitStatus::*; + + match other { + TB_INIT_STATUS_TB_INIT_SUCCESS => panic!(), + TB_INIT_STATUS_TB_INIT_UNEXPECTED => Unexpected, + TB_INIT_STATUS_TB_INIT_OUT_OF_MEMORY => OutOfMemory, + TB_INIT_STATUS_TB_INIT_ADDRESS_INVALID => AddressInvalid, + TB_INIT_STATUS_TB_INIT_ADDRESS_LIMIT_EXCEEDED => AddressLimitExceeded, + TB_INIT_STATUS_TB_INIT_SYSTEM_RESOURCES => SystemResources, + TB_INIT_STATUS_TB_INIT_NETWORK_SUBSYSTEM => NetworkSubsystem, + v => panic!("Unknown InitStatus: {v}"), + } + } +} + +impl From for i32 { + fn from(other: InitStatus) -> i32 { + use tbc::*; + use InitStatus::*; + + match other { + Unexpected => TB_INIT_STATUS_TB_INIT_UNEXPECTED, + OutOfMemory => TB_INIT_STATUS_TB_INIT_OUT_OF_MEMORY, + AddressInvalid => TB_INIT_STATUS_TB_INIT_ADDRESS_INVALID, + AddressLimitExceeded => TB_INIT_STATUS_TB_INIT_ADDRESS_LIMIT_EXCEEDED, + SystemResources => TB_INIT_STATUS_TB_INIT_SYSTEM_RESOURCES, + NetworkSubsystem => TB_INIT_STATUS_TB_INIT_NETWORK_SUBSYSTEM, + } + } +} + +impl From for PacketStatus { + fn from(other: u8) -> PacketStatus { + use tbc::*; + use PacketStatus::*; + + match other { + TB_PACKET_STATUS_TB_PACKET_OK => panic!(), + TB_PACKET_STATUS_TB_PACKET_TOO_MUCH_DATA => TooMuchData, + TB_PACKET_STATUS_TB_PACKET_CLIENT_EVICTED => ClientEvicted, + TB_PACKET_STATUS_TB_PACKET_CLIENT_RELEASE_TOO_LOW => ClientReleaseTooLow, + TB_PACKET_STATUS_TB_PACKET_CLIENT_RELEASE_TOO_HIGH => ClientReleaseTooHigh, + TB_PACKET_STATUS_TB_PACKET_CLIENT_SHUTDOWN => ClientShutdown, + TB_PACKET_STATUS_TB_PACKET_INVALID_OPERATION => InvalidOperation, + TB_PACKET_STATUS_TB_PACKET_INVALID_DATA_SIZE => InvalidDataSize, + v => panic!("Unknown PacketStatus: {v}"), + } + } +} + +impl From for u8 { + fn from(other: PacketStatus) -> u8 { + use tbc::*; + use PacketStatus::*; + + match other { + TooMuchData => TB_PACKET_STATUS_TB_PACKET_TOO_MUCH_DATA, + ClientEvicted => TB_PACKET_STATUS_TB_PACKET_CLIENT_EVICTED, + ClientReleaseTooLow => TB_PACKET_STATUS_TB_PACKET_CLIENT_RELEASE_TOO_LOW, + ClientReleaseTooHigh => TB_PACKET_STATUS_TB_PACKET_CLIENT_RELEASE_TOO_HIGH, + ClientShutdown => TB_PACKET_STATUS_TB_PACKET_CLIENT_SHUTDOWN, + InvalidOperation => TB_PACKET_STATUS_TB_PACKET_INVALID_OPERATION, + InvalidDataSize => TB_PACKET_STATUS_TB_PACKET_INVALID_DATA_SIZE, + } + } +} diff --git a/ocam/src/clients/rust/src/lib.rs b/ocam/src/clients/rust/src/lib.rs new file mode 100644 index 00000000..5d821c0f --- /dev/null +++ b/ocam/src/clients/rust/src/lib.rs @@ -0,0 +1,1808 @@ +//! The official TigerBeetle client for Rust. +//! +//! This is a client library for the [TigerBeetle] financial database. +//! To use, create a [`Client`] and call its methods to make requests. +//! +//! The client presents an async interface, but does not depend on a specific +//! Rust async runtime. Instead it contains its own off-thread event loop, +//! shared by all official TigerBeetle clients. Thus it should integrate +//! seamlessly into any Rust codebase. +//! +//! The cost of this though is that it does link to a non-Rust static library +//! (called `tb_client`), and it does need to context switch between threads for +//! every request. The native linking should be handled seamlessly on all +//! supported platforms, and the context switching overhead is expected to be +//! low compared to the cost of networking and disk I/O. +//! +//! [TigerBeetle]: https://tigerbeetle.com +//! +//! +//! # Example +//! +//! ```no_run +//! use tigerbeetle as tb; +//! +//! # async fn example() -> Result<(), Box> { +//! // Connect to TigerBeetle +//! let client = tb::Client::new(0, "127.0.0.1:3000")?; +//! +//! // Create accounts. Using TigerBeetle IDs is recommended. +//! let account_id1 = tb::id(); +//! let account_id2 = tb::id(); +//! +//! let accounts = [ +//! tb::Account { +//! id: account_id1, +//! ledger: 1, +//! code: 1, +//! flags: tb::AccountFlags::History, +//! ..Default::default() +//! }, +//! tb::Account { +//! id: account_id2, +//! ledger: 1, +//! code: 1, +//! flags: tb::AccountFlags::History, +//! ..Default::default() +//! }, +//! ]; +//! +//! let account_results = client.create_accounts(&accounts)?.await?; +//! +//! // A successful reply contains one result code for each account. +//! assert_eq!(account_results.len(), 2); +//! +//! // Create a transfer between accounts +//! let transfer_id = tb::id(); +//! let transfers = [tb::Transfer { +//! id: transfer_id, +//! debit_account_id: account_id1, +//! credit_account_id: account_id2, +//! amount: 100, +//! ledger: 1, +//! code: 1, +//! ..Default::default() +//! }]; +//! +//! let transfer_results = client.create_transfers(&transfers)?.await?; +//! assert_eq!(transfer_results.len(), 1); +//! +//! // Look up the accounts to see the transfer result +//! let accounts = client.lookup_accounts(&[account_id1, account_id2])?.await?; +//! let account1 = accounts[0]; +//! let account2 = accounts[1]; +//! +//! assert_eq!(account1.id, account_id1); +//! assert_eq!(account2.id, account_id2); +//! assert_eq!(account1.debits_posted, 100); +//! assert_eq!(account2.credits_posted, 100); +//! # Ok(()) +//! # } +//! ``` +//! +//! +//! # Request batching +//! +//! Most transaction and query operations support multiple events of the same +//! type at once (this can be seen in the request method signatures accepting +//! slices of their input types) and it is strongly recommended to submit many +//! events in a single request at once as TigerBeetle will only reach its +//! performance limits when events are received in large batches. The client +//! _does_ implement its own internal batching and will attempt to create them +//! efficiently, but it is more efficient for applications to create their own +//! batches based on understanding of their own architectural needs and +//! limitations. +//! +//! In TigerBeetle's standard build-time configuration **the maximum number of +//! events per batch is 8189**. If the events in a request exceed this number +//! its future will return [`PacketStatus::TooMuchData`]. +//! +//! +//! # Range query limits +//! +//! TigerBeetle's range queries, [`get_account_transfers`], +//! [`get_account_balances`], [`query_accounts`] and [`query_transfers`], also +//! have a limit to how many results they return. +//! +//! In TigerBeetle's standard build-time configuration **the maximum number of +//! results returned is 8189**. +//! +//! If the server returns a full batch for a range query, then further results +//! can be paged by incrementing `timeout_max` to one greater than the highest +//! timeout returned in the previous batch, and issuing a new query with +//! otherwise the same filter. This process can be repeated until the server +//! returns a partial batch. +//! +//! [`get_account_transfers`]: `Client::get_account_transfers` +//! [`get_account_balances`]: `Client::get_account_balances` +//! [`query_accounts`]: `Client::query_accounts` +//! [`query_transfers`]: `Client::query_transfers` +//! +//! Here is an example of paging to get started with: +//! +//! ```no_run +//! use tigerbeetle as tb; +//! use futures::{stream, Stream}; +//! +//! fn get_account_transfers_paged( +//! client: &tb::Client, +//! event: tb::AccountFilter, +//! ) -> impl Stream, tb::PacketStatus>> + '_ { +//! assert!( +//! event.limit > 1, +//! "paged queries should use an explicit limit" +//! ); +//! +//! enum State { +//! Start, +//! Continue(u64), +//! End, +//! } +//! +//! let is_reverse = (event.flags.0 & tb::AccountFilterFlags::Reversed.0) != 0; +//! +//! futures::stream::unfold(State::Start, move |state| async move { +//! let event = match state { +//! State::Start => event, +//! State::Continue(timestamp_begin) => { +//! if !is_reverse { +//! tb::AccountFilter { +//! timestamp_min: timestamp_begin, +//! ..event +//! } +//! } else { +//! tb::AccountFilter { +//! timestamp_max: timestamp_begin, +//! ..event +//! } +//! } +//! } +//! State::End => return None, +//! }; +//! let result_next = client.get_account_transfers(event).expect("client closed").await; +//! match result_next { +//! Ok(result_next) => { +//! let result_len = u32::try_from(result_next.len()).expect("u32"); +//! let must_page = result_len == event.limit; +//! if must_page { +//! let timestamp_first = result_next.first().expect("item").timestamp; +//! let timestamp_last = result_next.last().expect("item").timestamp; +//! let (timestamp_begin_next, should_continue) = if !is_reverse { +//! assert!(timestamp_first < timestamp_last); +//! let timestamp_begin_next = timestamp_last.checked_add(1).expect("overflow"); +//! assert_ne!(timestamp_begin_next, u64::MAX); +//! let should_continue = +//! timestamp_begin_next <= event.timestamp_max || event.timestamp_max == 0; +//! (timestamp_begin_next, should_continue) +//! } else { +//! assert!(timestamp_first > timestamp_last); +//! let timestamp_begin_next = timestamp_last.checked_sub(1).expect("overflow"); +//! assert_ne!(timestamp_begin_next, 0); +//! let should_continue = +//! timestamp_begin_next >= event.timestamp_min || event.timestamp_min == 0; +//! (timestamp_begin_next, should_continue) +//! }; +//! if should_continue { +//! Some((Ok(result_next), State::Continue(timestamp_begin_next))) +//! } else { +//! Some((Ok(result_next), State::End)) +//! } +//! } else { +//! Some((Ok(result_next), State::End)) +//! } +//! } +//! Err(result_next) => Some((Err(result_next), State::End)), +//! } +//! }) +//! } +//! ``` +//! +//! +//! # Response futures and client lifetime considerations +//! +//! Responses to requests are returned as [`Future`]s. It is not strictly +//! necessary for applications to `await` these futures — requests are +//! enqueued as soon as the request method is called and will be executed even +//! if the future is dropped. +//! +//! It is possible to drop a `Client` while request futures are still +//! outstanding. In this case any pending requests will be completed with +//! [`PacketStatus::ClientShutdown`]. Request futures may resolve to successful +//! results even after the client is closed. +//! +//! When `Client` is dropped without calling [`close`], +//! it will shutdown correctly, but some of that work happens +//! off-thread after the drop completes. +//! +//! For orderly shutdown, it is recommended to await all +//! request futures prior to destroying the client, +//! and to destroy the client by calling `close` and awaiting +//! its return value. +//! +//! [`close`]: Client::close +//! +//! +//! # Concurrency and multithreading +//! +//! Multiple requests may be submitted concurrently from a single client; the +//! results of which are returned as futures whose Rust lifetimes are tied to +//! the `Client`. The server only supports one in-flight request per client +//! though, so the client will internally buffer concurrent requests. To truly +//! have multiple requests in flight concurrently, multiple clients can be +//! created, though note that there is a hard-coded limit on how many clients +//! can be connected to the server simultaneously. +//! +//! The `Client` type implements `Send` and `Sync` and may be used in parallel +//! across multiple threads or async tasks, e.g. by placing it into an [`Arc`]. +//! In some cases this may be useful because it allows the client to leverage +//! its internal request batching to batch events from multiple threads (or +//! tasks), but otherwise it provides no performance advantage. +//! +//! [`Arc`]: `std::sync::Arc` +//! +//! +//! # TigerBeetle time-based identifiers +//! +//! Accounts and transfers must have globally unique identifiers. The generation +//! of these is application-specific, and any scheme that guarantees unique IDs +//! will work. Barring other constraints, TigerBeetle recommends using +//! [TigerBeetle time-based identifiers][tbid]. This crate provides an +//! implementation in the [`id`] function. +//! +//! For additional considerations when choosing an ID scheme +//! see [the TigerBeetle documentation on data modeling][tbdataid]. +//! +//! [tbid]: https://docs.tigerbeetle.com/coding/data-modeling/#tigerbeetle-time-based-identifiers-recommended +//! [tbdataid]: https://docs.tigerbeetle.com/coding/data-modeling/#id +//! +//! +//! # Use in non-async codebases +//! +//! The TigerBeetle client is async-only, but if you're working in a synchronous +//! codebase, you can use [`futures::executor::block_on`] to run async operations +//! to completion. +//! +//! [`futures::executor::block_on`]: https://docs.rs/futures/latest/futures/executor/fn.block_on.html +//! +//! ```no_run +//! use futures::executor::block_on; +//! use tigerbeetle as tb; +//! +//! fn synchronous_function() -> Result<(), Box> { +//! block_on(async { +//! let client = tb::Client::new(0, "127.0.0.1:3000")?; +//! +//! let accounts = [tb::Account { +//! id: tb::id(), +//! ledger: 1, +//! code: 1, +//! ..Default::default() +//! }]; +//! +//! let results = client.create_accounts(&accounts)?.await?; +//! +//! Ok(()) +//! }) +//! } +//! ``` +//! +//! Note that `block_on` will block the current thread until the async operation +//! completes, so this approach works best for simple use cases or when you need +//! to integrate TigerBeetle into an existing synchronous application. +//! +//! +//! # Rust structure binary representation and the TigerBeetle protocol +//! +//! Many types in this library are ABI-compatible with the underlying protocol +//! definition and can be cast (unsafely) directly to and from byte buffers +//! on all supported platforms, though this should not be required for typical +//! application purposes. +//! +//! The protocol-compatible types are: +//! +//! - [`Account`] and [`AccountFlags`] +//! - [`Transfer`] and [`TransferFlags`] +//! - [`AccountBalance`] +//! - [`AccountFilter`] and [`AccountFilterFlags`] +//! - [`QueryFilter`] and [`QueryFilterFlags`] +//! +//! Note that status enums are not ABI-compatible with the protocol's status codes +//! and must be converted with [`TryFrom`]. +//! +//! +//! # References +//! +//! [The TigerBeetle Reference](https://docs.tigerbeetle.com/reference/). + +use std::future::Future; +use std::os::raw::{c_char, c_void}; +use std::{fmt, mem, ptr}; + +mod oneshot; + +// The generated bindings. +// These are not part of the public API but are re-exported hidden +// so that the vortex driver can parse the TB protocol directly. +#[allow(unused)] +#[allow(non_upper_case_globals)] +#[allow(non_camel_case_types)] +#[allow(non_snake_case)] +#[rustfmt::skip] +#[doc(hidden)] +pub mod tb_client; + +use tb_client as tbc; + +mod conversions; +mod time_based_id; + +pub use time_based_id::id; + +/// The tb_client completion context is unused by the Rust bindings. +/// This is just a magic number to jump out of logs. +const COMPLETION_CONTEXT: usize = 0xAB; + +/// The TigerBeetle client. +pub struct Client { + client: *mut tbc::tb_client_t, +} + +unsafe impl Send for Client {} +unsafe impl Sync for Client {} + +impl Client { + /// Create a new TigerBeetle client. + /// + /// # Addresses + /// + /// The `addresses` argument is a comma-separated string of addresses, where + /// each may be either an IP4 address, a port number, or the pair of IP4 + /// address and port number separated by a colon. Examples include + /// `127.0.0.1`, `3001`, `127.0.0.1:3001` and + /// `127.0.0.1,3002,127.0.0.1:3003`. The default IP address is `127.0.0.1` + /// and default port is `3001`. + /// + /// This is the same address format supported by the TigerBeetle CLI. + /// + /// # References + /// + /// [Client Sessions](https://docs.tigerbeetle.com/reference/sessions/). + pub fn new(cluster_id: u128, addresses: &str) -> Result { + assert_abi_compatibility(); + + unsafe { + let tb_client = Box::new(tbc::tb_client_t { + opaque: Default::default(), + }); + let tb_client = Box::into_raw(tb_client); + let status = tbc::tb_client_init( + tb_client, + &cluster_id.to_le_bytes(), + addresses.as_ptr() as *const c_char, + addresses.len() as u32, + COMPLETION_CONTEXT, + Some(on_completion), + ); + if status == tbc::TB_INIT_STATUS_TB_INIT_SUCCESS { + Ok(Client { client: tb_client }) + } else { + Err(status.into()) + } + } + } + + /// Create one or more accounts. + /// + /// Accounts to create are provided as a slice of input [`Account`] events. + /// Their fields must be initialized as described in the corresponding + /// [protocol reference](#protocol-reference). + /// + /// The request is queued for submission prior to return of this function; + /// dropping the returned [`Future`] will not cancel the request. + /// + /// # Interpreting the return value + /// + /// This function has two levels of errors: if the entire request fails then + /// the future returns [`Err`] of [`PacketStatus`] and the caller should assume + /// that none of the submitted events were processed. + /// + /// The results of events are represented individually. There are two + /// related event result types: `CreateAccountStatus` is the enum of + /// possible outcomes, and `CreateAccountResult` which includes both the + /// `status` enum and the `timestamp` when the event was processed. + /// + /// Note that a status of `CreateAccountStatus::Exists` should often be treated + /// the same as `CreateAccountStatus::Created`, as it also returns the same `timestamp` + // of the original account. This result can happen in cases of application crashes + /// or other scenarios where requests have been replayed. + /// + /// # Example + /// + /// ```no_run + /// use tigerbeetle as tb; + /// + /// async fn make_create_accounts_request( + /// client: &tb::Client, + /// accounts: &[tb::Account], + /// ) -> std::result::Result<(), Box> { + /// let account_results = client.create_accounts(accounts)?.await?; + /// assert_eq!(accounts.len(), account_results.len()); + /// let it = accounts + /// .iter() + /// .enumerate() + /// .map(move |(i, account)| (account, account_results[i])); + /// + /// for (account, account_result) in it { + /// match account_result.status { + /// tb::CreateAccountStatus::Created | tb::CreateAccountStatus::Exists => { + /// handle_create_account_success(account, account_result).await?; + /// } + /// _ => { + /// handle_create_account_failure(account, account_result).await?; + /// } + /// } + /// } + /// Ok(()) + /// } + /// + /// async fn handle_create_account_success( + /// _account: &tb::Account, + /// _result: tb::CreateAccountResult, + /// ) -> Result<(), Box> { + /// Ok(()) + /// } + /// + /// async fn handle_create_account_failure( + /// _account: &tb::Account, + /// _result: tb::CreateAccountResult, + /// ) -> Result<(), Box> { + /// Ok(()) + /// } + /// ``` + /// + /// # Maximum batch size + /// + /// If the length of the `events` argument exceeds the maximum batch size + /// the future will return [`Err`] of [`PacketStatus::TooMuchData`]. In + /// TigerBeetle's standard build-time configuration the maximum batch size + /// is 8189. + /// + /// # Protocol reference + /// + /// [`create_accounts`](https://docs.tigerbeetle.com/reference/requests/create_accounts). + pub fn create_accounts( + &self, + events: &[Account], + ) -> Result, PacketStatus>>, ClientClosed> + { + let (packet, rx) = + create_packet::(tbc::TB_OPERATION_TB_OPERATION_CREATE_ACCOUNTS, events); + + unsafe { + let packet = Box::into_raw(packet); + let status = tbc::tb_client_submit(self.client, packet); + match status { + tbc::TB_CLIENT_STATUS_TB_CLIENT_OK => {} + tbc::TB_CLIENT_STATUS_TB_CLIENT_INVALID => { + drop(Box::from_raw(packet)); + return Err(ClientClosed); + } + _ => unreachable!("unexpected status from tb_client_submit: {}", status), + } + } + + Ok(async { + let msg = rx.await; + + let responses: &[tbc::tb_create_account_result_t] = handle_message(&msg)?; + + Ok(responses + .iter() + .map(|result| CreateAccountResult { + timestamp: result.timestamp, + status: CreateAccountStatus::from(result.status), + }) + .collect()) + }) + } + + /// Create one or more transfers. + /// + /// Transfers to create are provided as a slice of input [`Transfer`] events. + /// Their fields must be initialized as described in the corresponding + /// [protocol reference](#protocol-reference). + /// + /// The request is queued for submission prior to return of this function; + /// dropping the returned [`Future`] will not cancel the request. + /// + /// # Interpreting the return value + /// + /// This function has two levels of errors: if the entire request fails then + /// the future returns [`Err`] of [`PacketStatus`] and the caller should assume + /// that none of the submitted events were processed. + /// + /// The results of events are represented individually. There are two + /// related event result types: `CreateTransferStatus` is the enum of + /// possible outcomes, and `CreateTransferResult` which includes both the + /// `status` enum and the `timestamp` when the event was processed. + /// + /// Note that a status of `CreateTransferStatus::Exists` should often be treated + /// the same as `CreateTransferStatus::Created`, as it also returns the same `timestamp` + /// of the original transfer. This result can happen in cases of application crashes + /// or other scenarios where requests have been replayed. + /// + /// # Example + /// + /// ```no_run + /// use tigerbeetle as tb; + /// + /// async fn make_create_transfers_request( + /// client: &tb::Client, + /// transfers: &[tb::Transfer], + /// ) -> std::result::Result<(), Box> { + /// let transfer_results = client.create_transfers(transfers)?.await?; + /// let it = transfers + /// .iter() + /// .enumerate() + /// .map(move |(i, transfer)| (transfer, transfer_results[i])); + /// for (transfer, transfer_result) in it { + /// match transfer_result.status { + /// tb::CreateTransferStatus::Created | tb::CreateTransferStatus::Exists => { + /// handle_create_transfer_success(transfer, transfer_result).await?; + /// } + /// _ => { + /// handle_create_transfer_failure(transfer, transfer_result).await?; + /// } + /// } + /// } + /// Ok(()) + /// } + /// + /// async fn handle_create_transfer_success( + /// _transfer: &tb::Transfer, + /// _result: tb::CreateTransferResult, + /// ) -> Result<(), Box> { + /// Ok(()) + /// } + /// + /// async fn handle_create_transfer_failure( + /// _transfer: &tb::Transfer, + /// _result: tb::CreateTransferResult, + /// ) -> Result<(), Box> { + /// Ok(()) + /// } + /// ``` + /// + /// # Maximum batch size + /// + /// If the length of the `events` argument exceeds the maximum batch size + /// the future will return [`Err`] of [`PacketStatus::TooMuchData`]. In + /// TigerBeetle's standard build-time configuration the maximum batch size + /// is 8189. + /// + /// # Protocol reference + /// + /// [`create_transfers`](https://docs.tigerbeetle.com/reference/requests/create_transfers). + pub fn create_transfers( + &self, + events: &[Transfer], + ) -> Result, PacketStatus>>, ClientClosed> + { + let (packet, rx) = + create_packet::(tbc::TB_OPERATION_TB_OPERATION_CREATE_TRANSFERS, events); + + unsafe { + let packet = Box::into_raw(packet); + let status = tbc::tb_client_submit(self.client, packet); + match status { + tbc::TB_CLIENT_STATUS_TB_CLIENT_OK => {} + tbc::TB_CLIENT_STATUS_TB_CLIENT_INVALID => { + drop(Box::from_raw(packet)); + return Err(ClientClosed); + } + _ => unreachable!("unexpected status from tb_client_submit: {}", status), + } + } + + Ok(async { + let msg = rx.await; + + let responses: &[tbc::tb_create_transfer_result_t] = handle_message(&msg)?; + + Ok(responses + .iter() + .map(|result| CreateTransferResult { + timestamp: result.timestamp, + status: CreateTransferStatus::from(result.status), + }) + .collect()) + }) + } + + /// Query individual accounts. + /// + /// The request is queued for submission prior to return of this function; + /// dropping the returned future will not cancel the request. + /// + /// # Interpreting the return value + /// + /// This function has two levels of errors: if the entire request fails then + /// the future returns [`Err`] of [`PacketStatus`] and the caller should assume + /// that none of the submitted events were processed. + /// + /// This request returns the found accounts, in the order requested. The + /// return value does not indicate which accounts were not found. Those can + /// be determined by comparing the output results to the input events, + /// example provided below. + /// + /// # Example + /// + /// ```no_run + /// use tigerbeetle as tb; + /// + /// async fn make_lookup_accounts_request( + /// client: &tb::Client, + /// accounts: &[u128], + /// ) -> Result<(), Box> { + /// let lookup_accounts_results = client.lookup_accounts(accounts)?.await?; + /// let lookup_accounts_results_merged = merge_lookup_accounts_results(accounts, lookup_accounts_results); + /// for (account_id, maybe_account) in lookup_accounts_results_merged { + /// match maybe_account { + /// Some(account) => { + /// handle_lookup_accounts_success(account).await?; + /// } + /// None => { + /// handle_lookup_accounts_failure(account_id).await?; + /// } + /// } + /// } + /// Ok(()) + /// } + /// + /// fn merge_lookup_accounts_results( + /// accounts: &[u128], + /// results: Vec, + /// ) -> impl Iterator)> + '_ { + /// let mut results = results.into_iter().peekable(); + /// accounts.iter().map(move |&id| match results.peek() { + /// Some(acc) if acc.id == id => (id, results.next()), + /// _ => (id, None), + /// }) + /// } + /// + /// # async fn handle_lookup_accounts_success( + /// # _account: tb::Account, + /// # ) -> Result<(), Box> { + /// # Ok(()) + /// # } + /// # + /// # async fn handle_lookup_accounts_failure( + /// # _account_id: u128, + /// # ) -> Result<(), Box> { + /// # Ok(()) + /// # } + /// ``` + /// + /// # Maximum batch size + /// + /// If the length of the `events` argument exceeds the maximum batch size + /// the future will return [`Err`] of [`PacketStatus::TooMuchData`]. In + /// TigerBeetle's standard build-time configuration the maximum batch size + /// is 8189. + /// + /// # Errors + /// + /// This request has two levels of errors: if the entire request fails then + /// the future returns [`Err`] of [`PacketStatus`] and the caller can assume + /// that none of the submitted events were processed; if the request was + /// processed, then each event may possibly be [`NotFound`]. + /// + /// # Protocol reference + /// + /// [`lookup_accounts`](https://docs.tigerbeetle.com/reference/requests/lookup_accounts). + pub fn lookup_accounts( + &self, + events: &[u128], + ) -> Result, PacketStatus>>, ClientClosed> { + let (packet, rx) = + create_packet::(tbc::TB_OPERATION_TB_OPERATION_LOOKUP_ACCOUNTS, events); + + unsafe { + let packet = Box::into_raw(packet); + let status = tbc::tb_client_submit(self.client, packet); + match status { + tbc::TB_CLIENT_STATUS_TB_CLIENT_OK => {} + tbc::TB_CLIENT_STATUS_TB_CLIENT_INVALID => { + drop(Box::from_raw(packet)); + return Err(ClientClosed); + } + _ => unreachable!("unexpected status from tb_client_submit: {}", status), + } + } + + Ok(async { + let msg = rx.await; + let responses: &[Account] = handle_message(&msg)?; + Ok(Vec::from(responses)) + }) + } + + /// Query individual transfers. + /// + /// The request is queued for submission prior to return of this function; + /// dropping the returned future will not cancel the request. + /// + /// # Maximum batch size + /// + /// If the length of the `events` argument exceeds the maximum batch size + /// the future will return [`Err`] of [`PacketStatus::TooMuchData`]. In + /// TigerBeetle's standard build-time configuration the maximum batch size + /// is 8189. + /// + /// # Errors + /// + /// This request has two levels of errors: if the entire request fails then + /// the future returns [`Err`] of [`PacketStatus`] and the caller can assume + /// that none of the submitted events were processed; if the request was + /// processed, then each event may possibly be [`NotFound`]. + /// + /// # Example + /// + /// ``` + /// use tigerbeetle as tb; + /// + /// async fn make_lookup_transfers_request( + /// client: &tb::Client, + /// transfers: &[u128], + /// ) -> Result<(), Box> { + /// let lookup_transfers_results = client.lookup_transfers(transfers)?.await?; + /// let lookup_transfers_results_merged = merge_lookup_transfers_results(transfers, lookup_transfers_results); + /// for (transfer_id, maybe_transfer) in lookup_transfers_results_merged { + /// match maybe_transfer { + /// Some(transfer) => { + /// handle_lookup_transfers_success(transfer).await?; + /// } + /// None => { + /// handle_lookup_transfers_failure(transfer_id).await?; + /// } + /// } + /// } + /// Ok(()) + /// } + /// + /// fn merge_lookup_transfers_results( + /// transfers: &[u128], + /// results: Vec, + /// ) -> impl Iterator)> + '_ { + /// let mut results = results.into_iter().peekable(); + /// transfers.iter().map(move |&id| match results.peek() { + /// Some(transfer) if transfer.id == id => (id, results.next()), + /// _ => (id, None), + /// }) + /// } + /// + /// # async fn handle_lookup_transfers_success( + /// # _transfer: tb::Transfer, + /// # ) -> Result<(), Box> { + /// # Ok(()) + /// # } + /// # + /// # async fn handle_lookup_transfers_failure( + /// # _transfer_id: u128, + /// # ) -> Result<(), Box> { + /// # Ok(()) + /// # } + /// ``` + /// + /// # Protocol reference + /// + /// [`lookup_transfers`](https://docs.tigerbeetle.com/reference/requests/lookup_transfers). + pub fn lookup_transfers( + &self, + events: &[u128], + ) -> Result, PacketStatus>>, ClientClosed> { + let (packet, rx) = + create_packet::(tbc::TB_OPERATION_TB_OPERATION_LOOKUP_TRANSFERS, events); + + unsafe { + let packet = Box::into_raw(packet); + let status = tbc::tb_client_submit(self.client, packet); + match status { + tbc::TB_CLIENT_STATUS_TB_CLIENT_OK => {} + tbc::TB_CLIENT_STATUS_TB_CLIENT_INVALID => { + drop(Box::from_raw(packet)); + return Err(ClientClosed); + } + _ => unreachable!("unexpected status from tb_client_submit: {}", status), + } + } + + Ok(async { + let msg = rx.await; + let responses: &[Transfer] = handle_message(&msg)?; + Ok(Vec::from(responses)) + }) + } + + /// Query multiple transfers for a single account. + /// + /// The request is queued for submission prior to return of this function; + /// dropping the returned future will not cancel the request. + /// + /// # Errors + /// + /// If the entire request fails then the future returns [`Err`] of [`PacketStatus`]. + /// + /// # Protocol reference + /// + /// [`get_account_transfers`](https://docs.tigerbeetle.com/reference/requests/get_account_transfers). + pub fn get_account_transfers( + &self, + event: AccountFilter, + ) -> Result, PacketStatus>>, ClientClosed> { + let (packet, rx) = create_packet::( + tbc::TB_OPERATION_TB_OPERATION_GET_ACCOUNT_TRANSFERS, + &[event], + ); + + unsafe { + let packet = Box::into_raw(packet); + let status = tbc::tb_client_submit(self.client, packet); + match status { + tbc::TB_CLIENT_STATUS_TB_CLIENT_OK => {} + tbc::TB_CLIENT_STATUS_TB_CLIENT_INVALID => { + drop(Box::from_raw(packet)); + return Err(ClientClosed); + } + _ => unreachable!("unexpected status from tb_client_submit: {}", status), + } + } + + Ok(async { + let msg = rx.await; + let result: &[Transfer] = handle_message(&msg)?; + + Ok(result.to_vec()) + }) + } + + /// Query historical account balances for a single account. + /// + /// The request is queued for submission prior to return of this function; + /// dropping the returned future will not cancel the request. + /// + /// # Errors + /// + /// If the entire request fails then the future returns [`Err`] of [`PacketStatus`]. + /// + /// # Protocol reference + /// + /// [`get_account_balances`](https://docs.tigerbeetle.com/reference/requests/get_account_balances). + pub fn get_account_balances( + &self, + event: AccountFilter, + ) -> Result, PacketStatus>>, ClientClosed> { + let (packet, rx) = create_packet::( + tbc::TB_OPERATION_TB_OPERATION_GET_ACCOUNT_BALANCES, + &[event], + ); + + unsafe { + let packet = Box::into_raw(packet); + let status = tbc::tb_client_submit(self.client, packet); + match status { + tbc::TB_CLIENT_STATUS_TB_CLIENT_OK => {} + tbc::TB_CLIENT_STATUS_TB_CLIENT_INVALID => { + drop(Box::from_raw(packet)); + return Err(ClientClosed); + } + _ => unreachable!("unexpected status from tb_client_submit: {}", status), + } + } + + Ok(async { + let msg = rx.await; + let result: &[AccountBalance] = handle_message(&msg)?; + + Ok(result.to_vec()) + }) + } + + /// Query multiple accounts related by fields and timestamps. + /// + /// The request is queued for submission prior to return of this function; + /// dropping the returned future will not cancel the request. + /// + /// # Errors + /// + /// If the entire request fails then the future returns [`Err`] of [`PacketStatus`]. + /// + /// # Protocol reference + /// + /// [`query_accounts`](https://docs.tigerbeetle.com/reference/requests/query_accounts). + pub fn query_accounts( + &self, + event: QueryFilter, + ) -> Result, PacketStatus>>, ClientClosed> { + let (packet, rx) = + create_packet::(tbc::TB_OPERATION_TB_OPERATION_QUERY_ACCOUNTS, &[event]); + + unsafe { + let packet = Box::into_raw(packet); + let status = tbc::tb_client_submit(self.client, packet); + match status { + tbc::TB_CLIENT_STATUS_TB_CLIENT_OK => {} + tbc::TB_CLIENT_STATUS_TB_CLIENT_INVALID => { + drop(Box::from_raw(packet)); + return Err(ClientClosed); + } + _ => unreachable!("unexpected status from tb_client_submit: {}", status), + } + } + + Ok(async { + let msg = rx.await; + let result: &[Account] = handle_message(&msg)?; + + Ok(result.to_vec()) + }) + } + + /// Query multiple transfers related by fields and timestamps. + /// + /// The request is queued for submission prior to return of this function; + /// dropping the returned future will not cancel the request. + /// + /// # Errors + /// + /// If the entire request fails then the future returns [`Err`] of [`PacketStatus`]. + /// + /// # Protocol reference + /// + /// [`query_transfers`](https://docs.tigerbeetle.com/reference/requests/query_transfers). + pub fn query_transfers( + &self, + event: QueryFilter, + ) -> Result, PacketStatus>>, ClientClosed> { + let (packet, rx) = + create_packet::(tbc::TB_OPERATION_TB_OPERATION_QUERY_TRANSFERS, &[event]); + + unsafe { + let packet = Box::into_raw(packet); + let status = tbc::tb_client_submit(self.client, packet); + match status { + tbc::TB_CLIENT_STATUS_TB_CLIENT_OK => {} + tbc::TB_CLIENT_STATUS_TB_CLIENT_INVALID => { + drop(Box::from_raw(packet)); + return Err(ClientClosed); + } + _ => unreachable!("unexpected status from tb_client_submit: {}", status), + } + } + + Ok(async { + let msg = rx.await; + let result: &[Transfer] = handle_message(&msg)?; + + Ok(result.to_vec()) + }) + } + + /// Close the client and asynchronously wait for completion. + /// + /// The returned future resolves to `Err(ClientClosed)` if the client + /// was already invalidated by eviction. + /// + /// Calling `close` will cancel any pending requests. This is only possible + /// if the futures for those requests were dropped without awaiting them. + pub fn close(mut self) -> impl Future> { + struct SendClient(*mut tbc::tb_client_t); + unsafe impl Send for SendClient {} + + let client = std::mem::replace(&mut self.client, std::ptr::null_mut()); + let client = SendClient(client); + + let (tx, rx) = oneshot::channel::>(); + + std::thread::spawn(move || { + let client = client; + let result = unsafe { + // This is a blocking function so we're calling it offthread. + let status = tbc::tb_client_deinit(client.0); + let result = match status { + tbc::TB_CLIENT_STATUS_TB_CLIENT_OK => Ok(()), + tbc::TB_CLIENT_STATUS_TB_CLIENT_INVALID => Err(ClientClosed), + _ => unreachable!("unexpected status from tb_client_deinit: {}", status), + }; + std::mem::drop(Box::from_raw(client.0)); + result + }; + tx.send(result); + }); + + rx + } +} + +impl Drop for Client { + fn drop(&mut self) { + if !self.client.is_null() { + let close_future = Client { + client: self.client, + } + .close(); + // NB: Rust 1.68 clippy - specifically - want's an explicit drop for this future. + drop(close_future); + } + } +} + +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + f.write_str("Client") + } +} + +/// Make basic assertions about the ABI of our types. +/// +/// We don't actually use some of the C types at all, +/// instead casting directly to hand-written Rust types. +/// +/// These assertions give us some confidence those types +/// might be possibly correct. +fn assert_abi_compatibility() { + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::() + ); + assert_eq!( + std::mem::align_of::(), + std::mem::align_of::() + ); + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::() + ); + assert_eq!( + std::mem::align_of::(), + std::mem::align_of::() + ); + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::() + ); + assert_eq!( + std::mem::align_of::(), + std::mem::align_of::() + ); + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::() + ); + assert_eq!( + std::mem::align_of::(), + std::mem::align_of::() + ); + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::() + ); + assert_eq!( + std::mem::align_of::(), + std::mem::align_of::() + ); +} + +/// A TigerBeetle account. +/// +/// # Protocol reference +/// +/// [`Account`](https://docs.tigerbeetle.com/reference/account/). +#[repr(C)] +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Account { + pub id: u128, + pub debits_pending: u128, + pub debits_posted: u128, + pub credits_pending: u128, + pub credits_posted: u128, + pub user_data_128: u128, + pub user_data_64: u64, + pub user_data_32: u32, + pub reserved: Reserved<4>, + pub ledger: u32, + pub code: u16, + pub flags: AccountFlags, + pub timestamp: u64, +} + +/// Bitflags for the `flags` field of [`Account`]. +/// +/// # Protocol reference +/// +/// [`Account.flags`](https://docs.tigerbeetle.com/reference/account/#flags). +pub use tbc::AccountFlags; + +/// A transfer between accounts. +/// +/// # Protocol reference +/// +/// [`Transfer`](https://docs.tigerbeetle.com/reference/transfer). +#[repr(C)] +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Transfer { + pub id: u128, + pub debit_account_id: u128, + pub credit_account_id: u128, + pub amount: u128, + pub pending_id: u128, + pub user_data_128: u128, + pub user_data_64: u64, + pub user_data_32: u32, + pub timeout: u32, + pub ledger: u32, + pub code: u16, + pub flags: TransferFlags, + pub timestamp: u64, +} + +/// Bitflags for the `flags` field of [`Transfer`]. +/// +/// # Protocol reference +/// +/// [`Transfer.flags`](https://docs.tigerbeetle.com/reference/transfer/#flags). +pub use tbc::TransferFlags; + +/// Filter for querying transfers and historical balances. +/// +/// # Protocol reference +/// +/// [`AccountFilter`](https://docs.tigerbeetle.com/reference/account-filter). +#[repr(C)] +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct AccountFilter { + pub account_id: u128, + pub user_data_128: u128, + pub user_data_64: u64, + pub user_data_32: u32, + pub code: u16, + pub reserved: Reserved<58>, + pub timestamp_min: u64, + pub timestamp_max: u64, + pub limit: u32, + pub flags: AccountFilterFlags, +} + +/// Bitflags for the `flags` field of [`AccountFilter`]. +/// +/// # Protocol reference +/// +/// [`AccountFilter.flags`](https://docs.tigerbeetle.com/reference/account-filter/#flags). +pub use tbc::AccountFilterFlags; + +/// An account balance at a point in time. +/// +/// # Protocol reference +/// +/// [`AccountBalance`](https://docs.tigerbeetle.com/reference/account-balance/). +#[repr(C)] +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct AccountBalance { + pub debits_pending: u128, + pub debits_posted: u128, + pub credits_pending: u128, + pub credits_posted: u128, + pub timestamp: u64, + pub reserved: Reserved<56>, +} + +/// Parameters for querying accounts and transfers. +/// +/// # Protocol reference +/// +/// [`QueryFilter`](https://docs.tigerbeetle.com/reference/query-filter/). +#[repr(C)] +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct QueryFilter { + pub user_data_128: u128, + pub user_data_64: u64, + pub user_data_32: u32, + pub ledger: u32, + pub code: u16, + pub reserved: Reserved<6>, + pub timestamp_min: u64, + pub timestamp_max: u64, + pub limit: u32, + pub flags: QueryFilterFlags, +} + +/// Bitflags for the `flags` field of [`QueryFilter`]. +/// +/// # Protocol reference +/// +/// [`QueryFilter.flags`](https://docs.tigerbeetle.com/reference/query-filter/#flags). +pub use tbc::QueryFilterFlags; + +/// The result of a single [`create_accounts`] event. +/// +/// For the meaning of individual enum variants see the linked protocol reference. +/// +/// See also [`CreateAccountResult`] (note the plural), the type directly +/// returned by `create_accunts`, and which contains an additional index for +/// relating results with input events. +/// +/// [`create_accounts`]: `Client::create_accounts` +/// +/// # Protocol reference +/// +/// [`CreateAccountStatus`](https://docs.tigerbeetle.com/reference/requests/create_accounts/#result). +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum CreateAccountStatus { + Created, + LinkedEventFailed, + LinkedEventChainOpen, + ImportedEventExpected, + ImportedEventNotExpected, + TimestampMustBeZero, + ImportedEventTimestampOutOfRange, + ImportedEventTimestampMustNotAdvance, + ReservedField, + ReservedFlag, + IdMustNotBeZero, + IdMustNotBeIntMax, + ExistsWithDifferentFlags, + ExistsWithDifferentUserData128, + ExistsWithDifferentUserData64, + ExistsWithDifferentUserData32, + ExistsWithDifferentLedger, + ExistsWithDifferentCode, + Exists, + FlagsAreMutuallyExclusive, + DebitsPendingMustBeZero, + DebitsPostedMustBeZero, + CreditsPendingMustBeZero, + CreditsPostedMustBeZero, + LedgerMustNotBeZero, + CodeMustNotBeZero, + ImportedEventTimestampMustNotRegress, +} + +/// The result of a single [`create_accounts`] event, with index. +/// +/// [`create_accounts`]: `Client::create_accounts` +/// +/// # Protocol reference +/// +/// [`CreateAccountStatus`](https://docs.tigerbeetle.com/reference/requests/create_accounts/#result). +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct CreateAccountResult { + pub timestamp: u64, + pub status: CreateAccountStatus, +} + +impl core::fmt::Display for CreateAccountStatus { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + match self { + Self::Created => f.write_str("created"), + Self::LinkedEventFailed => f.write_str("linked event failed"), + Self::LinkedEventChainOpen => f.write_str("linked event chain open"), + Self::ImportedEventExpected => f.write_str("imported event expected"), + Self::ImportedEventNotExpected => f.write_str("imported event not expected"), + Self::TimestampMustBeZero => f.write_str("timestamp must be zero"), + Self::ImportedEventTimestampOutOfRange => { + f.write_str("imported event timestamp out of range") + } + Self::ImportedEventTimestampMustNotAdvance => { + f.write_str("imported event timestamp must not advance") + } + Self::ReservedField => f.write_str("reserved field"), + Self::ReservedFlag => f.write_str("reserved flag"), + Self::IdMustNotBeZero => f.write_str("id must not be zero"), + Self::IdMustNotBeIntMax => f.write_str("id must not be int max"), + Self::ExistsWithDifferentFlags => f.write_str("exists with different flags"), + Self::ExistsWithDifferentUserData128 => { + f.write_str("exists with different user_data_128") + } + Self::ExistsWithDifferentUserData64 => { + f.write_str("exists with different user_data_64") + } + Self::ExistsWithDifferentUserData32 => { + f.write_str("exists with different user_data_32") + } + Self::ExistsWithDifferentLedger => f.write_str("exists with different ledger"), + Self::ExistsWithDifferentCode => f.write_str("exists with different code"), + Self::Exists => f.write_str("exists"), + Self::FlagsAreMutuallyExclusive => f.write_str("flags are mutually exclusive"), + Self::DebitsPendingMustBeZero => f.write_str("debits_pending must be zero"), + Self::DebitsPostedMustBeZero => f.write_str("debits_posted must be zero"), + Self::CreditsPendingMustBeZero => f.write_str("credits_pending must be zero"), + Self::CreditsPostedMustBeZero => f.write_str("credits_posted must be zero"), + Self::LedgerMustNotBeZero => f.write_str("ledger must not be zero"), + Self::CodeMustNotBeZero => f.write_str("code must not be zero"), + Self::ImportedEventTimestampMustNotRegress => { + f.write_str("imported event timestamp must not regress") + } + } + } +} + +/// The result of a single [`create_transfers`] event. +/// +/// For the meaning of individual enum variants see the linked protocol reference. +/// +/// See also [`CreateTransferResult`] (note the plural), the type directly +/// returned by `create_accunts`, and which contains an additional index for +/// relating results with input events. +/// +/// [`create_transfers`]: `Client::create_transfers` +/// +/// # Protocol reference +/// +/// [`CreateTransferStatus`](https://docs.tigerbeetle.com/reference/requests/create_transfers/#result). +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum CreateTransferStatus { + Created, + LinkedEventFailed, + LinkedEventChainOpen, + ImportedEventExpected, + ImportedEventNotExpected, + TimestampMustBeZero, + ImportedEventTimestampOutOfRange, + ImportedEventTimestampMustNotAdvance, + ReservedFlag, + IdMustNotBeZero, + IdMustNotBeIntMax, + ExistsWithDifferentFlags, + ExistsWithDifferentPendingId, + ExistsWithDifferentTimeout, + ExistsWithDifferentDebitAccountId, + ExistsWithDifferentCreditAccountId, + ExistsWithDifferentAmount, + ExistsWithDifferentUserData128, + ExistsWithDifferentUserData64, + ExistsWithDifferentUserData32, + ExistsWithDifferentLedger, + ExistsWithDifferentCode, + Exists, + IdAlreadyFailed, + FlagsAreMutuallyExclusive, + DebitAccountIdMustNotBeZero, + DebitAccountIdMustNotBeIntMax, + CreditAccountIdMustNotBeZero, + CreditAccountIdMustNotBeIntMax, + AccountsMustBeDifferent, + PendingIdMustBeZero, + PendingIdMustNotBeZero, + PendingIdMustNotBeIntMax, + PendingIdMustBeDifferent, + TimeoutReservedForPendingTransfer, + ClosingTransferMustBePending, + LedgerMustNotBeZero, + CodeMustNotBeZero, + DebitAccountNotFound, + CreditAccountNotFound, + AccountsMustHaveTheSameLedger, + TransferMustHaveTheSameLedgerAsAccounts, + PendingTransferNotFound, + PendingTransferNotPending, + PendingTransferHasDifferentDebitAccountId, + PendingTransferHasDifferentCreditAccountId, + PendingTransferHasDifferentLedger, + PendingTransferHasDifferentCode, + ExceedsPendingTransferAmount, + PendingTransferHasDifferentAmount, + PendingTransferAlreadyPosted, + PendingTransferAlreadyVoided, + PendingTransferExpired, + ImportedEventTimestampMustNotRegress, + ImportedEventTimestampMustPostdateDebitAccount, + ImportedEventTimestampMustPostdateCreditAccount, + ImportedEventTimeoutMustBeZero, + DebitAccountAlreadyClosed, + CreditAccountAlreadyClosed, + OverflowsDebitsPending, + OverflowsCreditsPending, + OverflowsDebitsPosted, + OverflowsCreditsPosted, + OverflowsDebits, + OverflowsCredits, + OverflowsTimeout, + ExceedsCredits, + ExceedsDebits, +} + +/// The result of a single [`create_transfers`] event, with index. +/// +/// [`create_transfers`]: `Client::create_transfers` +/// +/// # Protocol reference +/// +/// [`CreateTransferStatus`](https://docs.tigerbeetle.com/reference/requests/create_transfers/#result). +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct CreateTransferResult { + pub timestamp: u64, + pub status: CreateTransferStatus, +} + +impl core::fmt::Display for CreateTransferStatus { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + match self { + Self::Created => f.write_str("created"), + Self::LinkedEventFailed => f.write_str("linked event failed"), + Self::LinkedEventChainOpen => f.write_str("linked event chain open"), + Self::ImportedEventExpected => f.write_str("imported event expected"), + Self::ImportedEventNotExpected => f.write_str("imported event not expected"), + Self::TimestampMustBeZero => f.write_str("timestamp must be zero"), + Self::ImportedEventTimestampOutOfRange => { + f.write_str("imported event timestamp out of range") + } + Self::ImportedEventTimestampMustNotAdvance => { + f.write_str("imported event timestamp must not advance") + } + Self::ReservedFlag => f.write_str("reserved flag"), + Self::IdMustNotBeZero => f.write_str("id must not be zero"), + Self::IdMustNotBeIntMax => f.write_str("id must not be int max"), + Self::ExistsWithDifferentFlags => f.write_str("exists with different flags"), + Self::ExistsWithDifferentPendingId => f.write_str("exists with different pending_id"), + Self::ExistsWithDifferentTimeout => f.write_str("exists with different timeout"), + Self::ExistsWithDifferentDebitAccountId => { + f.write_str("exists with different debit_account_id") + } + Self::ExistsWithDifferentCreditAccountId => { + f.write_str("exists with different credit_account_id") + } + Self::ExistsWithDifferentAmount => f.write_str("exists with different amount"), + Self::ExistsWithDifferentUserData128 => { + f.write_str("exists with different user_data_128") + } + Self::ExistsWithDifferentUserData64 => { + f.write_str("exists with different user_data_64") + } + Self::ExistsWithDifferentUserData32 => { + f.write_str("exists with different user_data_32") + } + Self::ExistsWithDifferentLedger => f.write_str("exists with different ledger"), + Self::ExistsWithDifferentCode => f.write_str("exists with different code"), + Self::Exists => f.write_str("exists"), + Self::IdAlreadyFailed => f.write_str("id already failed"), + Self::FlagsAreMutuallyExclusive => f.write_str("flags are mutually exclusive"), + Self::DebitAccountIdMustNotBeZero => f.write_str("debit_account_id must not be zero"), + Self::DebitAccountIdMustNotBeIntMax => { + f.write_str("debit_account_id must not be int max") + } + Self::CreditAccountIdMustNotBeZero => f.write_str("credit_account_id must not be zero"), + Self::CreditAccountIdMustNotBeIntMax => { + f.write_str("credit_account_id must not be int max") + } + Self::AccountsMustBeDifferent => f.write_str("accounts must be different"), + Self::PendingIdMustBeZero => f.write_str("pending_id must be zero"), + Self::PendingIdMustNotBeZero => f.write_str("pending_id must not be zero"), + Self::PendingIdMustNotBeIntMax => f.write_str("pending_id must not be int max"), + Self::PendingIdMustBeDifferent => f.write_str("pending_id must be different"), + Self::TimeoutReservedForPendingTransfer => { + f.write_str("timeout reserved for pending transfer") + } + Self::ClosingTransferMustBePending => f.write_str("closing transfer must be pending"), + Self::LedgerMustNotBeZero => f.write_str("ledger must not be zero"), + Self::CodeMustNotBeZero => f.write_str("code must not be zero"), + Self::DebitAccountNotFound => f.write_str("debit account not found"), + Self::CreditAccountNotFound => f.write_str("credit account not found"), + Self::AccountsMustHaveTheSameLedger => { + f.write_str("accounts must have the same ledger") + } + Self::TransferMustHaveTheSameLedgerAsAccounts => { + f.write_str("transfer must have the same ledger as accounts") + } + Self::PendingTransferNotFound => f.write_str("pending transfer not found"), + Self::PendingTransferNotPending => f.write_str("pending transfer not pending"), + Self::PendingTransferHasDifferentDebitAccountId => { + f.write_str("pending transfer has different debit_account_id") + } + Self::PendingTransferHasDifferentCreditAccountId => { + f.write_str("pending transfer has different credit_account_id") + } + Self::PendingTransferHasDifferentLedger => { + f.write_str("pending transfer has different ledger") + } + Self::PendingTransferHasDifferentCode => { + f.write_str("pending transfer has different code") + } + Self::ExceedsPendingTransferAmount => f.write_str("exceeds pending transfer amount"), + Self::PendingTransferHasDifferentAmount => { + f.write_str("pending transfer has different amount") + } + Self::PendingTransferAlreadyPosted => f.write_str("pending transfer already posted"), + Self::PendingTransferAlreadyVoided => f.write_str("pending transfer already voided"), + Self::PendingTransferExpired => f.write_str("pending transfer expired"), + Self::ImportedEventTimestampMustNotRegress => { + f.write_str("imported event timestamp must not regress") + } + Self::ImportedEventTimestampMustPostdateDebitAccount => { + f.write_str("imported event timestamp must postdate debit account") + } + Self::ImportedEventTimestampMustPostdateCreditAccount => { + f.write_str("imported event timestamp must postdate credit account") + } + Self::ImportedEventTimeoutMustBeZero => { + f.write_str("imported event timeout must be zero") + } + Self::DebitAccountAlreadyClosed => f.write_str("debit account already closed"), + Self::CreditAccountAlreadyClosed => f.write_str("credit account already closed"), + Self::OverflowsDebitsPending => f.write_str("overflows debits_pending"), + Self::OverflowsCreditsPending => f.write_str("overflows credits_pending"), + Self::OverflowsDebitsPosted => f.write_str("overflows debits_posted"), + Self::OverflowsCreditsPosted => f.write_str("overflows credits_posted"), + Self::OverflowsDebits => f.write_str("overflows debits"), + Self::OverflowsCredits => f.write_str("overflows credits"), + Self::OverflowsTimeout => f.write_str("overflows timeout"), + Self::ExceedsCredits => f.write_str("exceeds credits"), + Self::ExceedsDebits => f.write_str("exceeds debits"), + } + } +} + +/// Errors resulting from constructing a [`Client`]. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum InitStatus { + /// Some other unexpected error occurrred. + Unexpected, + /// Out of memory. + OutOfMemory, + /// There was some error parsing the provided addresses. + AddressInvalid, + /// Too many addresses were provided. + AddressLimitExceeded, + /// Some system resource was exhausted. + /// + /// This includes file descriptors, threads, and lockable memory. + SystemResources, + /// The network was unavailable or other network initialization error. + NetworkSubsystem, +} + +impl std::error::Error for InitStatus {} +impl core::fmt::Display for InitStatus { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + match self { + Self::Unexpected => f.write_str("unexpected"), + Self::OutOfMemory => f.write_str("out of memory"), + Self::AddressInvalid => f.write_str("address invalid"), + Self::AddressLimitExceeded => f.write_str("address limit exceeded"), + Self::SystemResources => f.write_str("system resources"), + Self::NetworkSubsystem => f.write_str("network subsystem"), + } + } +} + +/// Errors that occur prior to the server processing a batch of operations. +/// +/// When one of these is returned as a result of a transaction request, +/// then all operations in the request can be assumed to have not been processed. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum PacketStatus { + /// Too many events were submitted to a multi-event request. + TooMuchData, + /// The client was evicted by the server. + ClientEvicted, + /// The client's version is too low. + ClientReleaseTooLow, + /// The client's version is too high. + ClientReleaseTooHigh, + /// The client was already destructed. + ClientShutdown, + /// An invalid operation was submitted. + /// + /// This should not be possible in the Rust client. + InvalidOperation, + /// The operation's payload was an incorrect size. + /// + /// This should not be possible in the Rust client. + InvalidDataSize, +} + +impl std::error::Error for PacketStatus {} +impl core::fmt::Display for PacketStatus { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + match self { + Self::TooMuchData => f.write_str("too much data"), + Self::ClientEvicted => f.write_str("client evicted"), + Self::ClientReleaseTooLow => f.write_str("client release too low"), + Self::ClientReleaseTooHigh => f.write_str("client release too high"), + Self::ClientShutdown => f.write_str("client shutdown"), + Self::InvalidOperation => f.write_str("invalid operation"), + Self::InvalidDataSize => f.write_str("invalid data size"), + } + } +} + +/// An error indicating the client has been closed or is invalid. +/// +/// Returned when `tb_client_submit` reports the client handle is invalid, +/// which occurs after the client has been closed. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct ClientClosed; + +impl std::error::Error for ClientClosed {} +impl core::fmt::Display for ClientClosed { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.write_str("client closed") + } +} + +/// An error type returned by point queries. +/// +/// Returned by [`Client::lookup_accounts`] and [`Client::lookup_transfers`] +/// when the account or transfer does not exist. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct NotFound; + +impl std::error::Error for NotFound {} +impl core::fmt::Display for NotFound { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.write_str("not found") + } +} + +/// A utility type for representing reserved bytes in structs. +/// +/// This type is instantiated with [`Default::default`] and typically +/// does not need to be used directly. +#[repr(transparent)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Reserved([u8; N]); + +impl Default for Reserved { + fn default() -> Reserved { + Reserved([0; N]) + } +} + +fn create_packet( + op: u8, // TB_OPERATION + events: &[Event], +) -> ( + Box, + oneshot::Receiver>, +) +where + Event: Copy + 'static, +{ + let (tx, rx) = oneshot::channel::>(); + + let callback = Box::new(CallbackData { + header: CallbackHeader { + complete: complete_typed::, + }, + sender: tx, + }); + + let mut events: Vec = events.to_vec(); + assert_eq!(events.len(), events.capacity()); + + let events_len = events.len(); + let events_ptr = events.as_mut_ptr(); + mem::forget(events); + + let packet = Box::new(tbc::tb_packet_t { + user_data: Box::into_raw(callback) as *mut c_void, + data: events_ptr as *mut c_void, + data_size: (mem::size_of::() * events_len) as u32, + user_tag: 0xABCD, + operation: op, + status: tbc::TB_PACKET_STATUS_TB_PACKET_OK, + opaque: [0; 64], + }); + + (packet, rx) +} + +fn handle_message( + msg: &CompletionMessage, +) -> Result<&[CResult], PacketStatus> { + let packet = &msg.packet.0; + let result = &msg.result; + + if packet.status != tbc::TB_PACKET_STATUS_TB_PACKET_OK { + return Err(packet.status.into()); + } + + let result = unsafe { + if !result.is_empty() { + std::slice::from_raw_parts( + result.as_ptr() as *const CResult, + result + .len() + .checked_div(mem::size_of::()) + .expect("div"), + ) + } else { + &[] + } + }; + + Ok(result) +} + +// Thread-sendable wrapper for the owned packet. +struct Packet(Box); + +// Safety: after completion, zig no longer touches the packet; we own it exclusively. +unsafe impl Send for Packet {} + +struct CompletionMessage { + _context: usize, + packet: Packet, + _timestamp: u64, + result: Vec, + _events: Vec, +} + +/// Type-erased header stored in `packet.user_data`. +/// +/// By using an unsafe type-erased bare function instead a closure here +/// we avoid double boxing that closure to store it as a thin pointer, +/// reducing allocations per request by 1. +/// +/// `on_completion` reads the `complete` function pointer without knowing +/// the `Event` type and unsafely casts from `CallbackHeader` to the typed +/// `CallbackData`. +#[repr(C)] +struct CallbackHeader { + complete: unsafe fn(*mut CallbackHeader, usize, *mut tbc::tb_packet_t, u64, *const u8, u32), +} + +/// Full callback data, generic over `Event`. +#[repr(C)] +struct CallbackData { + header: CallbackHeader, + sender: oneshot::Sender>, +} + +unsafe fn complete_typed( + header: *mut CallbackHeader, + context: usize, + packet: *mut tbc::tb_packet_t, + timestamp: u64, + result: *const u8, + result_size: u32, +) { + let callback = Box::from_raw(header as *mut CallbackData); + + let events_len = (*packet).data_size as usize / mem::size_of::(); + let events = Vec::from_raw_parts((*packet).data as *mut Event, events_len, events_len); + (*packet).data = ptr::null_mut(); + let packet = Packet(Box::from_raw(packet)); + + let result = if result_size != 0 { + std::slice::from_raw_parts(result, result_size as usize) + } else { + &[] + }; + let result = Vec::from(result); + + callback.sender.send(CompletionMessage { + _context: context, + packet, + _timestamp: timestamp, + result, + _events: events, + }); +} + +extern "C" fn on_completion( + context: usize, + packet: *mut tbc::tb_packet_t, + timestamp: u64, + result_ptr: *const u8, + result_len: u32, +) { + unsafe { + let header = (*packet).user_data as *mut CallbackHeader; + (*packet).user_data = ptr::null_mut(); + ((*header).complete)(header, context, packet, timestamp, result_ptr, result_len); + } +} diff --git a/ocam/src/clients/rust/src/oneshot.rs b/ocam/src/clients/rust/src/oneshot.rs new file mode 100644 index 00000000..5d03eeaf --- /dev/null +++ b/ocam/src/clients/rust/src/oneshot.rs @@ -0,0 +1,182 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Waker}; + +struct Shared { + value: Option, + waker: Option, +} + +pub struct Sender(Arc>>); +pub struct Receiver(Arc>>); + +pub fn channel() -> (Sender, Receiver) { + let shared = Arc::new(Mutex::new(Shared { + value: None, + waker: None, + })); + (Sender(shared.clone()), Receiver(shared)) +} + +impl Sender { + pub fn send(self, value: T) { + let mut shared = self.0.lock().unwrap(); + shared.value = Some(value); + if let Some(waker) = shared.waker.take() { + waker.wake(); + } + } +} + +impl Future for Receiver { + type Output = T; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let mut shared = self.0.lock().unwrap(); + if let Some(value) = shared.value.take() { + Poll::Ready(value) + } else { + shared.waker = Some(cx.waker().clone()); + Poll::Pending + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::{RawWaker, RawWakerVTable}; + + // Minimal waker that tracks wake count. + fn counting_waker(count: &Arc) -> Waker { + let data = Arc::into_raw(count.clone()) as *const (); + + unsafe fn clone(data: *const ()) -> RawWaker { + Arc::increment_strong_count(data as *const AtomicUsize); + RawWaker::new(data, &VTABLE) + } + unsafe fn wake(data: *const ()) { + let arc = Arc::from_raw(data as *const AtomicUsize); + arc.fetch_add(1, Ordering::SeqCst); + } + unsafe fn wake_by_ref(data: *const ()) { + let arc = &*(data as *const AtomicUsize); + arc.fetch_add(1, Ordering::SeqCst); + } + unsafe fn drop(data: *const ()) { + Arc::decrement_strong_count(data as *const AtomicUsize); + } + + static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake_by_ref, drop); + + unsafe { Waker::from_raw(RawWaker::new(data, &VTABLE)) } + } + + fn poll_once(rx: &mut Receiver, waker: &Waker) -> Poll { + let mut cx = Context::from_waker(waker); + Pin::new(rx).poll(&mut cx) + } + + #[test] + fn send_before_poll() { + let (tx, mut rx) = channel::(); + let wake_count = Arc::new(AtomicUsize::new(0)); + let waker = counting_waker(&wake_count); + + tx.send(42); + + // Value already present — should resolve immediately. + match poll_once(&mut rx, &waker) { + Poll::Ready(v) => assert_eq!(v, 42), + Poll::Pending => panic!("expected Ready"), + } + // No waker should have been registered or woken. + assert_eq!(wake_count.load(Ordering::SeqCst), 0); + } + + #[test] + fn poll_before_send() { + let (tx, mut rx) = channel::(); + let wake_count = Arc::new(AtomicUsize::new(0)); + let waker = counting_waker(&wake_count); + + // First poll — no value yet. + assert!(poll_once(&mut rx, &waker).is_pending()); + assert_eq!(wake_count.load(Ordering::SeqCst), 0); + + // Send wakes the registered waker. + tx.send(99); + assert_eq!(wake_count.load(Ordering::SeqCst), 1); + + // Second poll picks up the value. + match poll_once(&mut rx, &waker) { + Poll::Ready(v) => assert_eq!(v, 99), + Poll::Pending => panic!("expected Ready"), + } + } + + #[test] + fn multiple_polls_replace_waker() { + let (tx, mut rx) = channel::<&str>(); + let count1 = Arc::new(AtomicUsize::new(0)); + let count2 = Arc::new(AtomicUsize::new(0)); + let waker1 = counting_waker(&count1); + let waker2 = counting_waker(&count2); + + // Register waker1, then replace with waker2. + assert!(poll_once(&mut rx, &waker1).is_pending()); + assert!(poll_once(&mut rx, &waker2).is_pending()); + + tx.send("hello"); + + // Only the most recent waker should fire. + assert_eq!(count1.load(Ordering::SeqCst), 0); + assert_eq!(count2.load(Ordering::SeqCst), 1); + } + + #[test] + fn send_without_prior_poll() { + // No waker registered — send should not panic. + let (tx, _rx) = channel::(); + tx.send(7); + } + + #[test] + fn send_from_another_thread() { + let (tx, mut rx) = channel::>(); + let wake_count = Arc::new(AtomicUsize::new(0)); + let waker = counting_waker(&wake_count); + + assert!(poll_once(&mut rx, &waker).is_pending()); + + std::thread::spawn(move || { + tx.send(vec![1, 2, 3]); + }) + .join() + .unwrap(); + + match poll_once(&mut rx, &waker) { + Poll::Ready(v) => assert_eq!(v, vec![1, 2, 3]), + Poll::Pending => panic!("expected Ready"), + } + } + + #[test] + fn zero_sized_type() { + let (tx, mut rx) = channel::<()>(); + let wake_count = Arc::new(AtomicUsize::new(0)); + let waker = counting_waker(&wake_count); + + assert!(poll_once(&mut rx, &waker).is_pending()); + tx.send(()); + assert!(poll_once(&mut rx, &waker).is_ready()); + } + + #[test] + fn drop_sender_without_sending() { + // Dropping sender without sending shouldn't panic or wake. + let (_tx, _rx) = channel::(); + } +} diff --git a/ocam/src/clients/rust/src/tb_client.rs b/ocam/src/clients/rust/src/tb_client.rs new file mode 100644 index 00000000..cfccb606 --- /dev/null +++ b/ocam/src/clients/rust/src/tb_client.rs @@ -0,0 +1,424 @@ + /////////////////////////////////////////////////////// + // This file was auto-generated by rust_bindings.zig // + // Do not manually modify. // + /////////////////////////////////////////////////////// + +#[derive(Copy, Clone, Debug, Default)] +#[derive(Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct AccountFlags(pub u16); +impl AccountFlags { + pub const Linked: AccountFlags = AccountFlags(1 << 0); + pub const DebitsMustNotExceedCredits: AccountFlags = AccountFlags(1 << 1); + pub const CreditsMustNotExceedDebits: AccountFlags = AccountFlags(1 << 2); + pub const History: AccountFlags = AccountFlags(1 << 3); + pub const Imported: AccountFlags = AccountFlags(1 << 4); + pub const Closed: AccountFlags = AccountFlags(1 << 5); + + pub fn empty() -> Self { AccountFlags(0) } +} + +impl std::ops::BitOr for AccountFlags { + type Output = AccountFlags; + fn bitor(self, rhs: Self) -> Self::Output { + Self(self.0 | rhs.0) + } +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct tb_account_t { + pub id: u128, + pub debits_pending: u128, + pub debits_posted: u128, + pub credits_pending: u128, + pub credits_posted: u128, + pub user_data_128: u128, + pub user_data_64: u64, + pub user_data_32: u32, + pub reserved: u32, + pub ledger: u32, + pub code: u16, + pub flags: u16, + pub timestamp: u64, +} + +#[derive(Copy, Clone, Debug, Default)] +#[derive(Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct TransferFlags(pub u16); +impl TransferFlags { + pub const Linked: TransferFlags = TransferFlags(1 << 0); + pub const Pending: TransferFlags = TransferFlags(1 << 1); + pub const PostPendingTransfer: TransferFlags = TransferFlags(1 << 2); + pub const VoidPendingTransfer: TransferFlags = TransferFlags(1 << 3); + pub const BalancingDebit: TransferFlags = TransferFlags(1 << 4); + pub const BalancingCredit: TransferFlags = TransferFlags(1 << 5); + pub const ClosingDebit: TransferFlags = TransferFlags(1 << 6); + pub const ClosingCredit: TransferFlags = TransferFlags(1 << 7); + pub const Imported: TransferFlags = TransferFlags(1 << 8); + + pub fn empty() -> Self { TransferFlags(0) } +} + +impl std::ops::BitOr for TransferFlags { + type Output = TransferFlags; + fn bitor(self, rhs: Self) -> Self::Output { + Self(self.0 | rhs.0) + } +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct tb_transfer_t { + pub id: u128, + pub debit_account_id: u128, + pub credit_account_id: u128, + pub amount: u128, + pub pending_id: u128, + pub user_data_128: u128, + pub user_data_64: u64, + pub user_data_32: u32, + pub timeout: u32, + pub ledger: u32, + pub code: u16, + pub flags: u16, + pub timestamp: u64, +} + +pub type TB_CREATE_ACCOUNT_STATUS = u32; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_CREATED: TB_CREATE_ACCOUNT_STATUS = 0xFFFFFFFF; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_LINKED_EVENT_FAILED: TB_CREATE_ACCOUNT_STATUS = 1; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_LINKED_EVENT_CHAIN_OPEN: TB_CREATE_ACCOUNT_STATUS = 2; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_EXPECTED: TB_CREATE_ACCOUNT_STATUS = 22; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_NOT_EXPECTED: TB_CREATE_ACCOUNT_STATUS = 23; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_TIMESTAMP_MUST_BE_ZERO: TB_CREATE_ACCOUNT_STATUS = 3; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE: TB_CREATE_ACCOUNT_STATUS = 24; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE: TB_CREATE_ACCOUNT_STATUS = 25; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_RESERVED_FIELD: TB_CREATE_ACCOUNT_STATUS = 4; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_RESERVED_FLAG: TB_CREATE_ACCOUNT_STATUS = 5; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_ZERO: TB_CREATE_ACCOUNT_STATUS = 6; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_ID_MUST_NOT_BE_INT_MAX: TB_CREATE_ACCOUNT_STATUS = 7; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_FLAGS: TB_CREATE_ACCOUNT_STATUS = 15; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_128: TB_CREATE_ACCOUNT_STATUS = 16; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_64: TB_CREATE_ACCOUNT_STATUS = 17; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_USER_DATA_32: TB_CREATE_ACCOUNT_STATUS = 18; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_LEDGER: TB_CREATE_ACCOUNT_STATUS = 19; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS_WITH_DIFFERENT_CODE: TB_CREATE_ACCOUNT_STATUS = 20; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_EXISTS: TB_CREATE_ACCOUNT_STATUS = 21; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_FLAGS_ARE_MUTUALLY_EXCLUSIVE: TB_CREATE_ACCOUNT_STATUS = 8; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_DEBITS_PENDING_MUST_BE_ZERO: TB_CREATE_ACCOUNT_STATUS = 9; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_DEBITS_POSTED_MUST_BE_ZERO: TB_CREATE_ACCOUNT_STATUS = 10; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_CREDITS_PENDING_MUST_BE_ZERO: TB_CREATE_ACCOUNT_STATUS = 11; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_CREDITS_POSTED_MUST_BE_ZERO: TB_CREATE_ACCOUNT_STATUS = 12; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_LEDGER_MUST_NOT_BE_ZERO: TB_CREATE_ACCOUNT_STATUS = 13; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_CODE_MUST_NOT_BE_ZERO: TB_CREATE_ACCOUNT_STATUS = 14; +pub const TB_CREATE_ACCOUNT_STATUS_TB_CREATE_ACCOUNT_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS: TB_CREATE_ACCOUNT_STATUS = 26; + +pub type TB_CREATE_TRANSFER_STATUS = u32; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREATED: TB_CREATE_TRANSFER_STATUS = 0xFFFFFFFF; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_LINKED_EVENT_FAILED: TB_CREATE_TRANSFER_STATUS = 1; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_LINKED_EVENT_CHAIN_OPEN: TB_CREATE_TRANSFER_STATUS = 2; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_EXPECTED: TB_CREATE_TRANSFER_STATUS = 56; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_NOT_EXPECTED: TB_CREATE_TRANSFER_STATUS = 57; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_TIMESTAMP_MUST_BE_ZERO: TB_CREATE_TRANSFER_STATUS = 3; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_OUT_OF_RANGE: TB_CREATE_TRANSFER_STATUS = 58; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_ADVANCE: TB_CREATE_TRANSFER_STATUS = 59; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_RESERVED_FLAG: TB_CREATE_TRANSFER_STATUS = 4; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ID_MUST_NOT_BE_ZERO: TB_CREATE_TRANSFER_STATUS = 5; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ID_MUST_NOT_BE_INT_MAX: TB_CREATE_TRANSFER_STATUS = 6; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_FLAGS: TB_CREATE_TRANSFER_STATUS = 36; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_PENDING_ID: TB_CREATE_TRANSFER_STATUS = 40; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_TIMEOUT: TB_CREATE_TRANSFER_STATUS = 44; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_DEBIT_ACCOUNT_ID: TB_CREATE_TRANSFER_STATUS = 37; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CREDIT_ACCOUNT_ID: TB_CREATE_TRANSFER_STATUS = 38; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_AMOUNT: TB_CREATE_TRANSFER_STATUS = 39; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_128: TB_CREATE_TRANSFER_STATUS = 41; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_64: TB_CREATE_TRANSFER_STATUS = 42; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_USER_DATA_32: TB_CREATE_TRANSFER_STATUS = 43; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_LEDGER: TB_CREATE_TRANSFER_STATUS = 67; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS_WITH_DIFFERENT_CODE: TB_CREATE_TRANSFER_STATUS = 45; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXISTS: TB_CREATE_TRANSFER_STATUS = 46; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ID_ALREADY_FAILED: TB_CREATE_TRANSFER_STATUS = 68; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_FLAGS_ARE_MUTUALLY_EXCLUSIVE: TB_CREATE_TRANSFER_STATUS = 7; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_ZERO: TB_CREATE_TRANSFER_STATUS = 8; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX: TB_CREATE_TRANSFER_STATUS = 9; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_ZERO: TB_CREATE_TRANSFER_STATUS = 10; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ID_MUST_NOT_BE_INT_MAX: TB_CREATE_TRANSFER_STATUS = 11; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ACCOUNTS_MUST_BE_DIFFERENT: TB_CREATE_TRANSFER_STATUS = 12; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_ZERO: TB_CREATE_TRANSFER_STATUS = 13; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_ZERO: TB_CREATE_TRANSFER_STATUS = 14; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_ID_MUST_NOT_BE_INT_MAX: TB_CREATE_TRANSFER_STATUS = 15; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_ID_MUST_BE_DIFFERENT: TB_CREATE_TRANSFER_STATUS = 16; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_TIMEOUT_RESERVED_FOR_PENDING_TRANSFER: TB_CREATE_TRANSFER_STATUS = 17; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CLOSING_TRANSFER_MUST_BE_PENDING: TB_CREATE_TRANSFER_STATUS = 64; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_LEDGER_MUST_NOT_BE_ZERO: TB_CREATE_TRANSFER_STATUS = 19; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CODE_MUST_NOT_BE_ZERO: TB_CREATE_TRANSFER_STATUS = 20; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_DEBIT_ACCOUNT_NOT_FOUND: TB_CREATE_TRANSFER_STATUS = 21; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREDIT_ACCOUNT_NOT_FOUND: TB_CREATE_TRANSFER_STATUS = 22; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_ACCOUNTS_MUST_HAVE_THE_SAME_LEDGER: TB_CREATE_TRANSFER_STATUS = 23; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_TRANSFER_MUST_HAVE_THE_SAME_LEDGER_AS_ACCOUNTS: TB_CREATE_TRANSFER_STATUS = 24; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_FOUND: TB_CREATE_TRANSFER_STATUS = 25; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_NOT_PENDING: TB_CREATE_TRANSFER_STATUS = 26; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_DEBIT_ACCOUNT_ID: TB_CREATE_TRANSFER_STATUS = 27; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CREDIT_ACCOUNT_ID: TB_CREATE_TRANSFER_STATUS = 28; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_LEDGER: TB_CREATE_TRANSFER_STATUS = 29; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_CODE: TB_CREATE_TRANSFER_STATUS = 30; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXCEEDS_PENDING_TRANSFER_AMOUNT: TB_CREATE_TRANSFER_STATUS = 31; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_HAS_DIFFERENT_AMOUNT: TB_CREATE_TRANSFER_STATUS = 32; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_POSTED: TB_CREATE_TRANSFER_STATUS = 33; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_ALREADY_VOIDED: TB_CREATE_TRANSFER_STATUS = 34; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_PENDING_TRANSFER_EXPIRED: TB_CREATE_TRANSFER_STATUS = 35; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_NOT_REGRESS: TB_CREATE_TRANSFER_STATUS = 60; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_DEBIT_ACCOUNT: TB_CREATE_TRANSFER_STATUS = 61; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMESTAMP_MUST_POSTDATE_CREDIT_ACCOUNT: TB_CREATE_TRANSFER_STATUS = 62; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_IMPORTED_EVENT_TIMEOUT_MUST_BE_ZERO: TB_CREATE_TRANSFER_STATUS = 63; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_DEBIT_ACCOUNT_ALREADY_CLOSED: TB_CREATE_TRANSFER_STATUS = 65; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_CREDIT_ACCOUNT_ALREADY_CLOSED: TB_CREATE_TRANSFER_STATUS = 66; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_PENDING: TB_CREATE_TRANSFER_STATUS = 47; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_PENDING: TB_CREATE_TRANSFER_STATUS = 48; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_DEBITS_POSTED: TB_CREATE_TRANSFER_STATUS = 49; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_CREDITS_POSTED: TB_CREATE_TRANSFER_STATUS = 50; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_DEBITS: TB_CREATE_TRANSFER_STATUS = 51; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_CREDITS: TB_CREATE_TRANSFER_STATUS = 52; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_OVERFLOWS_TIMEOUT: TB_CREATE_TRANSFER_STATUS = 53; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXCEEDS_CREDITS: TB_CREATE_TRANSFER_STATUS = 54; +pub const TB_CREATE_TRANSFER_STATUS_TB_CREATE_TRANSFER_EXCEEDS_DEBITS: TB_CREATE_TRANSFER_STATUS = 55; + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct tb_create_account_result_t { + pub timestamp: u64, + pub status: u32, + pub reserved: u32, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct tb_create_transfer_result_t { + pub timestamp: u64, + pub status: u32, + pub reserved: u32, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct tb_account_filter_t { + pub account_id: u128, + pub user_data_128: u128, + pub user_data_64: u64, + pub user_data_32: u32, + pub code: u16, + pub reserved: [u8; 58], + pub timestamp_min: u64, + pub timestamp_max: u64, + pub limit: u32, + pub flags: u32, +} + +#[derive(Copy, Clone, Debug, Default)] +#[derive(Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct AccountFilterFlags(pub u32); +impl AccountFilterFlags { + pub const Debits: AccountFilterFlags = AccountFilterFlags(1 << 0); + pub const Credits: AccountFilterFlags = AccountFilterFlags(1 << 1); + pub const Reversed: AccountFilterFlags = AccountFilterFlags(1 << 2); + + pub fn empty() -> Self { AccountFilterFlags(0) } +} + +impl std::ops::BitOr for AccountFilterFlags { + type Output = AccountFilterFlags; + fn bitor(self, rhs: Self) -> Self::Output { + Self(self.0 | rhs.0) + } +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct tb_account_balance_t { + pub debits_pending: u128, + pub debits_posted: u128, + pub credits_pending: u128, + pub credits_posted: u128, + pub timestamp: u64, + pub reserved: [u8; 56], +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct tb_query_filter_t { + pub user_data_128: u128, + pub user_data_64: u64, + pub user_data_32: u32, + pub ledger: u32, + pub code: u16, + pub reserved: [u8; 6], + pub timestamp_min: u64, + pub timestamp_max: u64, + pub limit: u32, + pub flags: u32, +} + +#[derive(Copy, Clone, Debug, Default)] +#[derive(Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct QueryFilterFlags(pub u32); +impl QueryFilterFlags { + pub const Reversed: QueryFilterFlags = QueryFilterFlags(1 << 0); + + pub fn empty() -> Self { QueryFilterFlags(0) } +} + +impl std::ops::BitOr for QueryFilterFlags { + type Output = QueryFilterFlags; + fn bitor(self, rhs: Self) -> Self::Output { + Self(self.0 | rhs.0) + } +} + +// Opaque struct serving as a handle for the client instance. +// This struct must be "pinned" (not copyable or movable), as its address must remain stable +// throughout the lifetime of the client instance. +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct tb_client_t { + pub opaque: [u64; 4], +} + +// Struct containing the state of a request submitted through the client. +// This struct must be "pinned" (not copyable or movable), as its address must remain stable +// throughout the lifetime of the request. +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct tb_packet_t { + pub user_data: *mut ::std::os::raw::c_void, + pub data: *mut ::std::os::raw::c_void, + pub data_size: u32, + pub user_tag: u16, + pub operation: u8, + pub status: u8, + pub opaque: [u8; 64], +} + +pub type TB_OPERATION = u8; +pub const TB_OPERATION_TB_OPERATION_PULSE: TB_OPERATION = 128; +pub const TB_OPERATION_TB_OPERATION_GET_CHANGE_EVENTS: TB_OPERATION = 137; +pub const TB_OPERATION_TB_OPERATION_LOOKUP_ACCOUNTS: TB_OPERATION = 140; +pub const TB_OPERATION_TB_OPERATION_LOOKUP_TRANSFERS: TB_OPERATION = 141; +pub const TB_OPERATION_TB_OPERATION_GET_ACCOUNT_TRANSFERS: TB_OPERATION = 142; +pub const TB_OPERATION_TB_OPERATION_GET_ACCOUNT_BALANCES: TB_OPERATION = 143; +pub const TB_OPERATION_TB_OPERATION_QUERY_ACCOUNTS: TB_OPERATION = 144; +pub const TB_OPERATION_TB_OPERATION_QUERY_TRANSFERS: TB_OPERATION = 145; +pub const TB_OPERATION_TB_OPERATION_CREATE_ACCOUNTS: TB_OPERATION = 146; +pub const TB_OPERATION_TB_OPERATION_CREATE_TRANSFERS: TB_OPERATION = 147; + +pub type TB_PACKET_STATUS = u8; +pub const TB_PACKET_STATUS_TB_PACKET_OK: TB_PACKET_STATUS = 0; +pub const TB_PACKET_STATUS_TB_PACKET_TOO_MUCH_DATA: TB_PACKET_STATUS = 1; +pub const TB_PACKET_STATUS_TB_PACKET_CLIENT_EVICTED: TB_PACKET_STATUS = 2; +pub const TB_PACKET_STATUS_TB_PACKET_CLIENT_RELEASE_TOO_LOW: TB_PACKET_STATUS = 3; +pub const TB_PACKET_STATUS_TB_PACKET_CLIENT_RELEASE_TOO_HIGH: TB_PACKET_STATUS = 4; +pub const TB_PACKET_STATUS_TB_PACKET_CLIENT_SHUTDOWN: TB_PACKET_STATUS = 5; +pub const TB_PACKET_STATUS_TB_PACKET_INVALID_OPERATION: TB_PACKET_STATUS = 6; +pub const TB_PACKET_STATUS_TB_PACKET_INVALID_DATA_SIZE: TB_PACKET_STATUS = 7; + +pub type TB_INIT_STATUS = i32; +pub const TB_INIT_STATUS_TB_INIT_SUCCESS: TB_INIT_STATUS = 0; +pub const TB_INIT_STATUS_TB_INIT_UNEXPECTED: TB_INIT_STATUS = 1; +pub const TB_INIT_STATUS_TB_INIT_OUT_OF_MEMORY: TB_INIT_STATUS = 2; +pub const TB_INIT_STATUS_TB_INIT_ADDRESS_INVALID: TB_INIT_STATUS = 3; +pub const TB_INIT_STATUS_TB_INIT_ADDRESS_LIMIT_EXCEEDED: TB_INIT_STATUS = 4; +pub const TB_INIT_STATUS_TB_INIT_SYSTEM_RESOURCES: TB_INIT_STATUS = 5; +pub const TB_INIT_STATUS_TB_INIT_NETWORK_SUBSYSTEM: TB_INIT_STATUS = 6; + +pub type TB_CLIENT_STATUS = i32; +pub const TB_CLIENT_STATUS_TB_CLIENT_OK: TB_CLIENT_STATUS = 0; +pub const TB_CLIENT_STATUS_TB_CLIENT_INVALID: TB_CLIENT_STATUS = 1; + +pub type TB_REGISTER_LOG_CALLBACK_STATUS = i32; +pub const TB_REGISTER_LOG_CALLBACK_STATUS_TB_REGISTER_LOG_CALLBACK_SUCCESS: TB_REGISTER_LOG_CALLBACK_STATUS = 0; +pub const TB_REGISTER_LOG_CALLBACK_STATUS_TB_REGISTER_LOG_CALLBACK_ALREADY_REGISTERED: TB_REGISTER_LOG_CALLBACK_STATUS = 1; +pub const TB_REGISTER_LOG_CALLBACK_STATUS_TB_REGISTER_LOG_CALLBACK_NOT_REGISTERED: TB_REGISTER_LOG_CALLBACK_STATUS = 2; + +pub type TB_LOG_LEVEL = i32; +pub const TB_LOG_LEVEL_TB_LOG_ERR: TB_LOG_LEVEL = 0; +pub const TB_LOG_LEVEL_TB_LOG_WARN: TB_LOG_LEVEL = 1; +pub const TB_LOG_LEVEL_TB_LOG_INFO: TB_LOG_LEVEL = 2; +pub const TB_LOG_LEVEL_TB_LOG_DEBUG: TB_LOG_LEVEL = 3; + +extern "C" { + // Initialize a new TigerBeetle client which connects to the addresses provided and + // completes submitted packets by invoking the callback with the given context. + pub fn tb_client_init( + client_out: *mut tb_client_t, + // 128-bit unsigned integer represented as a 16-byte little-endian array. + cluster_id: *const [u8; 16], + address_ptr: *const ::std::os::raw::c_char, + address_len: u32, + completion_ctx: usize, + completion_callback: ::std::option::Option< + unsafe extern "C" fn( + arg1: usize, + arg3: *mut tb_packet_t, + arg4: u64, + arg5: *const u8, + arg6: u32, + ), + >, + ) -> TB_INIT_STATUS; + + // Initialize a new TigerBeetle client which echos back any data submitted. + pub fn tb_client_init_echo( + client_out: *mut tb_client_t, + // 128-bit unsigned integer represented as a 16-byte little-endian array. + cluster_id: *const [u8; 16], + address_ptr: *const ::std::os::raw::c_char, + address_len: u32, + completion_ctx: usize, + completion_callback: ::std::option::Option< + unsafe extern "C" fn( + arg1: usize, + arg3: *mut tb_packet_t, + arg4: u64, + arg5: *const u8, + arg6: u32, + ), + >, + ) -> TB_INIT_STATUS; + + // Retrieve the callback context initially passed into `tb_client_init` or + // `tb_client_init_echo`. + pub fn tb_client_completion_context( + client: *mut tb_client_t, + completion_ctx_out: *mut usize, + ) -> TB_CLIENT_STATUS; + + // Submit a packet with its operation, data, and data_size fields set. + // Once completed, `on_completion` will be invoked with `on_completion_ctx` and the given + // packet on the `tb_client` thread (separate from caller's thread). + pub fn tb_client_submit( + client: *mut tb_client_t, + packet: *mut tb_packet_t, + ) -> TB_CLIENT_STATUS; + + // Closes the client, causing any previously submitted packets to be completed with + // `TB_PACKET_CLIENT_SHUTDOWN` before freeing any allocated client resources from init. + // It is undefined behavior to use any functions on the client once deinit is called. + pub fn tb_client_deinit( + client: *mut tb_client_t, + ) -> TB_CLIENT_STATUS; + + // Registers or unregisters the application log callback. + pub fn register_log_callback( + callback: ::std::option::Option< + unsafe extern "C" fn( + TB_LOG_LEVEL, + *const u8, + u32, + ), + >, + debug: bool, + ) -> TB_REGISTER_LOG_CALLBACK_STATUS; +} \ No newline at end of file diff --git a/ocam/src/clients/rust/src/time_based_id.rs b/ocam/src/clients/rust/src/time_based_id.rs new file mode 100644 index 00000000..ad1f53c8 --- /dev/null +++ b/ocam/src/clients/rust/src/time_based_id.rs @@ -0,0 +1,314 @@ +use std::cmp::Ordering; +use std::sync::Mutex; +use std::time::SystemTime; + +/// Generate a TigerBeetle time-based identifier. +/// +/// This generates `u128` identifiers suitable for the `id` fields +/// of TigerBeetle `Account`s and `Transaction`s. +/// +/// [TigerBeetle time-based identifiers][tbid] include a timestamp +/// component and a random component, are lexicographically sortable, +/// monotonically increasing, and enable optimizations in TigerBeetle's LSM tree. +/// +/// [tbid]: https://docs.tigerbeetle.com/coding/data-modeling/#tigerbeetle-time-based-identifiers-recommended +/// +/// ## System clock warning +/// +/// Generating these IDs correctly depends on a well-behaved system clock. There +/// are two cases that will cause degenerate behavior: +/// +/// - The system clock changes into the past. IDs will be generated +/// sequentially from the previous ID until the clock catches up with the future timestamp. +/// +/// - The system time is prior to the Unix epoch. IDs will be generated +/// sequentially starting at the Unix epoch (plus a base random number). +// +// References: +// +// - https://github.com/tigerbeetle/tigerbeetle/blob/75f77b8b3280ce2f289cf42ae928945190fe4a2a/src/clients/node/src/index.ts#L161-L191 +// - https://github.com/ulid/spec +pub fn id() -> u128 { + let mut guard = GLOBAL_GENERATOR.lock().expect("global tbid generator"); + match *guard { + None => { + *guard = Some(TbidGenerator::new()); + drop(guard); + id() + } + Some(ref mut generator) => generator.next(), + } +} + +static GLOBAL_GENERATOR: Mutex> = Mutex::new(None); + +struct TbidGenerator { + ms_since_epoch: u128, + random: u128, // 80 bits +} + +impl TbidGenerator { + fn new() -> TbidGenerator { + let ms_since_epoch = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + + TbidGenerator { + ms_since_epoch, + random: random_u80(), + } + } + + fn next(&mut self) -> u128 { + self.next_from_system_time(SystemTime::now()) + } + + fn next_from_system_time(&mut self, now: SystemTime) -> u128 { + *self = self.next_state_from_system_time(now); + + // Pack `ms_since_epoch` and `random` into a `u128`. + // + // |----------| |----------------| + // Timestamp Randomness + // 48bits 80bits + + assert!(is_u80(self.random)); + + self.ms_since_epoch << 80 | self.random + } + + fn next_state_from_system_time(&self, now: SystemTime) -> TbidGenerator { + let previous_ms_since_epoch = self.ms_since_epoch; + + let next_ms_since_epoch = now + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + + match next_ms_since_epoch.cmp(&previous_ms_since_epoch) { + Ordering::Greater => { + // The new time is greater than previous. + // Use it and choose a new random number. + TbidGenerator { + ms_since_epoch: next_ms_since_epoch, + random: random_u80(), + } + } + Ordering::Equal | Ordering::Less => { + // The new time is equal or less than previous. + // Use the old time and increment the random number. + match self.next_random() { + Some(next_random) => TbidGenerator { + ms_since_epoch: previous_ms_since_epoch, + random: next_random, + }, + None => { + // Difficult case. + // + // `self.random` would overflow a `u80`. + // + // It _seems_ to be extremely unlikely: it requires many + // ids to be generated in a single millisecond, and also + // for the initial value of `self.random` to be large; + // though consider that the clock may be broken and a + // "millisecond" could last forever. + // + // The ULID spec says if the randomness overflows 80 + // bits then it is an error, but we instead manually + // carry the bit from the randomness to the timestamp, + // moving time forward by 1ms, and generate a new random + // number. Generated ids will be ahead of the wall + // clock by 1 ms until time catches up. + TbidGenerator { + // There is too much resolution in a u128 for this to ever overflow. + ms_since_epoch: previous_ms_since_epoch + .checked_add(1) + .expect("impossible overflow"), + random: random_u80(), + } + } + } + } + } + } + + fn next_random(&self) -> Option { + assert!(is_u80(self.random)); + if self.random == U80_MASK { + None + } else { + Some(self.random + 1) + } + } +} + +const U80_MASK: u128 = 0x_FFFF_FFFF_FFFF_FFFF_FFFF; + +fn is_u80(val: u128) -> bool { + val <= U80_MASK +} + +fn random_u80() -> u128 { + random_u128() & U80_MASK +} + +fn random_u128() -> u128 { + let a = random_u64(); + let b = random_u64(); + let a = a as u128; + let b = b as u128; + a | (b << 64) +} + +fn random_u64() -> u64 { + std::hash::Hasher::finish(&std::hash::BuildHasher::build_hasher( + &std::collections::hash_map::RandomState::new(), + )) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant, SystemTime}; + + use super::{id, TbidGenerator, U80_MASK}; + + struct TestResult { + id_start: u128, + id_end: u128, + } + + fn run_test_for_a_few_ms(mut id_generator: impl FnMut() -> u128) -> TestResult { + // Enough time to transition between millisecond timestamps + let test_duration = Duration::from_millis(10); + let end_instant = Instant::now() + test_duration; + + let mut id_prev = 0; + let mut id_start = None; + let mut id_end = None; + + while Instant::now() < end_instant { + let id_next = id_generator(); + + assert!(id_prev < id_next, "ids not monotonically increasing"); + + // Regression test - should not reset random to 0 on overflow, + // but instead generate a new random number. + let id_next_random_part = id_next & U80_MASK; + assert_ne!(id_next_random_part, 0); + + // Don't waste other processes' time + std::thread::yield_now(); + + id_start = id_start.or(Some(id_next)); + id_end = Some(id_next); + + id_prev = id_next + } + + let id_start = id_start.unwrap(); + let id_end = id_end.unwrap(); + + TestResult { id_start, id_end } + } + + fn assert_ids_monotonic_and_multiple_timestamps(id_generator: impl FnMut() -> u128) { + let TestResult { id_start, id_end } = run_test_for_a_few_ms(id_generator); + // Verify we transitioned between timestamps + let timestamp_start = id_start & !U80_MASK; + let timestamp_end = id_end & !U80_MASK; + assert_ne!(timestamp_start, timestamp_end); + } + + fn assert_ids_monotonic_and_single_timestamp(id_generator: impl FnMut() -> u128) { + let TestResult { id_start, id_end } = run_test_for_a_few_ms(id_generator); + // Verify we transitioned between timestamps + let timestamp_start = id_start & !U80_MASK; + let timestamp_end = id_end & !U80_MASK; + assert_eq!(timestamp_start, timestamp_end); + } + + #[test] + fn test_ids_with_normal_clock() { + assert_ids_monotonic_and_multiple_timestamps(|| id()); + } + + #[test] + fn test_random_overflow_still_monotonic() { + // Put this test in the future so the ms don't increment + let future_duration_from_now = Duration::from_secs(1_000_000); + let future_time = SystemTime::now() + .checked_add(future_duration_from_now) + .unwrap(); + let future_duration_since_epoch = future_time + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_millis(); + let mut idgen = TbidGenerator { + ms_since_epoch: future_duration_since_epoch, + random: U80_MASK - 10, + }; + + assert_ids_monotonic_and_multiple_timestamps(|| idgen.next()); + } + + #[test] + fn test_reverse_time() { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_millis(); + let mut idgen = TbidGenerator { + ms_since_epoch: now, + random: 0, + }; + + let mut count = 0; + + assert_ids_monotonic_and_single_timestamp(|| { + let past_time_base = SystemTime::UNIX_EPOCH - Duration::from_secs(1_000_000); + let past_time = past_time_base + Duration::from_millis(count); + count += 1; + idgen.next_from_system_time(past_time) + }); + } + + #[test] + fn test_reverse_time_then_catch_up() { + let now = SystemTime::now(); + let now_ms = now + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_millis(); + let mut idgen = TbidGenerator { + ms_since_epoch: now_ms, + random: 0, + }; + + let mut count = 0; + + assert_ids_monotonic_and_multiple_timestamps(|| { + let past_time_base = now - Duration::from_millis(2); + let past_time = past_time_base + Duration::from_millis(count); + count += 1; + idgen.next_from_system_time(past_time) + }); + } + + #[test] + fn test_prior_to_unix_epoch() { + let mut idgen = TbidGenerator { + ms_since_epoch: 0, + random: 0, + }; + + let mut count = 0; + + assert_ids_monotonic_and_single_timestamp(|| { + let past_time_base = SystemTime::UNIX_EPOCH - Duration::from_secs(1_000_000); + let past_time = past_time_base + Duration::from_millis(count); + count += 1; + idgen.next_from_system_time(past_time) + }); + } +} diff --git a/ocam/src/clients/rust/tests/status_codes_and_flags.rs b/ocam/src/clients/rust/tests/status_codes_and_flags.rs new file mode 100644 index 00000000..cf66068d --- /dev/null +++ b/ocam/src/clients/rust/tests/status_codes_and_flags.rs @@ -0,0 +1,213 @@ +// We are going to try to test the Rust redefinitions of TigerBeetle status +// codes and bitflags with a round-trip between the C status codes and the Rust +// representations. This will involve parsing the defs out of the tb_client.h +// header. +// +// We want to do this because the addition of new status codes will not trigger +// compilation failures, and may lead to silent bugs in application code. +// +// It would be much preferable to do this once for all clients, but I don't +// forsee Vortex being able to do that in the near future, and it's not clear +// exactly how it would do that anyway. But at least we can do it for the Rust +// client. Rust client is best client! + +use tigerbeetle as tb; + +static TB_CLIENT_H: &str = include_str!("../assets/tb_client.h"); + +/// Parse the enum values out of a C source. +fn parse_c_enum_values(name: &str) -> Vec { + let enum_body_lines = find_enum_body_lines(TB_CLIENT_H, name); + + if enum_body_lines.is_empty() { + panic!("enum {name} has no parsable body"); + } + + let mut enum_values = Vec::new(); + + for line in enum_body_lines { + enum_values.push(parse_enum_value(line)); + } + + enum_values +} + +fn find_enum_body_lines<'t>(text: &'t str, name: &str) -> Vec<&'t str> { + let start_line = format!("typedef enum {name} {{"); + let end_line = format!("}} {name};"); + + let lines = text + .lines() + .skip_while(|line| line != &start_line) + .skip(1) + .take_while(|line| line != &end_line); + + lines.collect() +} + +fn parse_enum_value(text: &str) -> u32 { + let after_equals = text.split("=").skip(1).next(); + let after_equals = after_equals.expect(&format!("missing '=' in enum variant text '{text}'")); + + assert!(after_equals.ends_with(",")); + let expr = &after_equals[..after_equals.len() - 1]; + let expr = expr.trim(); + + // At this point expr should either be an uint or "uint << uint" for flags. + if !expr.contains("<<") { + match parse_dec_or_hex(expr) { + Ok(val) => val, + Err(_) => { + panic!("enum text '{text}' didn't parse as u32"); + } + } + } else { + let mut split = expr.split("<<"); + let arg1 = split + .next() + .expect(&format!("missing bitshift arg1 in '{text}'")); + let arg2 = split + .next() + .expect(&format!("missing bitshift arg2 in '{text}'")); + let arg1: u32 = arg1 + .trim() + .parse() + .expect(&format!("enum arg1 in '{text}' didn't parse as u32")); + let arg2: u32 = arg2 + .trim() + .parse() + .expect(&format!("enum arg1 in '{text}' didn't parse as u32")); + arg1 << arg2 + } +} + +fn parse_dec_or_hex(s: &str) -> Result { + if let Some(hex) = s.strip_prefix("0x") { + u32::from_str_radix(hex, 16) + } else { + s.parse() + } +} + +#[test] +fn does_our_c_enum_parser_even_work() { + let tb_log_level_values_expected = [0, 1, 2, 3]; + let tb_log_level_values_actual = parse_c_enum_values("TB_LOG_LEVEL"); + + for val in tb_log_level_values_expected.iter() { + assert!(tb_log_level_values_actual.contains(val)); + } + + for val in tb_log_level_values_actual.iter() { + assert!(tb_log_level_values_expected.contains(val)); + } +} + +fn round_trip_test( + c_enum_name: &str, + ignore_list: &[CType], + rust_from_c: impl Fn(CType) -> RustType, + c_from_rust: impl Fn(RustType) -> CType, +) where + RustType: std::fmt::Debug, + CType: std::fmt::Debug + Eq + Copy, + CType: TryFrom, + >::Error: std::fmt::Debug, +{ + let c_values = parse_c_enum_values(c_enum_name); + assert!(!c_values.is_empty()); + for c_value_u32 in c_values { + let c_value_original = CType::try_from(c_value_u32) + .expect(&format!("unexpected value {c_value_u32} for enum")); + if ignore_list.contains(&c_value_original) { + continue; + } + let rust_value = rust_from_c(c_value_original); + let c_value_new = c_from_rust(rust_value); + assert_eq!(c_value_original, c_value_new); + } +} + +#[test] +fn round_trip_create_account_result() { + round_trip_test::( + "TB_CREATE_ACCOUNT_STATUS", + &[], + |c_value| tb::CreateAccountStatus::from(c_value), + |rust_value| u32::from(rust_value), + ); +} + +#[test] +fn round_trip_create_transfer_result() { + round_trip_test::( + "TB_CREATE_TRANSFER_STATUS", + &[], + |c_value| tb::CreateTransferStatus::from(c_value), + |rust_value| u32::from(rust_value), + ); +} + +#[test] +fn round_trip_init_status() { + round_trip_test::( + "TB_INIT_STATUS", + // Success not represented in tb::InitStatus + &[0], + |c_value| tb::InitStatus::from(c_value), + |rust_value| i32::from(rust_value), + ); +} + +#[test] +fn round_trip_packet_status() { + round_trip_test::( + "TB_PACKET_STATUS", + // Success not represented in tb::PacketStatus + &[0], + |c_value| tb::PacketStatus::from(c_value), + |rust_value| u8::from(rust_value), + ); +} + +#[test] +fn round_trip_account_flags() { + round_trip_test::( + "TB_ACCOUNT_FLAGS", + &[], + // We use from_bits_truncate here to discard unknown flags. + // This will fail a round-trip test if we see one. + |c_value| tb::AccountFlags(c_value), + |rust_value| rust_value.0, + ); +} + +#[test] +fn round_trip_transfer_flags() { + round_trip_test::( + "TB_TRANSFER_FLAGS", + &[], + |c_value| tb::TransferFlags(c_value), + |rust_value| rust_value.0, + ); +} + +#[test] +fn round_trip_account_filter_flags() { + round_trip_test::( + "TB_ACCOUNT_FILTER_FLAGS", + &[], + |c_value| tb::AccountFilterFlags(c_value), + |rust_value| rust_value.0, + ); +} + +#[test] +fn round_trip_query_filter_flags() { + round_trip_test::( + "TB_QUERY_FILTER_FLAGS", + &[], + |c_value| tb::QueryFilterFlags(c_value), + |rust_value| rust_value.0, + ); +} diff --git a/ocam/src/clients/rust/tests/tests.rs b/ocam/src/clients/rust/tests/tests.rs new file mode 100644 index 00000000..b92e0d7c --- /dev/null +++ b/ocam/src/clients/rust/tests/tests.rs @@ -0,0 +1,1432 @@ +use std::cell::UnsafeCell; +use std::env; +use std::env::consts::EXE_SUFFIX; +use std::io::{BufRead as _, BufReader}; +use std::mem; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Barrier, Once, RwLock}; + +use futures::executor::block_on; +use futures::pin_mut; +use futures::{Stream, StreamExt}; + +use tigerbeetle as tb; + +type Result = std::result::Result>; + +// Singleton test database. +// This can be a OnceLock in Rust 1.70+, and LazyLock in 1.80. +fn get_test_db() -> &'static TestDb { + struct OnceLock { + once: Once, + value: UnsafeCell>, + } + + unsafe impl Sync for OnceLock {} + + static TEST_DB: OnceLock = OnceLock { + once: Once::new(), + value: UnsafeCell::new(None), + }; + + let error_msg = "couldn't start test database"; + + unsafe { + TEST_DB.once.call_once(|| { + *(&mut *TEST_DB.value.get()) = Some(TestDb::new().expect(error_msg)); + }); + + (&*TEST_DB.value.get()).as_ref().expect(error_msg) + } +} + +struct TestDb { + port: u16, + // Keep the server's stdin handle open as long as the test process is running, + // at which point the server will terminate. + _server: Child, +} + +fn tigerbeetle_bin() -> String { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + format!("{manifest_dir}/../../../tigerbeetle{EXE_SUFFIX}") +} + +fn work_dir() -> &'static str { + env!("CARGO_TARGET_TMPDIR") +} + +impl TestDb { + fn new() -> Result { + // NB: There is one test database shared between all tests, and reused + // between test runs. If the tests choose their IDs correctly there + // should never be any collisions, and that one database should work + // forever, just taking up a lot of space. + let database_name = "0_0.testdb.tigerbeetle"; + + if !Path::new(&format!("{}/{database_name}", work_dir())).try_exists()? { + let status = Command::new(tigerbeetle_bin()) + .current_dir(work_dir()) + .args([ + "format", + "--replica-count=1", + "--replica=0", + "--cluster=0", + database_name, + ]) + .status()?; + assert!(status.success()); + } + + let server = Self::start(&["--addresses=0", "--cache-grid=128MiB", database_name])?; + + Ok(server) + } + + /// Create a unique development-mode server for a specific test. + fn new_development(label: &str) -> Result { + let database_name = format!("0_0.{label}.tigerbeetle"); + + // Always start fresh for development instances. + let _ = std::fs::remove_file(format!("{}/{database_name}", work_dir())); + + let status = Command::new(tigerbeetle_bin()) + .current_dir(work_dir()) + .args([ + "format", + "--replica-count=1", + "--replica=0", + "--cluster=0", + "--development", + &database_name, + ]) + .status()?; + assert!(status.success()); + + let server = Self::start(&["--addresses=0", "--development", &database_name])?; + + Ok(server) + } + + fn start(args: &[&str]) -> Result { + let mut server = Command::new(tigerbeetle_bin()) + .current_dir(work_dir()) + // magic address 0: tell us the port to use, + // shutdown when stdin closes + .args(["start"]) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + let server_stdout = mem::take(&mut server.stdout).unwrap(); + let mut server_stdout = BufReader::new(server_stdout); + let mut first_line = String::new(); + server_stdout.read_line(&mut first_line)?; + let port = first_line.trim().parse()?; + + Ok(TestDb { + port, + _server: server, + }) + } + + fn address(&self) -> String { + format!("127.0.0.1:{}", self.port) + } +} + +// Only one database server should run at a time. Normal tests share a read +// lock; the eviction test takes a write lock so it runs exclusively. +static DB_LOCK: RwLock<()> = RwLock::new(()); + +/// Returns the client and a read guard that must be held for the test's +/// duration. The guard prevents the eviction test from running concurrently. +fn test_client() -> Result<(tb::Client, std::sync::RwLockReadGuard<'static, ()>)> { + let guard = DB_LOCK.read().unwrap(); + let client = tb::Client::new(0, &get_test_db().address())?; + Ok((client, guard)) +} + +fn assert_send(t: T) -> T { + t +} + +const TEST_LEDGER: u32 = 10; +const TEST_CODE: u16 = 20; + +#[test] +fn smoke() -> Result<()> { + let account_id1 = tb::id(); + let account_id2 = tb::id(); + let transfer_id1 = tb::id(); + + let account_id2_user_data_128 = tb::id(); + let transfer_id1_user_data_128 = tb::id(); + + block_on(async { + let (client, _guard) = test_client()?; + + { + let fut = client.create_accounts(&[ + tb::Account { + id: account_id1, + debits_pending: 0, + debits_posted: 0, + credits_pending: 0, + credits_posted: 0, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + reserved: tb::Reserved::default(), + ledger: TEST_LEDGER, + code: TEST_CODE, + flags: tb::AccountFlags::History, + timestamp: 0, + }, + tb::Account { + id: account_id2, + debits_pending: 0, + debits_posted: 0, + credits_pending: 0, + credits_posted: 0, + user_data_128: account_id2_user_data_128, + user_data_64: 0, + user_data_32: 0, + reserved: tb::Reserved::default(), + ledger: TEST_LEDGER, + code: TEST_CODE, + flags: tb::AccountFlags::History, + timestamp: 0, + }, + ])?; + let results = assert_send(fut).await?; + + assert_eq!(results.len(), 2); + assert!(results.iter().all(|result| { + result.timestamp > 0 && result.status == tb::CreateAccountStatus::Created + })); + } + + { + let results = client + .create_transfers(&[tb::Transfer { + id: transfer_id1, + debit_account_id: account_id1, + credit_account_id: account_id2, + amount: 10, + pending_id: 0, + user_data_128: transfer_id1_user_data_128, + user_data_64: 0, + user_data_32: 0, + timeout: 0, + ledger: TEST_LEDGER, + code: TEST_CODE, + flags: tb::TransferFlags::default(), + timestamp: 0, + }])? + .await?; + + assert_eq!(results.len(), 1); + assert!(results.iter().all(|result| { + result.timestamp > 0 && result.status == tb::CreateTransferStatus::Created + })); + } + + { + let results = client.lookup_accounts(&[account_id1, account_id2])?.await?; + + assert_eq!(results.len(), 2); + let res_account1 = results[0]; + let res_account2 = results[1]; + + assert_eq!(res_account1.id, account_id1); + assert_eq!(res_account1.debits_posted, 10); + assert_eq!(res_account1.credits_posted, 0); + assert_eq!(res_account2.id, account_id2); + assert_eq!(res_account2.debits_posted, 0); + assert_eq!(res_account2.credits_posted, 10); + } + + { + let results = client.lookup_transfers(&[transfer_id1])?.await?; + + assert_eq!(results.len(), 1); + let res_transfer1 = results[0]; + + assert_eq!(res_transfer1.id, transfer_id1); + assert_eq!(res_transfer1.debit_account_id, account_id1); + assert_eq!(res_transfer1.credit_account_id, account_id2); + assert_eq!(res_transfer1.amount, 10); + } + + { + let results = client + .get_account_transfers(tb::AccountFilter { + account_id: account_id1, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + code: TEST_CODE, + reserved: tb::Reserved::default(), + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: tb::AccountFilterFlags::Credits | tb::AccountFilterFlags::Debits, + })? + .await?; + + assert_eq!(results.len(), 1); + + let res_transfer = &results[0]; + + assert_eq!(res_transfer.id, transfer_id1); + assert_eq!(res_transfer.debit_account_id, account_id1); + assert_eq!(res_transfer.credit_account_id, account_id2); + assert_eq!(res_transfer.amount, 10); + } + + { + let results = client + .get_account_balances(tb::AccountFilter { + account_id: account_id1, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + code: TEST_CODE, + reserved: tb::Reserved::default(), + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: tb::AccountFilterFlags::Credits | tb::AccountFilterFlags::Debits, + })? + .await?; + + assert_eq!(results.len(), 1); + + let res_balance_1 = &results[0]; + + assert_eq!(res_balance_1.debits_posted, 10); + assert_eq!(res_balance_1.credits_posted, 0); + } + + { + let results = client + .query_accounts(tb::QueryFilter { + user_data_128: account_id2_user_data_128, + user_data_64: 0, + user_data_32: 0, + ledger: TEST_LEDGER, + code: TEST_CODE, + reserved: tb::Reserved::default(), + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: tb::QueryFilterFlags::default(), + })? + .await?; + + assert_eq!(results.len(), 1); + + let res_account = &results[0]; + + assert_eq!(res_account.id, account_id2); + } + + { + let results = client + .query_transfers(tb::QueryFilter { + user_data_128: transfer_id1_user_data_128, + user_data_64: 0, + user_data_32: 0, + ledger: TEST_LEDGER, + code: TEST_CODE, + reserved: tb::Reserved::default(), + timestamp_min: 0, + timestamp_max: 0, + limit: 10, + flags: tb::QueryFilterFlags::default(), + })? + .await?; + + assert_eq!(results.len(), 1); + + let res_transfer = &results[0]; + + assert_eq!(res_transfer.id, transfer_id1); + } + + Ok(()) + }) +} + +#[test] +fn ctor_fail() -> Result<()> { + let client = tb::Client::new(0, "hey"); + + assert!(matches!(client, Err(tb::InitStatus::AddressInvalid))); + + Ok(()) +} + +#[test] +fn dtor() -> Result<()> { + let (client, _guard) = test_client()?; + + block_on(async { + // Let's at least talk to the server before dropping + let _ = client.create_accounts(&[])?.await?; + drop(client); + Ok(()) + }) +} + +#[test] +fn close() -> Result<()> { + let (client, _guard) = test_client()?; + + block_on(async { + let _ = client.create_accounts(&[])?.await?; + client.close().await?; + Ok(()) + }) +} + +// Send a request and immediately drop the client. +// Should still clean up correctly. +#[test] +fn dtor_no_wait() -> Result<()> { + let (client, _guard) = test_client()?; + + block_on(async { + let _ = client.create_accounts(&[])?; + drop(client); + Ok(()) + }) +} + +#[test] +fn close_no_wait() -> Result<()> { + let (client, _guard) = test_client()?; + + block_on(async { + let _ = client.create_accounts(&[])?; + let _ = client.close(); + Ok(()) + }) +} + +#[test] +fn client_drop_before_future_awaited() -> Result<()> { + let future = { + let (client, _guard) = test_client()?; + + let account = tb::Account { + id: tb::id(), + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }; + + let future = client.create_accounts(&[account]).unwrap(); + drop(client); + future + }; + + let result = block_on(async { future.await }); + + match result { + Ok(_) => {} + Err(tb::PacketStatus::ClientShutdown) => {} + Err(_) => panic!(), + } + + Ok(()) +} + +#[test] +fn client_drop_causes_shutdown_status() -> Result<()> { + let futures = { + let (client, _guard) = test_client()?; + + let mut futures = Vec::new(); + for _ in 0..10 { + let account = tb::Account { + id: tb::id(), + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }; + futures.push(client.create_accounts(&[account]).unwrap()); + } + + drop(client); + futures + }; + + let mut shutdown_count = 0; + + for future in futures { + match block_on(async { future.await }) { + Ok(_) => {} + Err(tb::PacketStatus::ClientShutdown) => shutdown_count += 1, + Err(_) => panic!(), + } + } + + assert!(shutdown_count > 0); + + Ok(()) +} + +#[test] +fn too_many_events() -> Result<()> { + let (client, _guard) = test_client()?; + + block_on(async { + let accounts = lots_of_accounts(); + let result = client.create_accounts(&accounts)?.await; + + assert_eq!(result, Err(tb::PacketStatus::TooMuchData)); + + Ok(()) + }) +} + +fn lots_of_accounts() -> Vec { + let mut accounts = vec![]; + let num_accounts = 10_000; + for _ in 0..num_accounts { + let account = tb::Account { + id: tb::id(), + debits_pending: 0, + debits_posted: 0, + credits_pending: 0, + credits_posted: 0, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + reserved: tb::Reserved::default(), + ledger: TEST_LEDGER, + code: TEST_CODE, + flags: tb::AccountFlags::History, + timestamp: 0, + }; + accounts.push(account); + } + return accounts; +} + +#[test] +fn zero_events_create_accounts() -> Result<()> { + let (client, _guard) = test_client()?; + + block_on(async { + let result = client.create_accounts(&[])?.await?; + + assert!(result.is_empty()); + + Ok(()) + }) +} + +#[test] +fn zero_events_create_transfers() -> Result<()> { + let (client, _guard) = test_client()?; + + block_on(async { + let result = client.create_transfers(&[])?.await?; + + assert!(result.is_empty()); + + Ok(()) + }) +} + +#[test] +fn zero_events_lookup_accounts() -> Result<()> { + let (client, _guard) = test_client()?; + + block_on(async { + let result = client.lookup_accounts(&[])?.await?; + + assert!(result.is_empty()); + + Ok(()) + }) +} + +#[test] +fn zero_events_lookup_transfers() -> Result<()> { + let (client, _guard) = test_client()?; + + block_on(async { + let result = client.lookup_transfers(&[])?.await?; + + assert!(result.is_empty()); + + Ok(()) + }) +} + +#[test] +fn multithread() -> Result<()> { + let (client, _guard) = test_client()?; + let client = Arc::new(client); + + let num_threads = 16; + let num_requests = 1_000; + + let barrier = Arc::new(Barrier::new(num_threads)); + + let join_handles = std::iter::repeat(()).take(num_threads).map(|_| { + let client = client.clone(); + let barrier = barrier.clone(); + std::thread::spawn( + move || -> std::result::Result<(), Box> { + barrier.wait(); + block_on(async { + for _ in 0..num_requests { + let results = client + .create_accounts(&[tb::Account { + id: tb::id(), + debits_pending: 0, + debits_posted: 0, + credits_pending: 0, + credits_posted: 0, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + reserved: tb::Reserved::default(), + ledger: TEST_LEDGER, + code: TEST_CODE, + flags: tb::AccountFlags::History, + timestamp: 0, + }])? + .await?; + + assert_eq!(results.len(), 1); + assert!(results.iter().all(|result| { + result.timestamp > 0 + && result.status == tb::CreateAccountStatus::Created + })); + } + + Ok(()) + }) + }, + ) + }); + + // collect the handles to evaluate the thread::spawns + let join_handles = join_handles.collect::>(); + + for join_handle in join_handles { + let res = join_handle.join().expect("no panic"); + assert!(!res.is_err()); + } + + block_on(async { + let client = Arc::try_unwrap(client).expect("arc"); + + client.close().await?; + + Ok(()) + }) +} + +#[test] +fn concurrent_requests() -> Result<()> { + let (client, _guard) = test_client()?; + + let mut responses = Vec::new(); + + for _ in 0..10 { + let response = client + .create_accounts(&[tb::Account { + id: tb::id(), + debits_pending: 0, + debits_posted: 0, + credits_pending: 0, + credits_posted: 0, + user_data_128: 0, + user_data_64: 0, + user_data_32: 0, + reserved: tb::Reserved::default(), + ledger: TEST_LEDGER, + code: TEST_CODE, + flags: tb::AccountFlags::History, + timestamp: 0, + }]) + .unwrap(); + responses.push(response); + } + + for response in responses { + let results = block_on(async { response.await })?; + assert_eq!(results.len(), 1); + assert!(results.iter().all(|result| { + result.timestamp > 0 && result.status == tb::CreateAccountStatus::Created + })); + } + + Ok(()) +} + +// A potentially suprising behavior, documented in the crate docs. +#[test] +fn client_drop_loses_pending_transactions() -> Result<()> { + let mut ids = Vec::new(); + + // Queue up lots of transactions, drop their futures, drop the client. + { + let (client, _guard) = test_client()?; + + // Timing-sensitive - trying to create enough pending transactions that + // not all will be completed. I think test is unlikely to fail because + // of timing problems since it takes quite some time to process a + // transaction. Locally setting this to 1 still fails. + let transaction_count = 100_000; + for _ in 0..transaction_count { + let id = tb::id(); + let _ = client + .create_accounts(&[tb::Account { + id, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }]) + .unwrap(); + ids.push(id); + } + } + + // Some of those transactions will have been dropped by tb_client. + let (client, _guard) = test_client()?; + + // Reverse because later transactions most likely to be lost. + ids.reverse(); + + for next_ids in ids.chunks(8189) { + let results = block_on(client.lookup_accounts(next_ids)?)?; + if results.len() < next_ids.len() { + // This is what we expect. + return Ok(()); + } + } + + panic!("unexpected all transactions succeeded"); +} + +/// Query multiple transfers for a single account, with paging. +/// +/// This handles the case where there are too many results to fit into +/// a single batch, by making multiple sequential queries, incrementing +/// the timestamp range (or decrementing for reverse queries). +/// +/// The [`AccountFilter`]'s `limit` field should be set to greater than 1 +/// to set the page size. `limit` must be less than or equal to the build-time +/// configuration of the TigerBeetle server's batch size (default 8189). +/// +/// To perform a reverse query set [`AccountFilterFlag::Reversed`]. +// +// NB: This is a runnable version of an example in the crate docs. +// Try to keep them in sync. +fn get_account_transfers_paged( + client: &tb::Client, + event: tb::AccountFilter, +) -> impl Stream, tb::PacketStatus>> + '_ { + assert!( + event.limit > 1, + "paged queries should use an explicit limit" + ); + + enum State { + Start, + Continue(u64), + End, + } + + let is_reverse = (event.flags.0 & tb::AccountFilterFlags::Reversed.0) != 0; + + futures::stream::unfold(State::Start, move |state| async move { + let event = match state { + State::Start => event, + State::Continue(timestamp_begin) => { + if !is_reverse { + tb::AccountFilter { + timestamp_min: timestamp_begin, + ..event + } + } else { + tb::AccountFilter { + timestamp_max: timestamp_begin, + ..event + } + } + } + State::End => return None, + }; + let result_next = client + .get_account_transfers(event) + .expect("client closed") + .await; + match result_next { + Ok(result_next) => { + let result_len = u32::try_from(result_next.len()).expect("u32"); + let must_page = result_len == event.limit; + if must_page { + let timestamp_first = result_next.first().expect("item").timestamp; + let timestamp_last = result_next.last().expect("item").timestamp; + let (timestamp_begin_next, should_continue) = if !is_reverse { + assert!(timestamp_first < timestamp_last); + let timestamp_begin_next = timestamp_last.checked_add(1).expect("overflow"); + assert_ne!(timestamp_begin_next, u64::MAX); + let should_continue = + timestamp_begin_next <= event.timestamp_max || event.timestamp_max == 0; + (timestamp_begin_next, should_continue) + } else { + assert!(timestamp_first > timestamp_last); + let timestamp_begin_next = timestamp_last.checked_sub(1).expect("overflow"); + assert_ne!(timestamp_begin_next, 0); + let should_continue = + timestamp_begin_next >= event.timestamp_min || event.timestamp_min == 0; + (timestamp_begin_next, should_continue) + }; + if should_continue { + Some((Ok(result_next), State::Continue(timestamp_begin_next))) + } else { + Some((Ok(result_next), State::End)) + } + } else { + Some((Ok(result_next), State::End)) + } + } + Err(result_next) => Some((Err(result_next), State::End)), + } + }) +} + +struct PagingTestParams { + account_id1: u128, + #[allow(unused)] + account_id2: u128, + transfer_count: usize, +} + +fn make_paging_test_transfers(client: &tb::Client) -> Result { + let batch_size: usize = 1234; + let transfer_count: usize = 5678; + let account_id1 = tb::id(); + let account_id2 = tb::id(); + + let account1 = tb::Account { + id: account_id1, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }; + let account2 = tb::Account { + id: account_id2, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }; + + let transfers: Vec<_> = std::iter::from_fn(|| { + Some(tb::Transfer { + id: tb::id(), + debit_account_id: account_id1, + credit_account_id: account_id2, + amount: 100, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }) + }) + .take(transfer_count) + .collect(); + + block_on(async { + let account_results = client.create_accounts(&[account1, account2])?.await?; + assert_eq!(account_results.len(), 2); + assert!(account_results.iter().all(|result| { + result.timestamp > 0 && result.status == tb::CreateAccountStatus::Created + })); + + for transfers in transfers.chunks(batch_size) { + let transfer_results = client.create_transfers(transfers)?.await?; + assert_eq!(transfer_results.len(), transfers.len()); + assert!(transfer_results.iter().all(|result| { + result.timestamp > 0 && result.status == tb::CreateTransferStatus::Created + })); + } + + Ok(PagingTestParams { + account_id1, + account_id2, + transfer_count, + }) + }) +} + +#[test] +fn paging_forward() -> Result<()> { + let (client, _guard) = test_client()?; + let test_params = make_paging_test_transfers(&client)?; + + let query_results = get_account_transfers_paged( + &client, + tb::AccountFilter { + account_id: test_params.account_id1, + limit: 1000, + flags: tb::AccountFilterFlags::Debits, + ..Default::default() + }, + ); + + pin_mut!(query_results); + + let mut batches = 0; + let mut transfer_count = 0; + + while let Some(query_results) = block_on(query_results.next()) { + let query_results = query_results?; + batches += 1; + transfer_count += query_results.len(); + } + + assert!(batches > 1); + assert_eq!(transfer_count, test_params.transfer_count); + + Ok(()) +} + +#[test] +fn paging_reverse() -> Result<()> { + let (client, _guard) = test_client()?; + let test_params = make_paging_test_transfers(&client)?; + + let query_results = get_account_transfers_paged( + &client, + tb::AccountFilter { + account_id: test_params.account_id1, + limit: 1000, + flags: tb::AccountFilterFlags::Debits | tb::AccountFilterFlags::Reversed, + ..Default::default() + }, + ); + + pin_mut!(query_results); + + let mut batches = 0; + let mut transfer_count = 0; + + while let Some(query_results) = block_on(query_results.next()) { + let query_results = query_results?; + batches += 1; + transfer_count += query_results.len(); + } + + assert!(batches > 1); + assert_eq!(transfer_count, test_params.transfer_count); + + Ok(()) +} + +// NB: This is a runnable version of an example in the `create_accounts` docs. +// Try to keep them in sync. +#[test] +fn example_create_accounts() -> std::result::Result<(), Box> { + async fn make_create_accounts_request( + client: &tb::Client, + accounts: &[tb::Account], + ) -> std::result::Result<(), Box> { + let account_results = client.create_accounts(accounts)?.await?; + assert_eq!(accounts.len(), account_results.len()); + let it = accounts + .iter() + .enumerate() + .map(move |(i, account)| (account, account_results[i])); + + for (account, account_result) in it { + match account_result.status { + tb::CreateAccountStatus::Created | tb::CreateAccountStatus::Exists => { + handle_create_account_success(account, account_result).await?; + } + _ => { + handle_create_account_failure(account, account_result).await?; + } + } + } + Ok(()) + } + + async fn handle_create_account_success( + _account: &tb::Account, + _result: tb::CreateAccountResult, + ) -> std::result::Result<(), Box> { + Ok(()) + } + + async fn handle_create_account_failure( + _account: &tb::Account, + _result: tb::CreateAccountResult, + ) -> std::result::Result<(), Box> { + Ok(()) + } + + block_on(async { + let gen_accounts = || { + let duplicate_id = tb::id(); + [ + tb::Account { + id: duplicate_id, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }, + tb::Account { + id: duplicate_id, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }, + tb::Account { + id: tb::id(), + ledger: TEST_LEDGER, + code: 0, + ..Default::default() + }, + ] + }; + let results_expected = &[ + tb::CreateAccountStatus::Created, + tb::CreateAccountStatus::Exists, + tb::CreateAccountStatus::CodeMustNotBeZero, + ]; + + let (client, _guard) = test_client()?; + + // Test the example. + make_create_accounts_request(&client, &gen_accounts()).await?; + + // Also test that the results are what we expect. + let accounts = gen_accounts(); + let results = client.create_accounts(&accounts)?.await?; + assert_eq!(accounts.len(), results.len()); + let results_actual: Vec = + results.iter().map(|result| result.status).collect(); + assert_eq!(results_expected, &results_actual[..]); + + Ok(()) + }) +} + +// NB: This is a runnable version of an example in the `create_transfers` docs. +// Try to keep them in sync. +#[test] +fn example_create_transfers() -> std::result::Result<(), Box> { + async fn make_create_transfers_request( + client: &tb::Client, + transfers: &[tb::Transfer], + ) -> std::result::Result<(), Box> { + let transfer_results = client.create_transfers(transfers)?.await?; + let it = transfers + .iter() + .enumerate() + .map(move |(i, transfer)| (transfer, transfer_results[i])); + for (transfer, transfer_result) in it { + match transfer_result.status { + tb::CreateTransferStatus::Created | tb::CreateTransferStatus::Exists => { + handle_create_transfer_success(transfer, transfer_result).await?; + } + _ => { + handle_create_transfer_failure(transfer, transfer_result).await?; + } + } + } + Ok(()) + } + + async fn handle_create_transfer_success( + _transfer: &tb::Transfer, + _result: tb::CreateTransferResult, + ) -> std::result::Result<(), Box> { + Ok(()) + } + + async fn handle_create_transfer_failure( + _transfer: &tb::Transfer, + _result: tb::CreateTransferResult, + ) -> std::result::Result<(), Box> { + Ok(()) + } + + block_on(async { + let account_id1 = tb::id(); + let account_id2 = tb::id(); + let (client, _guard) = test_client()?; + + let accounts = [ + tb::Account { + id: account_id1, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }, + tb::Account { + id: account_id2, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }, + ]; + client.create_accounts(&accounts)?.await?; + + let gen_transfers = || { + let duplicate_id = tb::id(); + [ + tb::Transfer { + id: duplicate_id, + debit_account_id: account_id1, + credit_account_id: account_id2, + amount: 100, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }, + tb::Transfer { + id: duplicate_id, + debit_account_id: account_id1, + credit_account_id: account_id2, + amount: 100, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }, + tb::Transfer { + id: tb::id(), + debit_account_id: account_id1, + credit_account_id: account_id2, + amount: 100, + ledger: TEST_LEDGER, + code: 0, + ..Default::default() + }, + ] + }; + + let results_expected = &[ + tb::CreateTransferStatus::Created, + tb::CreateTransferStatus::Exists, + tb::CreateTransferStatus::CodeMustNotBeZero, + ]; + + // Test the example. + make_create_transfers_request(&client, &gen_transfers()).await?; + + // Also test that the results are what we expect. + let transfers = gen_transfers(); + let results = client.create_transfers(&transfers)?.await?; + assert_eq!(transfers.len(), results.len()); + let results_actual: Vec = + results.iter().map(|result| result.status).collect(); + assert_eq!(results_expected, &results_actual[..]); + + Ok(()) + }) +} + +// NB: This is a runnable version of an example in the `lookup_accounts` docs. +// Try to keep them in sync. +#[test] +fn example_lookup_accounts() -> std::result::Result<(), Box> { + async fn make_lookup_accounts_request( + client: &tb::Client, + accounts: &[u128], + ) -> std::result::Result<(), Box> { + let lookup_accounts_results = client.lookup_accounts(accounts)?.await?; + let lookup_accounts_results_merged = + merge_lookup_accounts_results(accounts, lookup_accounts_results); + for (account_id, maybe_account) in lookup_accounts_results_merged { + match maybe_account { + Some(account) => { + handle_lookup_accounts_success(account).await?; + } + None => { + handle_lookup_accounts_failure(account_id).await?; + } + } + } + Ok(()) + } + + /// An iterator over both successful and unsuccessful lookup results. + fn merge_lookup_accounts_results( + accounts: &[u128], + results: Vec, + ) -> impl Iterator)> + '_ { + let mut results = results.into_iter().peekable(); + accounts.iter().map(move |&id| match results.peek() { + Some(acc) if acc.id == id => (id, results.next()), + _ => (id, None), + }) + } + + async fn handle_lookup_accounts_success( + _account: tb::Account, + ) -> std::result::Result<(), Box> { + Ok(()) + } + + async fn handle_lookup_accounts_failure( + _account_id: u128, + ) -> std::result::Result<(), Box> { + Ok(()) + } + + block_on(async { + let account_id1 = tb::id(); + let account_id2 = tb::id(); + let accounts = &[ + tb::Account { + id: account_id1, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }, + tb::Account { + id: account_id2, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }, + ]; + let account_bogus1 = tb::id(); + let account_bogus2 = tb::id(); + let account_bogus3 = tb::id(); + let accounts_lookup = &[ + account_bogus1, + account_id1, + account_bogus2, + account_id2, + account_bogus3, + ]; + let results_expected = &[accounts[0], accounts[1]]; + let merge_expected = &[ + (account_bogus1, None), + (account_id1, Some(accounts[0])), + (account_bogus2, None), + (account_id2, Some(accounts[1])), + (account_bogus3, None), + ]; + + let (client, _guard) = test_client()?; + + let _ = client.create_accounts(accounts)?.await?; + + // Test the example. + make_lookup_accounts_request(&client, accounts_lookup).await?; + + // Also test that the results are what we expect. + let results_actual = client.lookup_accounts(accounts_lookup)?.await?; + let results_actual: Vec<_> = results_actual + .into_iter() + .map(|account| tb::Account { + timestamp: Default::default(), + ..account + }) + .collect(); + assert_eq!(results_expected, &results_actual[..]); + + // Test the `merge_lookup_accounts_results` function. + let merge_actual: Vec<_> = + merge_lookup_accounts_results(accounts_lookup, results_actual).collect(); + assert_eq!(merge_expected, &merge_actual[..]); + + Ok(()) + }) +} + +// NB: This is a runnable version of an example in the `lookup_transfers` docs. +// Try to keep them in sync. +#[test] +fn example_lookup_transfers() -> std::result::Result<(), Box> { + async fn make_lookup_transfers_request( + client: &tb::Client, + transfers: &[u128], + ) -> std::result::Result<(), Box> { + let lookup_transfers_results = client.lookup_transfers(transfers)?.await?; + let lookup_transfers_results_merged = + merge_lookup_transfers_results(transfers, lookup_transfers_results); + for (transfer_id, maybe_transfer) in lookup_transfers_results_merged { + match maybe_transfer { + Some(transfer) => { + handle_lookup_transfers_success(transfer).await?; + } + None => { + handle_lookup_transfers_failure(transfer_id).await?; + } + } + } + Ok(()) + } + + /// An iterator over both successful and unsuccessful lookup results. + fn merge_lookup_transfers_results( + transfers: &[u128], + results: Vec, + ) -> impl Iterator)> + '_ { + let mut results = results.into_iter().peekable(); + transfers.iter().map(move |&id| match results.peek() { + Some(transfer) if transfer.id == id => (id, results.next()), + _ => (id, None), + }) + } + + async fn handle_lookup_transfers_success( + _transfer: tb::Transfer, + ) -> std::result::Result<(), Box> { + Ok(()) + } + + async fn handle_lookup_transfers_failure( + _transfer_id: u128, + ) -> std::result::Result<(), Box> { + Ok(()) + } + + block_on(async { + let account_id1 = tb::id(); + let account_id2 = tb::id(); + let accounts = &[ + tb::Account { + id: account_id1, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }, + tb::Account { + id: account_id2, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }, + ]; + let transfer_id1 = tb::id(); + let transfer_id2 = tb::id(); + let transfers = &[ + tb::Transfer { + id: transfer_id1, + debit_account_id: account_id1, + credit_account_id: account_id2, + amount: 100, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }, + tb::Transfer { + id: transfer_id2, + debit_account_id: account_id2, + credit_account_id: account_id1, + amount: 50, + ledger: TEST_LEDGER, + code: TEST_CODE, + ..Default::default() + }, + ]; + let transfer_bogus1 = tb::id(); + let transfer_bogus2 = tb::id(); + let transfer_bogus3 = tb::id(); + let transfers_lookup = &[ + transfer_bogus1, + transfer_id1, + transfer_bogus2, + transfer_id2, + transfer_bogus3, + ]; + let results_expected = &[transfers[0], transfers[1]]; + let merge_expected = &[ + (transfer_bogus1, None), + (transfer_id1, Some(transfers[0])), + (transfer_bogus2, None), + (transfer_id2, Some(transfers[1])), + (transfer_bogus3, None), + ]; + + let (client, _guard) = test_client()?; + + let _ = client.create_accounts(accounts)?.await?; + let _ = client.create_transfers(transfers)?.await?; + + // Test the example. + make_lookup_transfers_request(&client, transfers_lookup).await?; + + // Also test that the results are what we expect. + let results_actual = client.lookup_transfers(transfers_lookup)?.await?; + let results_actual: Vec<_> = results_actual + .into_iter() + .map(|transfer| tb::Transfer { + timestamp: Default::default(), + ..transfer + }) + .collect(); + assert_eq!(results_expected, &results_actual[..]); + + // Test the `merge_lookup_transfers_results` function. + let merge_actual: Vec<_> = + merge_lookup_transfers_results(transfers_lookup, results_actual).collect(); + assert_eq!(merge_expected, &merge_actual[..]); + + Ok(()) + }) +} + +#[test] +fn client_evicted() -> Result<()> { + const CLIENTS_MAX: usize = 64; + + // Hold the write lock so no other database is running concurrently. + let _guard = DB_LOCK.write().unwrap(); + + // Use a separate server to avoid evicting the shared test client. + let server = TestDb::new_development("client_evicted")?; + let address = server.address(); + + let client_evict = tb::Client::new(0, &address)?; + + let accounts = block_on(client_evict.lookup_accounts(&[tb::id()])?)?; + assert_eq!(accounts.len(), 0); + + let mut handles = Vec::new(); + for _ in 0..CLIENTS_MAX { + let address = address.clone(); + handles.push(std::thread::spawn(move || { + let client = tb::Client::new(0, &address).unwrap(); + let accounts = block_on(client.lookup_accounts(&[tb::id()]).unwrap()).unwrap(); + assert_eq!(accounts.len(), 0); + })); + } + + for handle in handles { + let _ = handle.join(); + } + + // The original client should now be evicted. + let result = block_on(client_evict.lookup_accounts(&[tb::id()])?); + assert_eq!(result, Err(tb::PacketStatus::ClientEvicted)); + + // After eviction, the client handle is invalidated. Subsequent + // submissions are rejected. + let result = client_evict.lookup_accounts(&[tb::id()]); + assert_eq!(result.err(), Some(tb::ClientClosed)); + + // After eviction, close completes with ClientClosed because the eviction + // callback nulls the context pointer that deinit also checks. + let result = block_on(client_evict.close()); + assert_eq!(result, Err(tb::ClientClosed)); + + Ok(()) +} diff --git a/ocam/src/config.zig b/ocam/src/config.zig new file mode 100644 index 00000000..19b98fdb --- /dev/null +++ b/ocam/src/config.zig @@ -0,0 +1,297 @@ +//! Raw configuration values. +//! +//! Code which needs these values should use `constants.zig` instead. +//! Configuration values are set from a combination of: +//! - default values +//! - `root.tigerbeetle_config` +//! - `@import("tigerbeetle_options")` + +const builtin = @import("builtin"); +const std = @import("std"); +const stdx = @import("stdx"); +const assert = std.debug.assert; + +const root = @import("root"); + +const KiB = stdx.KiB; +const MiB = stdx.MiB; +const GiB = stdx.GiB; +const TiB = stdx.TiB; + +const BuildOptions = struct { + config_verify: bool, + git_commit: ?[40]u8, + release: []const u8, + release_client_min: []const u8, +}; + +// Allow setting build-time config either via `build.zig` `Options`, or via a struct in the root +// file. +const build_options: BuildOptions = blk: { + const vsr_options = + if (@hasDecl(root, "vsr_options")) + root.vsr_options + else + @import("vsr_options"); + + // Both the root file and Zig's `addOptions` expose the struct as identical structurally, + // but a different type from a nominal typing perspective. + var result: BuildOptions = undefined; + for (std.meta.fields(BuildOptions)) |field| { + @field(result, field.name) = launder_type( + field.type, + @field(vsr_options, field.name), + ); + } + break :blk result; +}; + +fn launder_type(comptime T: type, comptime value: anytype) T { + if (T == bool or + T == []const u8 or + T == ?[]const u8 or + T == ?[40]u8) + { + return value; + } + if (@typeInfo(T) == .@"enum") { + assert(@typeInfo(@TypeOf(value)) == .@"enum" or @typeInfo(@TypeOf(value)) == .enum_literal); + return @field(T, @tagName(value)); + } + unreachable; +} + +const vsr = @import("vsr.zig"); +const sector_size = @import("constants.zig").sector_size; + +pub const Config = struct { + pub const Cluster = ConfigCluster; + pub const Process = ConfigProcess; + + cluster: ConfigCluster, + process: ConfigProcess, + + /// Returns true if the configuration is intended for "production". + /// Intended solely for extra sanity-checks: all meaningful decisions should be driven by + /// specific fields of the config. + pub fn is_production(config: *const Config) bool { + return config.cluster.journal_slot_count > ConfigCluster.journal_slot_count_min; + } +}; + +/// Configurations which are tunable per-replica (or per-client). +/// - Replica configs need not equal each other. +/// - Client configs need not equal each other. +/// - Client configs need not equal replica configs. +/// - Replica configs can change between restarts. +/// +/// Fields are documented within constants.zig. +// TODO: Some of these could be runtime parameters (e.g. grid_scrubber_cycle). +const ConfigProcess = struct { + log_level: std.log.Level = .info, + verify: bool, + release: vsr.Release = vsr.Release.minimum, + release_client_min: vsr.Release = vsr.Release.minimum, + git_commit: ?[40]u8 = null, + port: u16 = 3001, + address: []const u8 = "127.0.0.1", + storage_size_limit_default: u64 = 16 * TiB, + storage_size_limit_max: u64 = 64 * TiB, + memory_size_max_default: u64 = GiB, + cache_accounts_size_default: u64, + cache_transfers_size_default: u64, + cache_transfers_pending_size_default: u64, + client_request_queue_max: u32 = 2, + lsm_manifest_node_size: u64 = 16 * KiB, + connection_delay_min: stdx.Duration = .ms(50), + connection_delay_max: stdx.Duration = .ms(1000), + tcp_backlog: u31 = 64, + tcp_rcvbuf: c_int = 4 * MiB, + tcp_keepalive: bool = true, + tcp_keepidle: c_int = 5, + tcp_keepintvl: c_int = 4, + tcp_keepcnt: c_int = 3, + tcp_nodelay: bool = true, + direct_io: bool, + journal_iops_read_max: u16 = 8, + journal_iops_write_max: u16 = 32, + client_replies_iops_read_max: u16 = 1, + client_replies_iops_write_max: u16 = 2, + tick_ms: u63 = 10, + rtt: stdx.Duration = .ms(300), + rtt_max: stdx.Duration = .seconds(60), + rtt_multiple: u8 = 2, + backoff_min: stdx.Duration = .ms(10), + backoff_max: stdx.Duration = .ms(10000), + clock_offset_tolerance_max: stdx.Duration = .ms(10000), + clock_epoch_max: stdx.Duration = .ms(60000), + clock_synchronization_window_min: stdx.Duration = .ms(2000), + clock_synchronization_window_max: stdx.Duration = .ms(20000), + grid_iops_read_max: u16 = 32, + grid_iops_write_max: u16 = 32, + grid_cache_size_default: u64 = GiB, + grid_repair_request_max: u16 = 4, + grid_repair_reads_max: u16 = 4, + grid_missing_blocks_max: u32 = 30, + grid_missing_tables_max: u32 = 6, + grid_scrubber_reads_max: u16 = 1, + grid_scrubber_cycle: stdx.Duration = .ms(std.time.ms_per_day * 180), + grid_scrubber_interval_min: stdx.Duration = .ms(50), + grid_scrubber_interval_max: stdx.Duration = .seconds(10), + multiversion_binary_platform_size_max: u64 = 64 * MiB, + multiversion_poll_interval: stdx.Duration = .ms(1000), +}; + +/// Configurations which are tunable per-cluster. +/// - All replicas within a cluster must have the same configuration. +/// - Replicas must reuse the same configuration when the binary is upgraded — they do not change +/// over the cluster lifetime. +/// - The storage formats generated by different ConfigClusters are incompatible. +/// +/// Fields are documented within constants.zig. +const ConfigCluster = struct { + cache_line_size: comptime_int = 64, + clients_max: u32, + pipeline_prepare_queue_max: u32 = 8, + view_change_headers_suffix_max: u32 = 8 + 1, + quorum_replication_max: u8 = 3, + journal_slot_count: u32 = 1024, + message_size_max: u32 = 1 * MiB, + superblock_copies: comptime_int = 4, + block_size: comptime_int = 512 * KiB, + lsm_levels: u6 = 7, + lsm_growth_factor: u32 = 8, + lsm_compaction_ops: comptime_int = 32, + lsm_snapshots_max: u32 = 32, + lsm_manifest_compact_extra_blocks: comptime_int = 1, + lsm_table_coalescing_threshold_percent: comptime_int = 50, + vsr_releases_max: u32 = 64, + + /// Minimal value. + // TODO(batiati): Maybe this constant should be derived from `grid_iops_read_max`, + // since each scan can read from `lsm_levels` in parallel. + lsm_scans_max: comptime_int = 6, + + /// The WAL requires at least two sectors of redundant headers — otherwise we could lose them + /// all to a single torn write. A replica needs at least one valid redundant header to + /// determine an (untrusted) maximum op in recover_torn_prepare(), without which it cannot + /// truncate a torn prepare. + pub const journal_slot_count_min = 2 * @divExact(sector_size, @sizeOf(vsr.Header)); + + pub const clients_max_min = 1; + + /// The smallest possible message_size_max (for use in the simulator to improve performance). + /// The message body must have room for pipeline_prepare_queue_max headers in the JV. + pub fn message_size_max_min(clients_max: u32) u32 { + return @max( + sector_size, + std.mem.alignForward( + u32, + @sizeOf(vsr.Header) + clients_max * @sizeOf(vsr.Header), + sector_size, + ), + ); + } + + /// Fingerprint of the cluster-wide configuration. + /// It is used to assert that all cluster members share the same config. + pub fn checksum(comptime config: ConfigCluster) u128 { + @setEvalBranchQuota(10_000); + comptime var config_bytes: []const u8 = &.{}; + comptime for (std.meta.fields(ConfigCluster)) |field| { + const value = @field(config, field.name); + const value_64 = @as(u64, value); + assert(builtin.target.cpu.arch.endian() == .little); + config_bytes = config_bytes ++ std.mem.asBytes(&value_64); + }; + return vsr.checksum(config_bytes); + } +}; + +pub const ConfigBase = enum { + production, + test_min, + default, +}; + +pub const configs = struct { + /// A good default config for production. + pub const default_production = Config{ + .process = .{ + .direct_io = true, + .cache_accounts_size_default = @sizeOf(vsr.tigerbeetle.Account) * MiB, + .cache_transfers_size_default = 0, + .cache_transfers_pending_size_default = 0, + .verify = true, + }, + .cluster = .{ + .clients_max = 64, + }, + }; + + /// Minimal test configuration — small WAL, small grid block size, etc. + /// Not suitable for production, but good for testing code that would be otherwise hard to + /// reach. + pub const test_min = Config{ + .process = .{ + .storage_size_limit_default = 1 * GiB, + .storage_size_limit_max = 1 * GiB, + .direct_io = false, + .cache_accounts_size_default = @sizeOf(vsr.tigerbeetle.Account) * 256, + .cache_transfers_size_default = 0, + .cache_transfers_pending_size_default = 0, + .journal_iops_read_max = 3, + .journal_iops_write_max = 2, + .grid_iops_read_max = 8, + .grid_iops_write_max = 8, + .grid_repair_request_max = 4, + .grid_repair_reads_max = 4, + .grid_missing_blocks_max = 3, + .grid_missing_tables_max = 2, + .grid_scrubber_reads_max = 2, + .grid_scrubber_cycle = .ms(std.time.ms_per_hour), + .verify = true, + }, + .cluster = .{ + .clients_max = 4 + 3, + .pipeline_prepare_queue_max = 4, + .view_change_headers_suffix_max = 4 + 1, + .journal_slot_count = Config.Cluster.journal_slot_count_min, + .message_size_max = Config.Cluster.message_size_max_min(4), + + .block_size = sector_size, + .lsm_compaction_ops = 4, + .lsm_growth_factor = 4, + // (This is higher than the production default value because the block size is smaller.) + .lsm_manifest_compact_extra_blocks = 5, + // (We need to fuzz more scans merge than in production.) + .lsm_scans_max = 12, + }, + }; + + pub const current = current: { + var base = if (@hasDecl(root, "tigerbeetle_config")) + root.tigerbeetle_config + else if (builtin.is_test) + test_min + else + default_production; + + const release = vsr.ReleaseTriple.parse(build_options.release) catch { + @compileError("invalid release version"); + }; + + const release_client_min = vsr.ReleaseTriple.parse(build_options.release_client_min) catch { + @compileError("invalid release_client_min version"); + }; + + base.process.release = vsr.Release.from(release); + base.process.release_client_min = vsr.Release.from(release_client_min); + base.process.git_commit = build_options.git_commit; + base.process.verify = build_options.config_verify; + + assert(base.process.release.value >= base.process.release_client_min.value); + + break :current base; + }; +}; diff --git a/ocam/src/constants.zig b/ocam/src/constants.zig new file mode 100644 index 00000000..6a209ded --- /dev/null +++ b/ocam/src/constants.zig @@ -0,0 +1,798 @@ +//! Constants are the configuration that the code actually imports — they include: +//! - all of the configuration values (flattened) +//! - derived configuration values, + +const std = @import("std"); +const assert = std.debug.assert; +const vsr = @import("vsr.zig"); +const Config = @import("config.zig").Config; +const stdx = @import("stdx"); + +const MiB = stdx.MiB; + +pub const config = @import("config.zig").configs.current; + +pub const semver = std.SemanticVersion{ + .major = config.process.release.triple().major, + .minor = config.process.release.triple().minor, + .patch = config.process.release.triple().patch, + .pre = null, + .build = if (config.process.git_commit) |sha_full| sha_full[0..7] else null, +}; + +/// The maximum number of replicas allowed in a cluster. +pub const replicas_max = 6; +/// The maximum number of standbys allowed in a cluster. +pub const standbys_max = 6; +/// The maximum number of cluster members (either standbys or active replicas). +pub const members_max = replicas_max + standbys_max; + +/// All operations = pipeline_prepare_queue_max); + assert(vsr_checkpoint_ops >= lsm_compaction_ops); + assert(vsr_checkpoint_ops % lsm_compaction_ops == 0); +} + +/// The maximum number of clients allowed per cluster, where each client has a unique 128-bit ID. +/// This impacts the amount of memory allocated at initialization by the server. +/// This determines the size of the VR client table used to cache replies to clients by client ID. +/// Each client has one entry in the VR client table to store the latest `message_size_max` reply. +/// Client ID 0 which is used by primary for pulse and upgrade request, is not counted. +pub const clients_max = config.cluster.clients_max; + +comptime { + assert(clients_max >= Config.Cluster.clients_max_min); +} + +/// The maximum number of release versions (upgrade candidates) that can be advertised by a replica +/// in each ping message body. +pub const vsr_releases_max = config.cluster.vsr_releases_max; + +/// The maximum cumulative size of a final TigerBeetle output binary - including potential past +/// releases and metadata. +pub fn multiversion_binary_platform_size_max(options: struct { macos: bool, debug: bool }) u64 { + // {Linux, Windows} get the base value. macOS gets 2x since it has universal binaries. All cases + // get a further 2x in debug. + var size_max = config.process.multiversion_binary_platform_size_max; + if (options.macos) size_max *= 2; + if (options.debug) size_max *= 2; + + return size_max; +} + +/// The maximum size, like above, but for any platform. +pub const multiversion_binary_size_max = + config.process.multiversion_binary_platform_size_max * 2 * 2; +comptime { + assert(multiversion_binary_platform_size_max(.{ + .macos = true, + .debug = true, + }) <= multiversion_binary_size_max); +} + +pub const multiversion_poll_interval = config.process.multiversion_poll_interval; + +comptime { + assert(vsr_releases_max >= 2); + assert(vsr_releases_max * @sizeOf(vsr.Release) <= message_body_size_max); + // The number of releases is encoded into ping headers as a u16. + assert(vsr_releases_max <= std.math.maxInt(u16)); +} + +/// The maximum number of nodes required to form a quorum for replication. +/// Majority quorums are only required across view change and replication phases (not within). +/// As per Flexible Paxos, provided `quorum_replication + quorum_view_change > replicas`: +/// 1. you may increase `quorum_view_change` above a majority, so that +/// 2. you can decrease `quorum_replication` below a majority, to optimize the common case. +/// This improves latency by reducing the number of nodes required for synchronous replication. +/// This reduces redundancy only in the short term, asynchronous replication will still continue. +/// The size of the replication quorum is limited to the minimum of this value and ⌈replicas/2⌉. +/// The size of the view change quorum will then be automatically inferred from quorum_replication. +pub const quorum_replication_max = config.cluster.quorum_replication_max; + +/// The default server port to listen on if not specified in `--addresses`: +pub const port = config.process.port; + +/// The default network interface address to listen on if not specified in `--addresses`: +/// WARNING: Binding to all interfaces with "0.0.0.0" is dangerous and opens the server to anyone. +/// Bind to the "127.0.0.1" loopback address to accept local connections as a safe default only. +pub const address = config.process.address; + +comptime { + // vsr.parse_address assumes that config.address/config.port are valid. + _ = std.net.Address.parseIp4(address, 0) catch unreachable; + _ = @as(u16, port); +} + +/// The default maximum amount of memory to use. +pub const memory_size_max_default = config.process.memory_size_max_default; + +/// At a high level, priority for object caching is (in descending order): +/// +/// 1. Accounts. +/// - 2 lookups per created transfer +/// - high temporal locality +/// - positive expected result +/// 2. Posted transfers. +/// - high temporal locality +/// - positive expected result +/// 3. Transfers. Generally don't cache these because of: +/// - low temporal locality +/// - negative expected result +/// +/// The default size of the accounts in-memory cache: +/// This impacts the amount of memory allocated at initialization by the server. +pub const cache_accounts_size_default = config.process.cache_accounts_size_default; + +/// The default size of the transfers in-memory cache: +/// This impacts the amount of memory allocated at initialization by the server. +/// We allocate more capacity than the number of transfers for a safe hash table load factor. +pub const cache_transfers_size_default = config.process.cache_transfers_size_default; + +/// The default size of the two-phase transfers in-memory cache: +/// This impacts the amount of memory allocated at initialization by the server. +pub const cache_transfers_pending_size_default = + config.process.cache_transfers_pending_size_default; + +/// The size of the client replies zone. +pub const client_replies_size = clients_max * message_size_max; + +comptime { + assert(client_replies_size > 0); + assert(client_replies_size % sector_size == 0); +} + +/// The maximum number of batch entries in the journal file: +/// A batch entry may contain many transfers, so this is not a limit on the number of transfers. +/// We need this limit to allocate space for copies of batch headers at the start of the journal. +/// These header copies enable us to disentangle corruption from crashes and recover accordingly. +pub const journal_slot_count = config.cluster.journal_slot_count; + +/// The maximum size of the WAL zone: +/// This is pre-allocated and zeroed for performance when initialized. +/// Writes within this file never extend the filesystem inode size reducing the cost of fdatasync(). +/// This enables static allocation of disk space so that appends cannot fail with ENOSPC. +/// This also enables us to detect filesystem inode corruption that would change the journal size. +pub const journal_size = journal_size_headers + journal_size_prepares; +pub const journal_size_headers = journal_slot_count * @sizeOf(vsr.Header); +pub const journal_size_prepares = journal_slot_count * message_size_max; + +comptime { + // For the given WAL (lsm_compaction_ops=4): + // + // A B C D E + // |····|····|····|····| + // + // - ("|" delineates bars, where a bar is a multiple of prepare batches.) + // - ("·" is a prepare in the WAL.) + // - The Replica triggers a checkpoint at "E". + // - The entries between "A" and "D" are on-disk in level 0. + // - The entries between "D" and "E" are in-memory in the immutable table. + // - So the checkpoint only includes "A…D". + // + // The journal must have at least two bars to ensure at least one is checkpointed. + assert(journal_slot_count >= Config.Cluster.journal_slot_count_min); + assert(journal_slot_count >= lsm_compaction_ops * 2); + assert(journal_slot_count % lsm_compaction_ops == 0); + // The journal must have at least two pipelines of messages to ensure that a new, fully-repaired + // primary has enough headers for a complete View message, even if the view-change just + // truncated + // another pipeline of messages. (See op_repair_min()). + assert(journal_slot_count >= pipeline_prepare_queue_max * 2); + + assert(journal_size == journal_size_headers + journal_size_prepares); +} + +/// The maximum size of a message in bytes: +/// This is also the limit of all inflight data across multiple pipelined requests per connection. +/// We may have one request of up to 2 MiB inflight or 2 pipelined requests of up to 1 MiB inflight. +/// This impacts sequential disk write throughput, the larger the buffer the better. +/// 2 MiB is 16,384 transfers, and a reasonable choice for sequential disk write throughput. +/// However, this impacts bufferbloat and head-of-line blocking latency for pipelined requests. +/// For a 1 Gbps NIC = 125 MiB/s throughput: 2 MiB / 125 * 1000ms = 16ms for the next request. +/// This impacts the amount of memory allocated at initialization by the server. +pub const message_size_max: u32 = config.cluster.message_size_max; +pub const message_body_size_max = message_size_max - @sizeOf(vsr.Header); + +comptime { + // The WAL format requires messages to be a multiple of the sector size. + assert(message_size_max % sector_size == 0); + assert(message_size_max >= @sizeOf(vsr.Header)); + assert(message_size_max >= sector_size); + assert(message_size_max >= Config.Cluster.message_size_max_min(clients_max)); + + // Ensure that JV/View messages can fit all necessary headers. + assert(message_body_size_max >= view_headers_max * @sizeOf(vsr.Header)); + + assert(message_body_size_max >= @sizeOf(vsr.ReconfigurationRequest)); + assert(message_body_size_max >= @sizeOf(vsr.BlockRequest)); + assert(message_body_size_max >= @sizeOf(vsr.CheckpointState)); +} + +/// The maximum number of Viewstamped Replication prepare messages that can be inflight at a time. +/// This is immutable once assigned per cluster, as replicas need to know how many operations might +/// possibly be uncommitted during a view change, and this must be constant for all replicas. +pub const pipeline_prepare_queue_max: u32 = config.cluster.pipeline_prepare_queue_max; + +/// The maximum number of Viewstamped Replication request messages that can be queued at a primary, +/// waiting to prepare. Each client has at most one request in flight, and a primary can send a +/// pulse or request upgrade. +pub const pipeline_request_queue_max: u32 = (clients_max + 1) -| pipeline_prepare_queue_max; + +comptime { + // A prepare-queue capacity larger than (clients_max + 1) is wasted. + assert(pipeline_prepare_queue_max <= clients_max + 1); + // A total queue capacity larger than (clients_max + 1) is wasted. + assert(pipeline_prepare_queue_max + pipeline_request_queue_max <= clients_max + 1); + assert(pipeline_prepare_queue_max > 0); + assert(pipeline_request_queue_max >= 0); + + // A JV message uses the `header.context` (u128) field as a bitset to mark whether it has + // prepared the corresponding header's message. + assert(pipeline_prepare_queue_max + 1 <= @bitSizeOf(u128)); +} + +/// Maximum number of headers from the WAL suffix to include in a View message. +/// Must at least cover the full pipeline. +/// Increasing this reduces likelihood that backups will need to repair their suffix's headers. +/// +/// CRITICAL: +/// - We must provide enough headers to cover all uncommitted headers so that the new +/// primary (if we are in a view change) can decide whether to discard uncommitted headers +/// that cannot be repaired because they are gaps. See JVQuorum for more detail. +/// - +1 to leave room for commit_max, in case a backup converts the View to a JV. +pub const view_change_headers_suffix_max = config.cluster.view_change_headers_suffix_max; + +/// The number of prepare headers to include in the body of a JV/View. +/// +/// View: +/// +/// - We must include all uncommitted headers. +/// - +1 We must include the highest cluster-committed header (in case the View is converted to a JV +/// by the backup). (This is part of view_change_headers_suffix_max). +/// - +2: We must provide the header corresponding to each checkpoint-trigger in the intact +/// suffix of our journal. +/// - These help a lagging replica catch up when its `op < commit_max`. +/// - There are at most two of these in the journal. +/// (There are 2 immediately after we checkpoint, until we prepare enough to overwrite one). +/// +/// JoinView: +/// +/// - We must include all uncommitted headers. +/// - +1 We must include the highest cluster-committed header, so that the new primary still has a +/// head op if it truncates the entire pipeline. +pub const view_headers_max = view_change_headers_suffix_max + 2; + +comptime { + assert(view_change_headers_suffix_max >= pipeline_prepare_queue_max + 1); + + assert(view_headers_max > 0); + assert(view_headers_max >= pipeline_prepare_queue_max + 3); + assert(view_headers_max <= journal_slot_count); + assert(view_headers_max <= @divFloor( + message_body_size_max - @sizeOf(vsr.CheckpointState), + @sizeOf(vsr.Header), + )); + assert(view_headers_max > view_change_headers_suffix_max); +} + +/// The maximum number of headers to include with a response to a command=get_headers message. +pub const get_headers_max = @min( + @divFloor(message_body_size_max, @sizeOf(vsr.Header)), + 64, +); + +comptime { + assert(get_headers_max > 0); +} + +/// The maximum number of block addresses/checksums requested by a single command=get_blocks. +pub const grid_repair_request_max = config.process.grid_repair_request_max; + +/// The number of grid reads allocated to handle incoming command=get_blocks messages. +pub const grid_repair_reads_max = config.process.grid_repair_reads_max; + +/// Immediately after state sync we want access to all of the grid's write bandwidth to rapidly sync +/// table blocks. +pub const grid_repair_writes_max = grid_iops_write_max; + +/// The default sizing of the grid cache. It's expected for operators to override this on the CLI. +pub const grid_cache_size_default = config.process.grid_cache_size_default; + +/// The maximum capacity (in *single* blocks – not counting syncing tables) of the +/// GridBlocksMissing. +/// +/// As this increases: +/// - GridBlocksMissing allocates more memory. +/// - The "period" of GridBlocksMissing's requests increases. +/// This makes the repair protocol more tolerant of network latency. +/// - (Repair protocol is used to repair manifest log blocks immediately after state sync). +pub const grid_missing_blocks_max = config.process.grid_missing_blocks_max; + +/// The number of tables that can be synced simultaneously. +/// "Table" in this context is the number of table index blocks to hold in memory while syncing +/// their content. +/// +/// As this increases: +/// - GridBlocksMissing allocates more memory (~2 blocks for each). +/// - Syncing is more efficient, as more blocks can be fetched concurrently. +pub const grid_missing_tables_max = config.process.grid_missing_tables_max; + +comptime { + assert(grid_repair_request_max > 0); + assert(grid_repair_request_max <= @divFloor(message_body_size_max, @sizeOf(vsr.BlockRequest))); + assert(grid_repair_request_max <= grid_repair_reads_max); + + assert(grid_repair_reads_max > 0); + assert(grid_repair_writes_max > 0); + assert(grid_repair_writes_max <= + grid_missing_blocks_max + grid_missing_tables_max * lsm_table_value_blocks_max); + + assert(grid_missing_blocks_max > 0); + assert(grid_missing_tables_max > 0); +} + +/// The maximum number of concurrent scrubber reads. +/// +/// Unless the scrubber cycle is extremely short and the data file very large there is no need to +/// set this higher than 1. +pub const grid_scrubber_reads_max = config.process.grid_scrubber_reads_max; + +/// `grid_scrubber_cycle` is the (approximate, target) total duration per scrub of each +/// replica's entire grid. Scrubbing work is spread evenly across this duration. +/// +/// Napkin math for the "worst case" scrubber read overhead as a function of cycle duration +/// (assuming a fully-loaded data file – maximum size and 100% acquired): +/// +/// storage_size_limit = 64TiB +/// grid_scrubber_cycle_seconds = 180 days * 24 hr/day * 60 min/hr * 60 s/min (2 cycle/year) +/// read_bytes_per_second = storage_size_limit / grid_scrubber_cycle_seconds ≈ 4.32 MiB/s +/// +pub const grid_scrubber_cycle_ticks = config.process.grid_scrubber_cycle.to_ms() / tick_ms; + +/// Accelerate/throttle scrubber reads if they are less/more frequent than this range. +/// (This is to keep the timeouts from being too extreme when the grid is tiny or huge.) +pub const grid_scrubber_interval_ticks_min = + config.process.grid_scrubber_interval_min.to_ms() / tick_ms; +pub const grid_scrubber_interval_ticks_max = + config.process.grid_scrubber_interval_max.to_ms() / tick_ms; + +comptime { + assert(grid_scrubber_reads_max > 0); + assert(grid_scrubber_reads_max <= grid_iops_read_max); + assert(grid_scrubber_cycle_ticks > 0); + assert(grid_scrubber_cycle_ticks > @divFloor(std.time.ms_per_min, tick_ms)); // Sanity-check. + assert(grid_scrubber_interval_ticks_min > 0); + assert(grid_scrubber_interval_ticks_min <= grid_scrubber_interval_ticks_max); + assert(grid_scrubber_interval_ticks_max > 0); +} + +/// The minimum and maximum amount of time to wait before initiating a connection. +/// Exponential backoff and jitter are applied within this range. +pub const connection_delay_min = config.process.connection_delay_min; +pub const connection_delay_max = config.process.connection_delay_max; + +/// The maximum number of outgoing messages that may be queued on a replica connection. +pub const connection_send_queue_max_replica = @max(@min(clients_max, 4), 2); + +/// The maximum number of outgoing messages that may be queued on a client connection. +/// The client has one in-flight request, and occasionally a ping. +pub const connection_send_queue_max_client = 2; + +/// The maximum number of outgoing requests that may be queued on a client (including the in-flight +/// request). +pub const client_request_queue_max = config.process.client_request_queue_max; + +/// The maximum number of connections in the kernel's complete connection queue pending an accept(): +/// If the backlog argument is greater than the value in `/proc/sys/net/core/somaxconn`, then it is +/// silently truncated to that value. Since Linux 5.4, the default in this file is 4096. +pub const tcp_backlog = config.process.tcp_backlog; + +/// The maximum size of a kernel socket receive buffer in bytes (or 0 to use the system default): +/// This sets SO_RCVBUF as an alternative to the auto-tuning range in /proc/sys/net/ipv4/tcp_rmem. +/// The value is limited by /proc/sys/net/core/rmem_max, unless the CAP_NET_ADMIN privilege exists. +/// The kernel doubles this value to allow space for packet bookkeeping overhead. +/// The receive buffer should ideally exceed the Bandwidth-Delay Product for maximum throughput. +/// At the same time, be careful going beyond 4 MiB as the kernel may merge many small TCP packets, +/// causing considerable latency spikes for large buffer sizes: +/// https://blog.cloudflare.com/the-story-of-one-latency-spike/ +pub const tcp_rcvbuf = config.process.tcp_rcvbuf; + +/// The maximum size of a kernel socket send buffer in bytes (or 0 to use the system default): +/// This sets SO_SNDBUF as an alternative to the auto-tuning range in /proc/sys/net/ipv4/tcp_wmem. +/// The value is limited by /proc/sys/net/core/wmem_max, unless the CAP_NET_ADMIN privilege exists. +/// The kernel doubles this value to allow space for packet bookkeeping overhead. +pub const tcp_sndbuf_replica = connection_send_queue_max_replica * message_size_max; +pub const tcp_sndbuf_client = connection_send_queue_max_client * message_size_max; + +comptime { + // Avoid latency issues from setting sndbuf too high: + assert(tcp_sndbuf_replica <= 16 * MiB); + assert(tcp_sndbuf_client <= 16 * MiB); +} + +/// Whether to enable TCP keepalive: +pub const tcp_keepalive = config.process.tcp_keepalive; + +/// The time (in seconds) the connection needs to be idle before sending TCP keepalive probes: +/// Probes are not sent when the send buffer has data or the congestion window size is zero, +/// for these cases we also need tcp_user_timeout_ms below. +pub const tcp_keepidle = config.process.tcp_keepidle; + +/// The time (in seconds) between individual keepalive probes: +pub const tcp_keepintvl = config.process.tcp_keepintvl; + +/// The maximum number of keepalive probes to send before dropping the connection: +pub const tcp_keepcnt = config.process.tcp_keepcnt; + +/// The time (in milliseconds) to timeout an idle connection or unacknowledged send: +/// This timer rides on the granularity of the keepalive or retransmission timers. +/// For example, if keepalive will only send a probe after 10s then this becomes the lower bound +/// for tcp_user_timeout_ms to fire, even if tcp_user_timeout_ms is 2s. Nevertheless, this would +/// timeout the connection at 10s rather than wait for tcp_keepcnt probes to be sent. At the same +/// time, if tcp_user_timeout_ms is larger than the max keepalive time then tcp_keepcnt will be +/// ignored and more keepalive probes will be sent until tcp_user_timeout_ms fires. +/// For a thorough overview of how these settings interact: +/// https://blog.cloudflare.com/when-tcp-sockets-refuse-to-die/ +pub const tcp_user_timeout_ms = (tcp_keepidle + tcp_keepintvl * tcp_keepcnt) * 1000; + +/// Whether to disable Nagle's algorithm to eliminate send buffering delays: +pub const tcp_nodelay = config.process.tcp_nodelay; + +/// Size of a CPU cache line in bytes +pub const cache_line_size = config.cluster.cache_line_size; + +/// The minimum size of an aligned kernel page and an Advanced Format disk sector: +/// This is necessary for direct I/O without the kernel having to fix unaligned pages with a copy. +/// The new Advanced Format sector size is backwards compatible with the old 512 byte sector size. +/// This should therefore never be less than 4 KiB to be future-proof when server disks are swapped. +pub const sector_size = 4096; + +/// Whether to perform direct I/O to the underlying disk device: +/// This enables several performance optimizations: +/// * A memory copy to the kernel's page cache can be eliminated for reduced CPU utilization. +/// * I/O can be issued immediately to the disk device without buffering delay for improved latency. +/// This also enables several safety features: +/// * Disk data can be scrubbed to repair latent sector errors and checksum errors proactively. +/// * Fsync failures can be recovered from correctly. +/// WARNING: Disabling direct I/O is unsafe; the page cache cannot be trusted after an fsync error, +/// even after an application panic, since the kernel will mark dirty pages as clean, even +/// when they were never written to disk. +pub const direct_io = config.process.direct_io; + +pub const iops_read_max = journal_iops_read_max + client_replies_iops_read_max + + grid_iops_read_max + superblock_iops_read_max; +pub const iops_write_max = journal_iops_write_max + client_replies_iops_write_max + + grid_iops_write_max + superblock_iops_write_max; + +/// Superblock has at most one write in flight. +const superblock_iops_read_max = 1; +const superblock_iops_write_max = 1; + +/// The maximum number of concurrent WAL read I/O operations to allow at once. +pub const journal_iops_read_max = config.process.journal_iops_read_max; +/// The maximum number of concurrent WAL write I/O operations to allow at once. +/// Ideally this is at least as high as pipeline_prepare_queue_max, but it is safe to be lower. +pub const journal_iops_write_max = config.process.journal_iops_write_max; + +/// The maximum number of concurrent reads to the client-replies zone. +/// Client replies are read when the client misses their original reply and retries a request. +pub const client_replies_iops_read_max = config.process.client_replies_iops_read_max; +/// The maximum number of concurrent writes to the client-replies zone. +/// Client replies are written after every commit. +pub const client_replies_iops_write_max = config.process.client_replies_iops_write_max; + +/// The maximum number of concurrent grid read I/O operations to allow at once. +pub const grid_iops_read_max = config.process.grid_iops_read_max; +/// The maximum number of concurrent grid write I/O operations to allow at once. +pub const grid_iops_write_max = config.process.grid_iops_write_max; + +comptime { + assert(journal_iops_read_max > 0); + assert(journal_iops_write_max > 0); + assert(client_replies_iops_read_max > 0); + assert(client_replies_iops_write_max > 0); + assert(client_replies_iops_write_max <= clients_max); + assert(grid_iops_read_max > 0); + assert(grid_iops_write_max > 0); +} + +/// The number of redundant copies of the superblock in the superblock storage zone. +/// This must be either { 4, 6, 8 }, i.e. an even number, for more efficient flexible quorums. +/// +/// The superblock contains local state for the replica and therefore cannot be replicated remotely. +/// Loss of the superblock would represent loss of the replica and so it must be protected. +/// +/// This can mean checkpointing latencies in the rare extreme worst-case of at most 264ms, although +/// this would require EWAH compression of our block free set to have zero effective compression. +/// In practice, checkpointing latency should be an order of magnitude better due to compression, +/// because our block free set will fill holes when allocating. +/// +/// The superblock only needs to be checkpointed every now and then, before the WAL wraps around, +/// or when a view change needs to take place to elect a new primary. +pub const superblock_copies = config.cluster.superblock_copies; + +comptime { + assert(superblock_copies % 2 == 0); + assert(superblock_copies >= 4); + assert(superblock_copies <= 8); +} + +/// The default maximum size of a local data file. This can be override, up to +/// storage_size_limit_max, by a CLI flag. +pub const storage_size_limit_default = config.process.storage_size_limit_default; + +/// The maximum size of a local data file. +/// This should not be much larger than several TiB to limit: +/// * blast radius and recovery time when a whole replica is lost, +/// * replicated storage overhead, since all data files are mirrored, and +/// * the static memory allocation required for tracking LSM forest metadata in memory. +/// +/// This is a "firm" limit --- while it is a compile-time constant, it does not affect data file +/// layout and can be safely changed for an existing cluster. +pub const storage_size_limit_max = config.process.storage_size_limit_max; + +comptime { + assert(storage_size_limit_max >= storage_size_limit_default); +} + +/// The unit of read/write access to LSM manifest and LSM table blocks in the block storage zone. +/// +/// - A lower block size increases the memory overhead of table metadata, due to smaller/more +/// tables. +/// - A higher block size increases space amplification due to partially-filled blocks. +pub const block_size = config.cluster.block_size; + +comptime { + assert(block_size % sector_size == 0); + assert(block_size > @sizeOf(vsr.Header)); + // Blocks are sent over the network as messages during grid repair and state sync. + assert(block_size <= message_size_max); +} + +/// The number of levels in an LSM tree. +/// A higher number of levels increases read amplification, as well as total storage capacity. +pub const lsm_levels = config.cluster.lsm_levels; + +comptime { + // ManifestLog serializes the level as a u6. + assert(lsm_levels > 0); + assert(lsm_levels <= std.math.maxInt(u6)); +} + +/// The number of tables at level i (0 ≤ i < lsm_levels) is `pow(lsm_growth_factor, i+1)`. +/// A higher growth factor increases write amplification (by increasing the number of tables in +/// level B that overlap a table in level A in a compaction), but decreases read amplification (by +/// reducing the height of the tree and thus the number of levels that must be probed). Since read +/// amplification can be optimized more easily (with caching), we target a growth +/// factor of 8 for lower write amplification rather than the more typical growth factor of 10. +pub const lsm_growth_factor = config.cluster.lsm_growth_factor; + +comptime { + assert(lsm_growth_factor > 1); +} + +/// Size of nodes used by the LSM tree manifest implementation. +/// TODO Double-check this with our "LSM Manifest" spreadsheet. +pub const lsm_manifest_node_size = config.process.lsm_manifest_node_size; + +/// The number of manifest blocks to compact *beyond the minimum*, per half-bar. +/// +/// In the worst case, we still compact entries faster than we produce them (by a margin of +/// "extra" blocks). This is necessary to ensure that the manifest has a bounded number of entries. +/// (Or in other words, that Pace's recurrence relation converges.) +/// +/// This specific choice of value is somewhat arbitrary, but yields a decent balance between +/// "compaction work performed" and "total manifest size". +/// +/// As this value increases, the manifest must perform more compaction work, but the manifest +/// upper-bound shrinks (and therefore manifest recovery time decreases). +/// +/// See ManifestLog.Pace for more detail. +pub const lsm_manifest_compact_extra_blocks = config.cluster.lsm_manifest_compact_extra_blocks; + +comptime { + assert(lsm_manifest_compact_extra_blocks > 0); +} + +/// Number of prepares accumulated in the in-memory table before flushing to disk. +/// +/// This is a batch of batches. Each prepare can contain at most 8_190 transfers. With +/// lsm_compaction_ops=32, 32 prepares are processed to fill the in-memory table with 262_080 +/// transfers. During processing of the next 32 prepares, this in-memory table is flushed to disk. +/// Simultaneously, compaction is run to free up enough space to flush the in-memory table from the +/// next batch of lsm_compaction_ops prepares. +/// +/// Together with message_body_size_max, lsm_compaction_ops determines the size a table on disk. +pub const lsm_compaction_ops = config.cluster.lsm_compaction_ops; + +comptime { + // The LSM tree uses half-measures to balance compaction. + assert(lsm_compaction_ops % 2 == 0); +} + +// Limits for the number of value blocks that a single compaction can queue up for IO and for the +// number of IO operations themselves. The number of index blocks is always one per level. +// This is a comptime upper bound. The actual number of concurrency is also limited by the +// runtime-known number of free blocks. +// +// For simplicity for now, size IOPS to always be available. +pub const lsm_compaction_queue_read_max = 16; +pub const lsm_compaction_queue_write_max = 16; +pub const lsm_compaction_iops_read_max = lsm_compaction_queue_read_max + 2; // + two index blocks. +pub const lsm_compaction_iops_write_max = lsm_compaction_queue_write_max + 1; // + one index block. + +pub const lsm_snapshots_max = config.cluster.lsm_snapshots_max; + +/// The maximum number of blocks that can possibly be referenced by any table index block. +/// +/// - This is a very conservative (upper-bound) calculation that doesn't rely on the StateMachine's +/// tree configuration. (To prevent Grid from depending on StateMachine). +/// - This counts value blocks, but does not count the index block itself. +pub const lsm_table_value_blocks_max = table_blocks_max: { + const checksum_size = @sizeOf(u256); + const address_size = @sizeOf(u64); + break :table_blocks_max @divFloor( + block_size - @sizeOf(vsr.Header), + (checksum_size + address_size), + ); +}; + +/// The default size in bytes of the NodePool used for the LSM forest's manifests. +pub const lsm_manifest_memory_size_default = lsm_manifest_memory: { + // TODO Tune this better. + const lsm_forest_node_count: u32 = 8192; + break :lsm_manifest_memory lsm_forest_node_count * lsm_manifest_node_size; +}; + +/// The maximum size in bytes of the NodePool used for the LSM forest's manifests. +pub const lsm_manifest_memory_size_max = + @divFloor(std.math.maxInt(u32), lsm_manifest_memory_size_multiplier) * + lsm_manifest_memory_size_multiplier; + +/// The minimum size in bytes of the NodePool used for the LSM forest's manifests. +pub const lsm_manifest_memory_size_min = lsm_manifest_memory_size_multiplier; + +/// The lsm memory size must be a multiple of this value. +/// +/// While technically this could be equal to lsm_manifest_node_size, we set it +/// to 1MiB so it is a more obvious increment for users. +pub const lsm_manifest_memory_size_multiplier = lsm_manifest_memory_multiplier: { + const lsm_manifest_memory_multiplier = 64 * lsm_manifest_node_size; + assert(lsm_manifest_memory_multiplier == MiB); + break :lsm_manifest_memory_multiplier lsm_manifest_memory_multiplier; +}; + +/// The LSM will attempt to coalesce a table if it is less full than this threshold. +pub const lsm_table_coalescing_threshold_percent = + config.cluster.lsm_table_coalescing_threshold_percent; + +comptime { + assert(lsm_table_coalescing_threshold_percent > 0); // Ensure that coalescing is possible. + assert(lsm_table_coalescing_threshold_percent < 100); // Don't coalesce full tables. +} + +/// The number of milliseconds between each replica tick, the basic unit of time in TigerBeetle. +/// Used to regulate heartbeats, retries and timeouts, all specified as multiples of a tick. +pub const tick_ms = config.process.tick_ms; + +/// The conservative round-trip time at startup when there is no network knowledge. +/// Adjusted dynamically thereafter for RTT-sensitive timeouts according to network congestion. +/// This should be set higher rather than lower to avoid flooding the network at startup. +pub const rtt_ticks = config.process.rtt.to_ms() / tick_ms; + +/// Maximum RTT, to prevent too-long timeouts. +pub const rtt_max_ticks = config.process.rtt_max.to_ms() / tick_ms; + +/// The multiple of round-trip time for RTT-sensitive timeouts. +pub const rtt_multiple = 2; + +/// The min/max bounds of exponential backoff (and jitter) to add to RTT-sensitive timeouts. +pub const backoff_min_ticks = config.process.backoff_min.to_ms() / tick_ms; +pub const backoff_max_ticks = config.process.backoff_max.to_ms() / tick_ms; + +/// The maximum amount of time we allow a peer to remain in unknown status, +/// after which it is terminated in favour of unconnected replicas (see +/// `reclaim_connection` in message_bus.zig). Peers must transition from unknown +/// to client or replica status in a reasonable amount of time using the +/// messages exchanged (see `peer_type` in message_header.zig). Pessimistically +/// set TTL to 45 seconds, since `ping_client` is sent by clients every 30 seconds. +pub const message_bus_unknown_time_to_live = stdx.Duration.seconds(45); + +/// The maximum skew between two clocks to allow when considering them to be in agreement. +/// The principle is that no two clocks tick exactly alike but some clocks more or less agree. +/// The maximum skew across the cluster as a whole is this value times the total number of clocks. +/// The cluster will be unavailable if the majority of clocks are all further than this value apart. +/// Decreasing this reduces the probability of reaching agreement on synchronized time. +/// Increasing this reduces the accuracy of synchronized time. +pub const clock_offset_tolerance_max = config.process.clock_offset_tolerance_max; + +/// The amount of time before the clock's synchronized epoch is expired. +/// If the epoch is expired before it can be replaced with a new synchronized epoch, then this most +/// likely indicates either a network partition or else too many clock faults across the cluster. +/// A new synchronized epoch will be installed as soon as these conditions resolve. +pub const clock_epoch_max = config.process.clock_epoch_max; + +/// The amount of time to wait for enough accurate samples before synchronizing the clock. +/// The more samples we can take per remote clock source, the more accurate our estimation becomes. +/// This impacts cluster startup time as the primary must first wait for synchronization to +/// complete. +pub const clock_synchronization_window_min = config.process.clock_synchronization_window_min; + +/// The amount of time without agreement before the clock window is expired and a new window opened. +/// This happens where some samples have been collected but not enough to reach agreement. +/// The quality of samples degrades as they age so at some point we throw them away and start over. +/// This eliminates the impact of gradual clock drift on our clock offset (clock skew) measurements. +/// If a window expires because of this then it is likely that the clock epoch will also be expired. +pub const clock_synchronization_window_max = config.process.clock_synchronization_window_max; + +/// TigerBeetle uses asserts proactively, unless they severely degrade performance. For production, +/// 5% slow down might be deemed critical, tests tolerate slowdowns up to 5x. Tests should be +/// reasonably fast to make deterministic simulation effective. `constants.verify` disambiguate the +/// two cases. +/// +/// In the control plane (eg, vsr proper) assert unconditionally. Due to batching, control plane +/// overhead is negligible. It is acceptable to spend O(N) time to verify O(1) computation. +/// +/// In the data plane (eg, lsm tree), finer grained judgement is required. Do an unconditional O(1) +/// assert before an O(N) loop (e.g, a bounds check). Inside the loop, it might or might not be +/// feasible to add an extra assert per iteration. In the latter case, guard the assert with `if +/// (constants.verify)`, but prefer an unconditional assert unless benchmarks prove it to be costly. +/// +/// In the data plane, never use O(N) asserts for O(1) computations --- due to do randomized testing +/// the overall coverage is proportional to the number of tests run. Slow thorough assertions +/// decrease the overall test coverage. +/// +/// Specific data structures might use a comptime parameter, to enable extra costly verification +/// only during unit tests of the data structure. +pub const verify = config.process.verify; + +/// The maximum number of bytes to use for compaction blocks. +pub const compaction_block_memory_size_max = std.math.maxInt(u32) * block_size; + +/// Maximum number of tree scans that can be performed by a single query. +/// NOTE: Each condition in a query is a scan, for example `WHERE a=0 AND b=1` needs 2 scans. +pub const lsm_scans_max = config.cluster.lsm_scans_max; + +/// Processing more than this amount of messages in a single event loop turn issues a warning. +pub const bus_message_burst_warn_min = 8; diff --git a/ocam/src/copyhound.zig b/ocam/src/copyhound.zig new file mode 100644 index 00000000..ee3da176 --- /dev/null +++ b/ocam/src/copyhound.zig @@ -0,0 +1,203 @@ +//! Analyze LLVM IR to find: +//! - large memcpy calls +//! - functions with many copies due to monomorphisation and big total size +//! +//! To get a file with IR, use `-femit-llvm-ir` cli argument for `zig build-exe` or +//! +//! $ zig build -Drelease -Demit-llvm-ir +//! +//! Pass the resulting .ll file to copyhound on stdin. +//! +//! ## Needless memcpy +//! +//! Run: +//! +//! $ zig run -OReleaseSafe src/copyhound.zig -- memcpy --bytes 128 < tigerbeetle.ll \ +//! | sort -n -k 2 +//! +//! This only detects memory copies with comptime-know size (eg, when you copy a `T`, rather than a +//! `[]T`). +//! +//! ## Code size +//! +//! Run: +//! +//! $ zig run -OReleaseSafe src/copyhound.zig -- funcsize < tigerbeetle.ll \ +//! | awk '{a[$1] += $2; b[$1] += 1} END {for (i in a) print i, b[i], a[i]}' \ +//! | sort -n -k 3 +//! +//! This will print every function name (first column), number of times it was monomorphized (second +//! column) and the total size of all monorphisations (third column). + +const std = @import("std"); +const stdx = @import("stdx"); + +const MiB = stdx.MiB; + +const log = std.log; +pub const std_options = .{ + .log_level = .info, +}; + +const CLIArgs = union(enum) { + memcpy: struct { bytes: u32 }, + funcsize, +}; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var arena = std.heap.ArenaAllocator.init(gpa.allocator()); + defer arena.deinit(); + + const allocator = arena.allocator(); + + var flags = stdx.Flags.init(allocator); + defer flags.deinit(allocator); + + const cli_args = flags.parse(CLIArgs); + + const line_buffer = try allocator.alloc(u8, MiB); + const func_buf = try allocator.alloc(u8, 4096); + + const stdin = std.io.getStdIn(); + var buf_reader = std.io.bufferedReader(stdin.reader()); + var in_stream = buf_reader.reader(); + + const stdout = std.io.getStdOut(); + var buf_writer = std.io.bufferedWriter(stdout.writer()); + defer buf_writer.flush() catch {}; + + var out_stream = buf_writer.writer(); + + var current_function: ?[]const u8 = null; + var current_function_size: u32 = 0; + while (try in_stream.readUntilDelimiterOrEof(line_buffer, '\n')) |line| { + if (std.mem.startsWith(u8, line, "define ")) { + current_function = extract_function_name(line, func_buf) orelse { + log.err("can't parse define line={s}", .{line}); + return error.BadDefine; + }; + continue; + } + + if (current_function) |function| { + if (std.mem.eql(u8, line, "}")) { + if (cli_args == .funcsize) { + try out_stream.print("{s} {}\n", .{ function, current_function_size }); + } + current_function = null; + current_function_size = 0; + continue; + } + current_function_size += 1; + if (stdx.cut(line, "@llvm.memcpy")) |cut| { + const size = extract_memcpy_size(cut.suffix) orelse { + log.err("can't parse memcpy call line={s}", .{line}); + return error.BadMemcpy; + }; + if (cli_args == .memcpy) { + if (size > cli_args.memcpy.bytes) { + try out_stream.print("{s} {}\n", .{ function, size }); + } + } + } + } + } +} + +/// Demangles function name by removing all comptime arguments (which are always inside `()`). +fn extract_function_name(define: []const u8, buf: []u8) ?[]const u8 { + if (!std.mem.endsWith(u8, define, "{")) return null; + + _, const mangled_name = stdx.cut(define, "@") orelse return null; + var buf_count: usize = 0; + var level: u32 = 0; + for (mangled_name) |c| { + switch (c) { + '(' => level += 1, + ')' => level -= 1, + '"' => {}, + else => { + if (level > 0) continue; + if (c == ' ') return buf[0..buf_count]; + if (buf_count == buf.len) return null; + buf[buf_count] = c; + buf_count += 1; + }, + } + } else return null; +} + +test "extract_function_name" { + var buf: [1024]u8 = undefined; + const func_name = extract_function_name( + \\define internal fastcc i64 @".vsr.vsr.clock.ClockType(.vsr.time.Time).monotonic" + ++ + \\(%.vsr.time.Time* %.0.1.val) unnamed_addr #1 !dbg !71485 { + , &buf).?; + try std.testing.expectEqualStrings(".vsr.vsr.clock.ClockType.monotonic", func_name); +} + +/// Parses out the size argument of an memcpy call. +fn extract_memcpy_size(memcpy_call: []const u8) ?u32 { + _, const call_args = stdx.cut(memcpy_call, "(") orelse return null; + var level: u32 = 0; + var arg_count: u32 = 0; + + const args_after_size = for (call_args, 0..) |c, i| { + switch (c) { + '(' => level += 1, + ')' => level -= 1, + ',' => { + if (level > 0) continue; + arg_count += 1; + if (!std.mem.startsWith(u8, call_args[i..], ", ")) return null; + if (arg_count == 2) break call_args[i + 2 ..]; + }, + else => {}, + } + } else return null; + + const size_arg, _ = stdx.cut(args_after_size, ",") orelse return null; + + _, const size_value = stdx.cut(size_arg, " ") orelse return null; + + // Runtime-known memcpy size, assume that's OK. + if (std.mem.startsWith(u8, size_value, "%")) return 0; + + return stdx.parse_int(u32, size_value, .{}) catch null; +} + +test "extract_memcpy_size" { + const T = struct { + fn check( + line: []const u8, + want: ?u32, + ) !void { + const got = extract_memcpy_size(line); + try std.testing.expectEqual(want, got); + } + }; + + // One argument is a nested expression with a function call. + try T.check( + " call void @llvm.memcpy.p0i8.p0i8.i64(i8* align 8 %0, i8* align 8 bitcast(" ++ + "{ void (i32, %std.os.linux.siginfo_t*, i8*)*," ++ + " [32 x i32], <{ i32, [4 x i8] }>, void ()* }*" ++ + " @8 to i8*), i64 152, i1 false)", + 152, + ); + + // The argument is `%6` --- a runtime value. + try T.check( + \\ call void @llvm.memcpy.p0i8.p0i8.i64(i8* align 1 %8, i8* align 1 %4, i64 %6, i1 false) + , 0); +} + +/// Format and print an error message followed by the usage string to stderr, +/// then exit with an exit code of 1. +pub fn fatal(comptime fmt_string: []const u8, args: anytype) noreturn { + const stderr = std.io.getStdErr().writer(); + stderr.print("error: " ++ fmt_string ++ "\n", args) catch {}; + std.posix.exit(1); +} diff --git a/ocam/src/counting_allocator.zig b/ocam/src/counting_allocator.zig new file mode 100644 index 00000000..9a46cd88 --- /dev/null +++ b/ocam/src/counting_allocator.zig @@ -0,0 +1,72 @@ +const std = @import("std"); +const Alignment = std.mem.Alignment; + +const CountingAllocator = @This(); + +parent_allocator: std.mem.Allocator, +alloc_size: u64 = 0, +free_size: u64 = 0, + +pub fn init(parent_allocator: std.mem.Allocator) CountingAllocator { + return .{ .parent_allocator = parent_allocator }; +} + +pub fn deinit(self: *CountingAllocator) void { + self.* = undefined; +} + +pub fn allocator(self: *CountingAllocator) std.mem.Allocator { + return .{ + .ptr = self, + .vtable = &.{ + .alloc = alloc, + .resize = resize, + .remap = remap, + .free = free, + }, + }; +} + +pub fn live_size(self: *CountingAllocator) u64 { + return self.alloc_size - self.free_size; +} + +fn alloc(ctx: *anyopaque, len: usize, ptr_align: Alignment, ret_addr: usize) ?[*]u8 { + const self: *CountingAllocator = @ptrCast(@alignCast(ctx)); + self.alloc_size += len; + return self.parent_allocator.rawAlloc(len, ptr_align, ret_addr); +} + +fn resize(ctx: *anyopaque, buf: []u8, buf_align: Alignment, new_len: usize, ret_addr: usize) bool { + const self: *CountingAllocator = @ptrCast(@alignCast(ctx)); + + if (self.parent_allocator.rawResize(buf, buf_align, new_len, ret_addr)) { + if (new_len > buf.len) { + self.alloc_size += new_len - buf.len; + } else { + self.free_size += buf.len - new_len; + } + return true; + } else { + return false; + } +} + +fn remap(ctx: *anyopaque, buf: []u8, buf_align: Alignment, new_len: usize, ret_addr: usize) ?[*]u8 { + const self: *CountingAllocator = @ptrCast(@alignCast(ctx)); + if (self.parent_allocator.rawRemap(buf, buf_align, new_len, ret_addr)) |remapped| { + if (new_len > buf.len) { + self.alloc_size += new_len - buf.len; + } else { + self.free_size += buf.len - new_len; + } + return remapped; + } + return null; +} + +fn free(ctx: *anyopaque, buf: []u8, buf_align: Alignment, ret_addr: usize) void { + const self: *CountingAllocator = @ptrCast(@alignCast(ctx)); + self.free_size += buf.len; + return self.parent_allocator.rawFree(buf, buf_align, ret_addr); +} diff --git a/ocam/src/devhub/devhub.js b/ocam/src/devhub/devhub.js new file mode 100644 index 00000000..6e592464 --- /dev/null +++ b/ocam/src/devhub/devhub.js @@ -0,0 +1,652 @@ +// Code powering "developer dashboard" aka devhub, at . +// +// At the moment, it isn't clear what's the right style for this kind of non-Zig developer facing +// code, so the following is somewhat arbitrary: +// +// - snake_case naming +// - `deno fmt` for style +// - no TypeScript, no build step + +window.onload = () => + Promise.all([ + main_release_rotation(), + main_seeds(), + main_metrics(), + ]); + +function assert(condition) { + if (!condition) { + alert("Assertion failed"); + throw new Error("Assertion failed"); + } +} + +function main_release_rotation() { + const release_manager = get_release_manager(); + for (const week of ["previous", "current", "next"]) { + document.querySelector(`#release-${week}`).textContent = + release_manager[week]; + } + + function get_release_manager() { + const shift = 2; // Adjust when changing the set of candidates to avoid shifts. + const week = get_week(new Date()) + shift; + const candidates = [ + "batiati", + "cb22", + "chaitanyabhandari", + "fabioarnold", + "GeorgKreuzmayr", + "lewisdaly", + "matklad", + "maxi-k", + "sentientwaffle", + "toziegler", + ]; + for (let i = 0; i < candidates.length - 1; i++) { + assert(candidates[i].toLowerCase() <= candidates[i + 1].toLowerCase()); + } + + return { + previous: candidates[week % candidates.length], + current: candidates[(week + 1) % candidates.length], + next: candidates[(week + 2) % candidates.length], + }; + } +} + +async function main_seeds() { + const duration_week = 1000 * 60 * 60 * 24 * 7; + const earlier = new Date(Date.now() - duration_week); + + const data_url = + "https://raw.githubusercontent.com/tigerbeetle/devhubdb/main/fuzzing/data.json"; + const issues_url = + "https://api.github.com/repos/tigerbeetle/tigerbeetle/issues?per_page=200"; + const logs_base = + "https://raw.githubusercontent.com/tigerbeetle/devhubdb/main/"; + const flakes_url = + `https://api.github.com/repos/tigerbeetle/tigerbeetle/actions/runs?${new URLSearchParams( + { + branch: "main", + per_page: 100, + created: `>=${earlier.toISOString().split("T")[0]}`, + status: "failure", + exclude_pull_requests: "true", + }, + )}`; + + const [records, issues, flakes] = await Promise.all([ + fetch_json(data_url), + fetch_json(issues_url), + fetch_json(flakes_url), + ]); + + const pulls = issues.filter((issue) => issue.pull_request); + const pulls_by_url = new Map( + pulls.map((pull) => [pull.pull_request.html_url, pull]), + ); + const open_pull_requests = new Set(pulls.map((it) => it.number)); + const untriaged_issues = issues.filter((issue) => + !issue.pull_request && + !issue.labels.map((label) => label.name).includes("triaged") + ); + document.querySelector("#untriaged-issues-count").innerText = + untriaged_issues.length; + if (untriaged_issues.length) { + document.querySelector("#untriaged-issues-count").classList.add( + "untriaged", + ); + } + + document.querySelector("#flakes-count").innerText = flakes.total_count; + if (flakes.total_count) { + document.querySelector("#flakes-count").classList.add("untriaged"); + } + + // Filtering: + // - By default, show one seed per fuzzer per commit; exclude successes for the main branch and + // already merged pull requests. + // - Clicking on the fuzzer cell in the table shows all seeds for this fuzzer/commit pair. + // - "show all" link (in the .html) disables filtering completely. + const query = new URLSearchParams(document.location.search); + const query_fuzzer = query.get("fuzzer"); + const query_commit = query.get("commit"); + const query_all = query.get("all") !== null; + const fuzzers_with_failures = new Set(); + + const table_dom = document.querySelector("#seeds>tbody"); + let commit_previous = undefined; + let seed_fail_count = 0; + let vpm = undefined; // VOPRs per minute + + for (const record of records) { + if ( + !vpm && record.fuzzer === "vopr" && is_main(record) && record.count > 100 + ) { + const elapsed_seconds = Date.now() / 1000 - record.seed_timestamp_start; + vpm = (record.count * 60) / elapsed_seconds; + } + + let include = undefined; + if (query_all) { + include = true; + } else if (query_fuzzer || query_commit) { + include = (!query_fuzzer || record.fuzzer == query_fuzzer) && + (!query_commit || record.commit_sha == query_commit); + } else if ( + pull_request_number(record) && + !open_pull_requests.has(pull_request_number(record)) + ) { + include = false; + } else if (record.fuzzer === "canary" && !is_main(record)) { + include = false; + } else if (record.fuzzer === "vopr" && is_release(record)) { + include = true; + } else { + include = (!record.ok || pull_request_number(record) !== undefined) && + !fuzzers_with_failures.has(record.branch + record.fuzzer); + if (include) fuzzers_with_failures.add(record.branch + record.fuzzer); + } + + if (!include) continue; + + const seed_duration_ms = + (record.seed_timestamp_end - record.seed_timestamp_start) * 1000; + const seed_freshness_ms = Date.now() - (record.seed_timestamp_start * 1000); + const staleness_threshold_ms = 3 * 60 * 60 * 1000; + const canery_is_stale = record.fuzzer === "canary" && + !pull_request_number(record) && + seed_freshness_ms > staleness_threshold_ms; + const staleness_warning = canery_is_stale + ? '⚠️' + : ""; + + const row_dom = document.createElement("tr"); + + let seed_success = record.fuzzer === "canary" ? !record.ok : record.ok; + if (canery_is_stale) seed_success = false; + if (seed_success) { + row_dom.classList.add("success"); + } else { + seed_fail_count++; + } + if (record.commit_sha != commit_previous) { + commit_previous = record.commit_sha; + row_dom.classList.add("group-start"); + } + + const pull = pulls_by_url.get(record.branch); + let commit_extra = "(unknown)"; + if (pull_request_number(record)) { + commit_extra = `#${ + pull_request_number(record) + }`; + } else if (is_main(record)) { + commit_extra = "(main)"; + } else if (is_release(record)) { + commit_extra = "(release)"; + } + const log_link = record.log + ? ` (log)` + : ""; + row_dom.innerHTML = ` + + ${ + record.commit_sha.substring(0, 7) + } + ${commit_extra} + + ${pull ? pull.user.login : ""} + ${record.fuzzer} + ${record.command} + ${log_link} + + + ${staleness_warning} + + + ${record.count.toLocaleString("en-US").replace(/,/g, " ")} + + `; + table_dom.appendChild(row_dom); + } + + if (vpm) { + document.querySelector("#vpm").innerHTML = `${Math.floor(vpm)} VPM`; + } + + let main_branch_fail = 0; + let main_branch_ok = 0; + let main_branch_canary = 0; + for (const record of records) { + if (is_main(record)) { + if (record.fuzzer === "canary") { + main_branch_canary += record.count; + } else if (record.ok) { + main_branch_ok += record.count; + } else { + main_branch_fail += record.count; + } + } + } + if (main_branch_fail > 0 && !query_commit && !query_fuzzer) { + // When there are failures on main and we don't query for a specific commit/fuzzer, + // there should be failing seeds in our table. + assert(seed_fail_count > 0); + } +} + +async function main_metrics() { + const data_url = + "https://raw.githubusercontent.com/tigerbeetle/devhubdb/main/devhub/data.json"; + const data = await (await fetch(data_url)).text(); + const max_batches = 200; + const all_batches = data.split("\n") + .filter((it) => it.length > 0) + .map((it) => JSON.parse(it)) + .reverse(); + + const query = new URLSearchParams(document.location.search); + const query_metric = query.get("metric"); + + render_performance_results(all_batches, query_metric); + + let batches = all_batches; + if (query_metric) { + batches = batches.filter((batch) => + batch.metrics.some((metric) => metric.name === query_metric) + ); + } + batches = batches.slice(0, max_batches); + + let series = batches_to_series(batches); + if (query_metric) series = series.filter((it) => it.name === query_metric); + document.querySelector("#metrics-show-all").hidden = !query_metric; + plot_series(series, document.querySelector("#charts"), batches.length); +} + +function is_main(record) { + return record.branch === "https://github.com/tigerbeetle/tigerbeetle"; +} + +function is_release(record) { + return record.branch === + "https://github.com/tigerbeetle/tigerbeetle/tree/release"; +} + +function pull_request_number(record) { + const pr_prefix = "https://github.com/tigerbeetle/tigerbeetle/pull/"; + if (record.branch.startsWith(pr_prefix)) { + const pr_number = record.branch.substring( + pr_prefix.length, + record.branch.length, + ); + return parseInt(pr_number, 10); + } + return undefined; +} + +function format_duration(duration_ms) { + const milliseconds = duration_ms % 1000; + const seconds = Math.floor((duration_ms / 1000) % 60); + const minutes = Math.floor((duration_ms / (1000 * 60)) % 60); + const hours = Math.floor((duration_ms / (1000 * 60 * 60)) % 24); + const days = Math.floor(duration_ms / (1000 * 60 * 60 * 24)); + const parts = []; + + if (days > 0) { + parts.push(`${days}d`); + } + if (hours > 0) { + parts.push(`${hours}h`); + } + if (minutes > 0) { + parts.push(`${minutes}m`); + } + if (days == 0) { + if (seconds > 0 || parts.length === 0) { + parts.push(`${seconds}s`); + } + if (hours == 0 && minutes == 0) { + if (milliseconds > 0) { + parts.push(`${milliseconds}ms`); + } + } + } + + return parts.join(" "); +} + +// Returns the ISO week of the date. +// +// Source: https://weeknumber.com/how-to/javascript +function get_week(date) { + date = new Date(date.getTime()); + date.setHours(0, 0, 0, 0); + // Thursday in current week decides the year. + date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7); + // January 4 is always in week 1. + const week1 = new Date(date.getFullYear(), 0, 4); + // Adjust to Thursday in week 1 and count number of weeks from date to week1. + return 1 + Math.round( + ((date.getTime() - week1.getTime()) / 86400000 - + 3 + (week1.getDay() + 6) % 7) / 7, + ); +} + +// The input data is array of runs, where a single run contains many measurements (eg, file size, +// build time). +// +// This function "transposes" the data, such that measurements with identical labels are merged to +// form a single array which is what we want to plot. +// +// This doesn't depend on particular plotting library though. +function batches_to_series(batches) { + const results = new Map(); + for (const [index, batch] of batches.entries()) { + for (const metric of batch.metrics) { + if (!results.has(metric.name)) { + results.set(metric.name, { + name: metric.name, + unit: undefined, + value: [], + git_commit: [], + timestamp: [], + }); + } + + const series = results.get(metric.name); + assert(series.name == metric.name); + + if (series.unit) { + assert(series.unit == metric.unit); + } else { + series.unit = metric.unit; + } + + // Even though our x-axis is time, we want to spread things out evenly by batch, rather than + // group according to time. Apex charts is much quicker when given an x value, even though it + // isn't strictly needed. + series.value.push([batches.length - index, metric.value]); + series.git_commit.push(batch.attributes.git_commit); + series.timestamp.push(batch.timestamp); + } + } + + return Array.from(results.values()); +} + +function render_performance_results(batches, query_metric) { + const performance_tests = [ + { + title: "CSO", + metric: "Cluster TPS", + }, + { + title: "CPO", + metric: "CPO TPS", + }, + ]; + const freshness_threshold_ms = 24 * 60 * 60 * 1000; + const table_dom = document.querySelector("#performance-results>tbody"); + + for (const test of performance_tests) { + const batch = batches.find((batch) => + batch.metrics.some((metric) => metric.name === test.metric) + ); + const row_dom = document.createElement("tr"); + + if (batch) { + const metric = batch.metrics.find((metric) => + metric.name === test.metric + ); + const freshness_ms = Date.now() - batch.timestamp * 1000; + + if (freshness_ms <= freshness_threshold_ms) { + row_dom.classList.add("success"); + } + if (query_metric === test.metric) { + row_dom.classList.add("selected"); + } + + row_dom.innerHTML = ` + ${test.title} + + + ${batch.attributes.git_commit.substring(0, 7)} + + + + + ${format_count(metric.value)} TPS + + `; + } else { + row_dom.innerHTML = ` + ${test.title} + N/A + N/A + N/A + `; + } + + table_dom.appendChild(row_dom); + } +} + +function plot_series(series_list, root_node, batch_count) { + const now_seconds = Date.now() / 1000; + const outlier_count = 3; + + const outile_indices = series_list.map( + (series, index) => ({ + series, + index, + score: outlier_score(series, now_seconds), + }), + ) + .sort((a, b) => b.score - a.score) + .slice(0, outlier_count) + .map((it) => it.index); + + const series_list_ordered = [ + ...outile_indices.map((index) => series_list[index]), + ...series_list.filter((_, index) => !outile_indices.includes(index)), + ]; + + for (const [series_index, series] of series_list_ordered.entries()) { + const options = { + title: { + text: series.name, + }, + chart: { + id: series.name, + group: "devhub", + type: "line", + height: "400px", + animations: { + enabled: false, + }, + events: { + dataPointSelection: (event, chartContext, { dataPointIndex }) => { + window.open( + "https://github.com/tigerbeetle/tigerbeetle/commit/" + + series.git_commit[dataPointIndex], + ); + }, + }, + }, + markers: { + size: 4, + }, + colors: series_index < outlier_count ? ["var(--red-10)"] : undefined, + series: [{ + name: series.name, + data: series.value, + }], + xaxis: { + categories: Array(series.value[series.value.length - 1][0]).fill("") + .concat( + series.timestamp.map((timestamp) => + format_date_day(new Date(timestamp * 1000)) + ).reverse(), + ), + min: 0, + max: batch_count, + tickAmount: 15, + axisTicks: { + show: false, + }, + tooltip: { + enabled: false, + }, + }, + tooltip: { + enabled: true, + shared: false, + intersect: true, + x: { + formatter: function (val, { dataPointIndex }) { + const formattedDate = format_date_day_time( + new Date(series.timestamp[dataPointIndex] * 1000), + ); + return `

${ + series.git_commit[dataPointIndex] + }
${formattedDate}
`; + }, + }, + }, + }; + + const formatters = { + bytes: format_bytes, + ms: format_duration, + s: (s) => format_duration(s * 1000), + count: format_count, + }; + + if (formatters[series.unit]) { + options.yaxis = { + labels: { + formatter: formatters[series.unit], + }, + }; + } + + const div = document.createElement("div"); + root_node.append(div); + const chart = new ApexCharts(div, options); + chart.render(); + } +} + +// Heuristic function that takes a time series and returns a number +// proportional to week-on-week change. +function outlier_score(series, now_seconds) { + const WEEK = 7 * 24 * 60 * 60; + + const recent = []; + const baseline = []; + + for (let i = 0; i < series.value.length; i++) { + const value = series.value[i][1]; + const age = now_seconds - series.timestamp[i]; + + if (age <= WEEK) { + recent.push(value); + } else if (age <= 2 * WEEK) { + baseline.push(value); + } else { + // Older than 2 weeks: discarded. + } + } + + if (recent.length === 0 || baseline.length === 0) { + return 0; + } + + const recent_mean = mean(recent); + const baseline_mean = mean(baseline); + + if (baseline_mean === 0) return 0; + + return Math.abs(recent_mean - baseline_mean) / baseline_mean; +} + +function mean(values) { + assert(values.length > 0); + let sum = 0; + for (const v of values) sum += v; + return sum / values.length; +} + +function format_bytes(bytes) { + return format_suffix(bytes, 1024, [ + "Bytes", + "KiB", + "MiB", + "GiB", + "TiB", + "PiB", + "EiB", + "ZiB", + "YiB", + ]); +} + +function format_count(count) { + return format_suffix(count, 1000, ["", "K", "M", "G"]); +} + +function format_suffix(amount, base, progression) { + if (amount == 0) return `0 ${progression[0]}`; + let i = 0; + while (i != progression.length - 1 && Math.pow(base, i + 1) < amount) { + i += 1; + } + return `${parseFloat((amount / Math.pow(base, i)).toFixed(2))} ${ + progression[i] + }`; +} + +function format_date_day(date) { + return format_date(date, false); +} + +function format_date_day_time(date) { + return format_date(date, true); +} + +function format_date(date, include_time) { + assert(date instanceof Date); + + const pad = (number) => String(number).padStart(2, "0"); + + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); // Months are 0-based. + const day = pad(date.getDate()); + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + const seconds = pad(date.getSeconds()); + return include_time + ? `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` + : `${year}-${month}-${day}`; +} + +async function fetch_json(url) { + const response = await fetch(url, { cache: "no-cache" }); + return await response.json(); +} + +function copy_to_clipboard(element) { + navigator.clipboard.writeText(element.innerText).then(() => { + const before = element.innerHTML; + element.innerText = "Copied!"; + setTimeout(() => element.innerHTML = before, 1000); + }); +} diff --git a/ocam/src/devhub/index.html b/ocam/src/devhub/index.html new file mode 100644 index 00000000..f21f8409 --- /dev/null +++ b/ocam/src/devhub/index.html @@ -0,0 +1,118 @@ + + + + + + + TigerBeetle DevHub + + + + + + + + +
+
+
+

Release manager

+ + + + + + + + + + + + + +
Last week:N/A
This week:N/A
Next week:N/A
+
+ + + +
+

Performance tests

+ + + + + + + + + + + +
TestCommitFreshnessResult
+
+
+ +
+

Fuzz runs + Show all + Raw data + +

+ + + + + + + + + + + + + + +
CommitAuthorFuzzerCommandDurationFreshnessCount
+
+ +
+

Metrics + + Nyrkiö + Raw data +

+
+
+
+ + + + + diff --git a/ocam/src/devhub/style.css b/ocam/src/devhub/style.css new file mode 100644 index 00000000..2ea22a42 --- /dev/null +++ b/ocam/src/devhub/style.css @@ -0,0 +1,237 @@ +:root { + --red-10: #DC3E42; + --lime-10: #93C926; + --amber-10: #FFA01C; + --indigo-10: #3358D4; + --gray-1: #FCFCFC; + --gray-4: #E8E8E8; + --gray-11: #646464; + --gray-12: #202020; +} + +@media (prefers-color-scheme: dark) { + :root { + --red-10: #EC5D5E; + --lime-10: #CF0; + --amber-10: #FFCB47; + --indigo-10: #5472E4; + --gray-1: #111111; + --gray-4: #2A2A2A; + --gray-11: #B4B4B4; + --gray-12: #EEEEEE; + } +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--gray-1); + color: var(--gray-12); + font-family: system-ui; + -webkit-font-smoothing: antialiased; + font-size: 14px; + font-size-adjust: 0.53; +} + +a { + color: var(--indigo-10); + text-underline-offset: 0.1em; +} + +h2 { + display: flex; + gap: 16px; + align-items: center; +} + +h2 a, h2 #vpm { + font-size: 14px; + font-weight: normal; +} + +h3 { + font-size: 14px; + line-height: 24px; + padding: 4px 16px; + border-bottom: 1px solid var(--gray-4); +} + +.blink { + animation: blink 0.25s step-start infinite; +} + +@keyframes blink { + 0%, 49% { + visibility: visible; + } + 50%, 100% { + visibility: hidden; + } +} + +.badge { + background-color: var(--indigo-10); + border-radius: 50px; + padding: 1px 6px; + color: var(--gray-1); + font-weight: bold; + + &.untriaged { + background-color: var(--red-10); + } +} + +nav { + display: flex; + align-items: center; + border-bottom: 1px solid var(--gray-4); + height: 56px; + padding: 8px 16px; +} + +#svg-logo-devhub { + fill: var(--lime-10); +} + +main { + display: flex; + flex-direction: column; + gap: 48px; + padding: 24px; +} + +section#top { + display: flex; + flex-direction: row; + gap: 24px; + flex-wrap: wrap; + + section { + height: min-content; + border: 1px solid var(--gray-4); + border-radius: 6px; + } + + table { + line-height: 24px; + padding: 8px 16px; + border-spacing: 0; + } + + th { + text-align: left; + font-weight: normal; + color: var(--gray-11); + } + + #release table { + color: var(--gray-11); + + strong { + font-weight: normal; + color: var(--gray-12); + } + } + + #links div { + padding: 8px 16px; + + p { + line-height: 24px; + } + } + + #performance-results { + padding: 8px 8px; + + th, td { + padding: 0 8px; + } + + tr { + &:not(.success) :not(th) { + color: var(--red-10); + } + + &.success { + color: var(--lime-10); + } + + &.selected { + background-color: var(--gray-4); + } + } + } +} + +section#fuzz-runs { + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; + + table { + border-collapse: separate; + border-spacing: 0; + border: 1px solid var(--gray-4); + border-radius: 6px; + line-height: 24px; + + th { + text-align: left; + } + + th, + td { + padding: 4px 8px; + } + + tr { + &.group-start td { + border-top: 1px solid var(--gray-4); + } + + &.success { + color: var(--lime-10); + } + + &:not(.success) :not(th) { + color: var(--red-10); + } + } + } +} + +section#metrics { + display: flex; + flex-direction: column; + gap: 12px; + + #charts { + border: 1px solid var(--gray-4); + border-radius: 6px; + display: flex; + flex-wrap: wrap; + padding-top: 8px; + gap: 8px; + justify-items: stretch; + + >div { + width: 600px; + } + } +} + +@media (prefers-color-scheme: dark) { + #charts { + color: var(--gray-1); + + >div { + filter: invert(1); + } + } +} diff --git a/ocam/src/direction.zig b/ocam/src/direction.zig new file mode 100644 index 00000000..634eb01e --- /dev/null +++ b/ocam/src/direction.zig @@ -0,0 +1,120 @@ +const std = @import("std"); +const stdx = @import("stdx"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const Elem = std.meta.Elem; + +const binary_search = @import("lsm/binary_search.zig"); + +/// LSM Tree is a sorted array with a monocle and a top hat. +/// +/// We want to iterate it in both directions: +/// - For CDC, you want to learn about all new objects with timestamp>threshold. +/// - For paginated timelines, you want to learn about past objects with timestamp` throughout the stack. +/// +/// Direction encapsulate the logic of "if ascending use < if descending use >". The mnemonic is +/// that usual comparison is horizontal along a number line, but Direction-aware is vertical. +/// +/// In other words, `key_min` and `key_max` track natural ordering, while `key_lower` and +/// `key_upper` are direction-aware. +pub const Direction = enum(u1) { + ascending = 0, + descending = 1, + + pub fn reverse(d: Direction) Direction { + return switch (d) { + .ascending => .descending, + .descending => .ascending, + }; + } + + pub inline fn cmp( + d: Direction, + a: anytype, + comptime op: enum { @"<", @"<=" }, + b: @TypeOf(a), + ) bool { + return switch (op) { + .@"<" => switch (d) { + .ascending => a < b, + .descending => a > b, + }, + .@"<=" => switch (d) { + .ascending => a <= b, + .descending => a >= b, + }, + }; + } + + pub inline fn lower(d: Direction, a: anytype, b: @TypeOf(a)) @TypeOf(a) { + return if (d.cmp(a, .@"<", b)) a else b; + } + + pub inline fn upper(d: Direction, a: anytype, b: @TypeOf(a)) @TypeOf(a) { + return if (d.cmp(a, .@"<", b)) b else a; + } + + pub inline fn slice_peek(d: Direction, slice: anytype) *const Elem(@TypeOf(slice)) { + assert(slice.len > 0); + return switch (d) { + .ascending => &slice[0], + .descending => &slice[slice.len - 1], + }; + } + + pub inline fn slice_pop( + d: Direction, + slice: anytype, + ) struct { Elem(@TypeOf(slice)), @TypeOf(slice) } { + assert(slice.len > 0); + return switch (d) { + .ascending => .{ slice[0], slice[1..] }, + .descending => .{ slice[slice.len - 1], slice[0 .. slice.len - 1] }, + }; + } + + pub inline fn slice_lower_bound( + direction: Direction, + comptime Key: type, + comptime Value: type, + comptime key_from_value: fn (*const Value) callconv(.@"inline") Key, + slice: []const Value, + key: Key, + ) []const Value { + maybe(slice.len == 0); + switch (direction) { + .ascending => { + const start = binary_search.binary_search_values_upsert_index( + Key, + Value, + key_from_value, + slice, + key, + .{ .mode = .lower_bound }, + ); + + return if (start == slice.len) &.{} else slice[start..]; + }, + .descending => { + const end = end: { + const index = binary_search.binary_search_values_upsert_index( + Key, + Value, + key_from_value, + slice, + key, + .{ .mode = .upper_bound }, + ); + break :end index + @intFromBool( + index < slice.len and key_from_value(&slice[index]) <= key, + ); + }; + + return if (end == 0) &.{} else slice[0..end]; + }, + } + } +}; diff --git a/ocam/src/docs_website/.vale.ini b/ocam/src/docs_website/.vale.ini new file mode 100644 index 00000000..34b1f5a7 --- /dev/null +++ b/ocam/src/docs_website/.vale.ini @@ -0,0 +1,18 @@ +StylesPath = styles + +Vocab = docs + +MinAlertLevel = suggestion + +[*.{md}] +# ^ This section applies to only Markdown files. +# +# You can change (or add) file extensions here +# to apply these settings to other file types. +# +# For example, to apply these settings to both +# Markdown and reStructuredText: +# +# [*.{md,rst}] +BasedOnStyles = Vale +Vale.Terms = NO diff --git a/ocam/src/docs_website/README.md b/ocam/src/docs_website/README.md new file mode 100644 index 00000000..344edc70 --- /dev/null +++ b/ocam/src/docs_website/README.md @@ -0,0 +1,17 @@ +# docs.tigerbeetle.com + +Documentation generator for . Static website is generated via `zig build` +and is pushed to , which is then hosted on GitHub pages. + +The website can also be build from the repository root via `./zig/zig build docs`. + +Overview of the build process: + +* Inputs are Markdown files from `/docs` and `/src/clients/$lang/README.md`. +* Links are checked by `./src/file_checker.zig`. +* Spelling is checked by vale. A list of accepted words is maintained in + `./styles/config/vocabularies/docs/accept.txt`. +* Outputs are static HTML files in the `./zig-out` directory. + +This process is triggered by `ci.zig` in our merge queue (mostly to detect broken links) and by +`release.zig` to push the rendered docs to . diff --git a/ocam/src/docs_website/assets/.nojekyll b/ocam/src/docs_website/assets/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/ocam/src/docs_website/assets/CNAME b/ocam/src/docs_website/assets/CNAME new file mode 100644 index 00000000..cc563821 --- /dev/null +++ b/ocam/src/docs_website/assets/CNAME @@ -0,0 +1 @@ +docs.tigerbeetle.com diff --git a/ocam/src/docs_website/assets/img/favicon.png b/ocam/src/docs_website/assets/img/favicon.png new file mode 100644 index 00000000..c08241ae Binary files /dev/null and b/ocam/src/docs_website/assets/img/favicon.png differ diff --git a/ocam/src/docs_website/assets/img/notfound-dark.webp b/ocam/src/docs_website/assets/img/notfound-dark.webp new file mode 100644 index 00000000..14f68226 Binary files /dev/null and b/ocam/src/docs_website/assets/img/notfound-dark.webp differ diff --git a/ocam/src/docs_website/assets/img/notfound-light.webp b/ocam/src/docs_website/assets/img/notfound-light.webp new file mode 100644 index 00000000..4870cabc Binary files /dev/null and b/ocam/src/docs_website/assets/img/notfound-light.webp differ diff --git a/ocam/src/docs_website/assets/img/preview.webp b/ocam/src/docs_website/assets/img/preview.webp new file mode 100644 index 00000000..85698e7d Binary files /dev/null and b/ocam/src/docs_website/assets/img/preview.webp differ diff --git a/ocam/src/docs_website/assets/js/search.js b/ocam/src/docs_website/assets/js/search.js new file mode 100644 index 00000000..9020b969 --- /dev/null +++ b/ocam/src/docs_website/assets/js/search.js @@ -0,0 +1,361 @@ +let pages = []; +let sections = []; + +const searchInput = document.querySelector("input[type=search]"); +const searchResults = document.querySelector(".search-results"); +const searchNotFound = document.querySelector(".search-notfound"); +const searchStats = document.querySelector(".search-stats"); +const searchHotkey = document.querySelector(".search-box>.hotkey"); +const searchClearButton = document.querySelector(".search-box>.clear-button"); + +let sidenavWasCollapsed = false; +let searchPreviewUsed = false; +document.addEventListener("keydown", event => { + if (event.ctrlKey || event.altKey || event.metaKey) return; + if (event.key === "/" && searchInput !== document.activeElement) { + sidenavWasCollapsed = document.body.classList.contains("sidenav-collapsed"); + document.body.classList.remove("sidenav-collapsed"); + searchInput.focus(); + event.preventDefault(); + } else if (event.key === "Escape") { + if (searchInput === document.activeElement || searchInput.value !== "") { + closeSearch(); + if (searchPreviewUsed) { + history.back(); + searchPreviewUsed = false; + } + event.preventDefault(); + } + } else if (searchInput.value !== "") { + if (event.key === "ArrowDown") { + selectNextResult(); + event.preventDefault(); + } else if (event.key === "ArrowUp") { + selectPreviousResult(); + event.preventDefault(); + } else if (event.key === "Enter") { + const selected = searchResults.querySelector(".selected"); + if (selected) { + if (selected.tagName == "SUMMARY") { + const details = selected.parentElement; + details.open = !details.open; + } else { + closeSearch(); + searchPreviewUsed = false; + } + event.preventDefault(); + } + } + } +}) + +searchInput.addEventListener("focus", () => { + searchHotkey.style.display = "none"; +}); +searchInput.addEventListener("blur", () => { + if (searchInput.value === "") searchHotkey.style.display = "block"; +}); +searchInput.addEventListener("input", onSearchInput); +searchClearButton.addEventListener("click", () => { + closeSearch(); + searchPreviewUsed = false; +}); + +initSearch(); + +async function initSearch() { + const response = await fetch(urlPrefix + "/search-index.json"); + pages = await response.json(); + const parser = new DOMParser(); + pages.forEach((page, pageIndex) => { + const doc = parser.parseFromString(page.html, "text/html"); + const body = doc.querySelector("body"); + + let currentSection; + for (const child of body.children) { + const anchor = child.querySelector(".anchor"); + if (anchor) { + const title = anchor.innerText.replace(/\n/g, " "); + if (!page.title) page.title = title; + currentSection = { + pageIndex, + title, + path: page.path, + hash: anchor.hash, + text: title, + }; + sections.push(currentSection); + } else if (currentSection) { + currentSection.text += " " + child.innerText.replace(/\n/g, " "); + } + } + }); + + if (searchInput.value) onSearchInput(); // Repeat search once the index is fetched. +} + +function onSearchInput() { + const groups = search(searchInput.value); + let menus = []; + for (const group of groups) { + const details = document.createElement("details"); + menus.push(details); + details.open = true; + const summary = document.createElement("summary"); + details.appendChild(summary); + summary.pageIndex = group.pageIndex; + summary.href = urlPrefix + "/" + if (group.hits[0].section.path) summary.href += group.hits[0].section.path + "/"; + assert(URL.canParse(summary.href, location.href)); + const p = document.createElement("p"); + summary.appendChild(p); + p.innerText = pages[group.pageIndex].title; + const menu = document.createElement("ol"); + details.appendChild(menu); + for (const result of group.hits) { + const li = document.createElement("li"); + menu.appendChild(li); + li.className = "item"; + const a = document.createElement("a"); + li.appendChild(a); + a.addEventListener("click", (e) => { + e.preventDefault(); + selectResult(a); + }); + a.href = urlPrefix + "/"; + if (result.section.path) a.href += result.section.path + "/"; + a.href += result.section.hash; + assert(URL.canParse(a.href, location.href)); + a.pageIndex = result.section.pageIndex; + const h3 = document.createElement("h3"); + a.appendChild(h3); + h3.innerText = result.section.title; + const p = document.createElement("p"); + a.appendChild(p); + p.innerText = result.context; + } + } + searchResults.replaceChildren(...menus); + + const hitCount = groups.reduce((sum, group) => sum + group.hits.length, 0); + const resultText = hitCount === 1 ? "result" : "results"; + const pageText = groups.length === 1 ? "page" : "pages"; + searchStats.innerText = `${hitCount} ${resultText} on ${groups.length} ${pageText}` + + highlightText(searchInput.value, searchResults); + + const searchActive = searchInput.value !== ""; + if (searchActive) { + leftPane.classList.add("search-active"); + } else { + leftPane.classList.remove("search-active"); + } + searchNotFound.style.display = searchActive && hitCount === 0 ? "flex" : "none"; +} + +function search(term, limit = 100) { + if (term.length === 0) return []; + const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escapedTerm, 'gi'); + let hitCount = 0; + let groups = []; + let currentGroup = null; + for (const section of sections) { + const match = regex.exec(section.text); + if (match) { + const firstIndex = match.index; + const count = section.text.match(regex).length; + const hit = { firstIndex, count, section }; + hit.context = makeContext(hit.section.text, hit.firstIndex, term.length); + if (!currentGroup || currentGroup.pageIndex !== section.pageIndex) { + currentGroup = { pageIndex: section.pageIndex, hits: [] }; + currentGroup.order = section.pageIndex; + const titleMatch = regex.exec(pages[section.pageIndex].title); + if (titleMatch) currentGroup.order -= 1000; + groups.push(currentGroup); + } + currentGroup.hits.push(hit); + if (++hitCount == limit) break; + } + } + groups.sort((a, b) => a.order - b.order); + + return groups; +} + +function makeContext(text, i, length) { + const windowSizeLeft = 15; + const windowSizeRight = 200; + const highlight = text.slice(i, i + length); + + let contextLeft = ""; + let i0 = Math.max(0, i - windowSizeLeft); + if (i0 > 0) contextLeft = "..."; + while (i0 > 0 && text[i0] !== ' ') i0--; + contextLeft = contextLeft + text.slice(i0, i).trimLeft(); + + let contextRight = ""; + let i1 = Math.min(text.length, i + length + windowSizeRight); + if (i1 < text.length) contextRight = "..."; + while (i1 < text.length && text[i1] !== ' ') i1++; + contextRight = text.slice(i + length, i1).trimRight() + contextRight; + + return contextLeft + highlight + contextRight; +} + +function selectResult(node) { + if (isMobileView()) { + closeSearch(); + document.body.classList.remove("mobile-expanded"); + location.href = node.href; + return; + } + searchResults.querySelectorAll(".selected").forEach(r => r.classList.remove("selected")); + node.classList.add("selected"); + scrollIntoViewIfNeeded(node, searchResults.parentNode); + + // Show page preview. + const page = pages[node.pageIndex]; + content.innerHTML = page.html; + addContentEventHandlers(); + const state = { pageIndex: node.pageIndex }; + if (searchPreviewUsed) { + history.replaceState(state, page.title, node.href); + } else { + history.pushState(state, page.title, node.href); + searchPreviewUsed = true; + } + statePathname = location.pathname; + document.title = page.title; + handleAnchor(); + highlightText(searchInput.value, content); + if (node.tagName == "A") markActiveHighlight(content); +} + +function markActiveHighlight(container) { + let element = container.firstElementChild; + if (location.hash) { + element = document.getElementById(location.hash.slice(1)); + } + for (; element; element = element.nextElementSibling) { + const highlight = element.querySelector(".highlight"); + if (highlight) { + highlight.classList.add("active"); + scrollIntoViewIfNeeded(highlight, container.parentNode); + return; + } + } +} + +let statePathname = location.pathname; +window.addEventListener("popstate", (e) => { + if (e.state) { + const page = pages[e.state.pageIndex]; + content.innerHTML = page.html; + addContentEventHandlers(); + syncSideNavWithLocation(); + } else { + if (location.pathname != statePathname) { + location.reload(); + } + } + statePathname = location.pathname; + handleAnchor(); +}); + +function handleAnchor() { + document.querySelectorAll(".target>.anchor").forEach(e => e.parentNode?.classList.remove("target")); + if (location.hash) { + const anchor = document.getElementById(location.hash.slice(1)); + if (anchor) { + anchor.classList.add("target"); + scrollIntoViewIfNeeded(anchor, content.parentNode); + } + } else { + content.parentNode.scrollTop = 0; + } +} +window.addEventListener("load", () => { + handleAnchor(); +}); + +function selectNextResult() { + const nodes = [...searchResults.querySelectorAll("summary, details[open] a")]; + if (nodes.length === 0) return; + const selected = searchResults.querySelector(".selected"); + const i = selected ? nodes.indexOf(selected) + 1 : 0; + selectResult(nodes[i % nodes.length]); +} + +function selectPreviousResult() { + const nodes = [...searchResults.querySelectorAll("summary, details[open] a")]; + if (nodes.length === 0) return; + const selected = searchResults.querySelector(".selected"); + const i = selected ? nodes.indexOf(selected) - 1 : -1; + selectResult(nodes[(i + nodes.length) % nodes.length]); +} + +function scrollIntoViewIfNeeded(element, parent) { + const elementRect = element.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + + const isOutOfView = ( + elementRect.top < parentRect.top || + elementRect.left < parentRect.left || + elementRect.bottom > parentRect.bottom || + elementRect.right > parentRect.right + ); + + if (isOutOfView) { + element.scrollIntoView({ block: 'center', inline: 'center' }); + } +} + +function closeSearch() { + searchInput.blur(); + searchHotkey.style.display = "block"; + searchInput.value = ""; + onSearchInput(); + removeTextHighlight(content); + syncSideNavWithLocation(); + if (sidenavWasCollapsed) document.body.classList.add("sidenav-collapsed"); + document.querySelector("article").focus(); +} + +function highlightText(term, container) { + const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapedTerm})`, 'gi'); + let replacements = []; + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null); + let node; + while (node = walker.nextNode()) { + const parent = node.parentNode; + if (parent && node.nodeValue.match(regex)) { + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + node.nodeValue.replace(regex, (match, p1, index) => { + if (index > lastIndex) { + fragment.appendChild(document.createTextNode(node.nodeValue.slice(lastIndex, index))); + } + const span = document.createElement("span"); + span.className = "highlight"; + span.textContent = match; + fragment.appendChild(span); + lastIndex = index + match.length; + }); + if (lastIndex < node.nodeValue.length) { + fragment.appendChild(document.createTextNode(node.nodeValue.slice(lastIndex))); + } + replacements.push({ parent, fragment, node }); + } + } + replacements.forEach(r => r.parent.replaceChild(r.fragment, r.node)); +} + +function removeTextHighlight(container) { + container.querySelectorAll(".highlight").forEach(h => h.classList.remove("highlight")); +} + +function isMobileView() { + return window.innerWidth < 810; +} diff --git a/ocam/src/docs_website/assets/style/highlight.css b/ocam/src/docs_website/assets/style/highlight.css new file mode 100644 index 00000000..adc0a5d2 --- /dev/null +++ b/ocam/src/docs_website/assets/style/highlight.css @@ -0,0 +1,45 @@ +.content pre { + border-radius: 8px; + background-color: var(--gray-1); + border: 1px solid var(--gray-4); + overflow: hidden; + + code { + display: block; + padding: 12px 16px; + overflow: auto; + color: var(--gray-12); + background-color: unset; + } + + code .co { + color: var(--gray-10); + } + + code .kw, + code .at, + code .cf { + color: var(--red-10); + } + + code .op { + color: var(--red-10); + } + + code .dt { + color: var(--orange-10); + } + + code .st { + color: var(--green-10); + } + + code .dv, + code .fl { + color: var(--sky-10); + } + + code .bu { + color: var(--blue-10); + } +} diff --git a/ocam/src/docs_website/assets/style/style.css b/ocam/src/docs_website/assets/style/style.css new file mode 100644 index 00000000..020bbf22 --- /dev/null +++ b/ocam/src/docs_website/assets/style/style.css @@ -0,0 +1,1237 @@ +:root { + --gray-0: #FFFFFF; + --gray-1: #FCFCFC; + --gray-2: #F9F9F9; + --gray-3: #F0F0F0; + --gray-4: #E8E8E8; + --gray-5: #E0E0E0; + --gray-6: #D9D9D9; + --gray-7: #CECECE; + --gray-8: #BBBBBB; + --gray-9: #8D8D8D; + --gray-10: #838383; + --gray-11: #4B4B4B; + /* change to darker gray for legibility */ + --gray-12: #202020; + + --gray-alpha-3: #0000000E; + + --red-10: #E3325E; + --blue-10: #0588F0; + --green-10: #2B9A66; + --orange-10: #EF5F00; + --yellow-10: #F1A600; + --sky-10: #32C6F6; + --amber-2: #FEFBE9; + --amber-6: #F3D673; + --amber-12: #4F3422; + + --highlight-active: #FFFFFF4D; + --visited-link: #A144AF; + + --primary-1: #0080FF04; + --primary-2: #008CFF0B; + --primary-3: #008FF519; + --primary-4: #009EFF2A; + --primary-5: #0093FF3D; + --primary-6: #0088F653; + --primary-7: #0083EB71; + --primary-8: #0084E6A1; + --primary-9: #0090FFFF; + --primary-10: #0086F0FA; + --primary-11: #006DCBF2; + --primary-12: #002359EE; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --gray-0: #000000; + --gray-1: #111111; + --gray-2: #191919; + --gray-3: #222222; + --gray-4: #2A2A2A; + --gray-5: #313131; + --gray-6: #3A3A3A; + --gray-7: #484848; + --gray-8: #606060; + --gray-9: #6E6E6E; + --gray-10: #7B7B7B; + /* change to lighter gray for legibility */ + --gray-11: #CCCCCC; + --gray-12: #EEEEEE; + + --gray-alpha-3: #FFFFFF21; + + --red-10: #EC5D5E; + --blue-10: #3B9EFF; + --green-10: #33B074; + --orange-10: #FF801F; + --yellow-10: #FFEF5C; + --sky-10: #A8EEFF; + --amber-2: #1D180F; + --amber-6: #5C3D05; + --amber-12: #FFE7B3; + + --highlight-active: #FFFFFFB2; + --visited-link: #FFA51F; + + --primary-1: #D1510004; + --primary-2: #F9B4000B; + --primary-3: #FFAA001E; + --primary-4: #FDB70028; + --primary-5: #FEBB0036; + --primary-6: #FEC40046; + --primary-7: #FDCB225C; + --primary-8: #FDCA327B; + --primary-9: #FFE629FF; + --primary-10: #FFFF57FF; + --primary-11: #FEE949F5; + --primary-12: #FEF6BAF6; + } +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--gray-0); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: 100%; + text-wrap-style: pretty; +} + +code { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + font-size: 85%; +} + +.highlight { + background-color: var(--primary-6); + color: var(--primary-12); + + &.active { + background-color: var(--primary-11); + color: var(--gray-0); + } +} + +summary { + list-style: none; + + &::-webkit-details-marker { + display: none; + } +} + +summary::before { + content: url('data:image/svg+xml,'); + text-align: center; + flex-shrink: 0; + /* color: var(--gray-11); */ + filter: invert(30%); + display: block; + width: 16px; + height: 16px; +} + +@media (prefers-color-scheme: dark) { + summary::before { + filter: invert(80%); + } +} + +details[open]>summary::before { + transform: rotate(90deg); +} + +nav.left-pane { + display: flex; + flex-direction: column; + overflow-y: auto; + width: 310px; + + >.top-wrapper { + position: sticky; + top: 0; + z-index: 1; + + >.top { + backdrop-filter: blur(10px); + background: linear-gradient(var(--gray-0), color-mix(in srgb, var(--gray-0), transparent 50%)); + display: flex; + flex-direction: column; + padding: 16px 16px 12px 16px; + gap: 4px; + } + + &::after { + content: ""; + display: block; + height: 1px; + backdrop-filter: blur(20px); + } + } + + header { + display: flex; + align-items: center; + gap: 12px; + color: var(--gray-12); + font-size: 15px; + line-height: 20px; + container-type: inline-size; + + .logo { + a { + display: block; + color: var(--gray-12); + + svg { + display: block; + } + } + } + + @container (max-width: 224px) { + .logo { + width: 28px; + overflow: hidden; + } + } + + a { + color: var(--gray-11); + text-decoration: none; + + &:hover { + color: var(--gray-12); + } + } + + .line { + width: 1px; + height: 20px; + background-color: var(--gray-6); + } + + .spacer { + flex: 1; + } + + .menu-button { + color: var(--gray-8); + cursor: pointer; + display: none; + width: 24px; + height: 24px; + } + + .collapse-button { + color: var(--gray-8); + cursor: pointer; + display: block; + width: 16px; + height: 16px; + } + } + + .search-box { + @media (scripting: none) { + display: none; + } + + position: relative; + overflow: hidden; + margin-top: 16px; + + .search-icon { + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + color: var(--gray-10); + } + + input[type=search]:focus~.search-icon { + color: var(--gray-12); + } + + .hotkey { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: block; + width: 16px; + height: 16px; + border: 1px solid var(--gray-7); + border-radius: 3px; + + svg { + display: block; + } + } + + .clear-button { + display: none; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + color: var(--gray-11); + cursor: pointer; + + &:hover { + color: var(--gray-12); + } + } + + input[type=search] { + appearance: none; + width: 100%; + height: 34px; + padding: 12px 31px; + background-color: color-mix(in srgb, var(--gray-2), transparent 35%); + border: 1px solid var(--gray-4); + border-radius: 6px; + outline: none; + font-size: 14px; + line-height: 16px; + color: var(--gray-10); + + &::placeholder { + color: var(--gray-10); + } + + &:focus { + padding-left: 30px; + border-width: 2px; + border-color: var(--primary-11); + color: var(--gray-12); + } + + &::-webkit-search-cancel-button { + display: none; + } + + &::-moz-search-cancel-button { + display: none; + } + } + } + + .search-stats { + display: none; + color: var(--gray-10); + font-size: 12px; + line-height: 20px; + padding-left: 32px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + &.search-active { + .search-box { + .hotkey { + display: none; + } + + .clear-button { + display: block; + } + } + + .search-stats { + display: block; + } + + .search-results { + display: block; + } + + nav.side { + display: none; + } + } + + ol { + list-style: none; + } + + summary:has(a.target)::before, + summary.selected::before { + /* color: var(--gray-0); */ + filter: invert(100%); + } + + @media (prefers-color-scheme: dark) { + + summary:has(a.target)::before, + summary.selected::before { + filter: none; + } + } +} + +.search-results { + color: var(--gray-10); + padding: 0 16px 16px 16px; + font-size: 12px; + line-height: 16px; + -webkit-user-select: none; + user-select: none; + display: none; + + summary { + display: flex; + align-items: center; + padding: 6px 8px; + border-radius: 6px; + gap: 8px; + font-size: 12px; + line-height: 20px; + font-weight: 400; + cursor: pointer; + + &::before { + /* color: var(--gray-10); */ + filter: invert(51%); + + @media (prefers-color-scheme: dark) { + filter: invert(48%); + } + } + + p { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &:hover { + background-color: var(--gray-3); + color: var(--gray-12); + + &::before { + /* color: var(--gray-12); */ + filter: invert(12%); + + @media (prefers-color-scheme: dark) { + filter: invert(93%); + } + } + } + + &.selected { + background-color: var(--primary-9); + color: var(--gray-1); + + .highlight { + background-color: var(--highlight-active); + color: var(--gray-0); + } + } + } + + a { + display: block; + color: var(--gray-11); + text-decoration: none; + padding: 6px 8px 6px 32px; + border-radius: 6px; + + h3 { + color: var(--gray-12); + font-size: 13px; + line-height: 16px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + p { + overflow: hidden; + text-overflow: ellipsis; + line-clamp: 2; + -webkit-line-clamp: 2; + display: -webkit-box; + -webkit-box-orient: vertical; + } + + &:hover { + background-color: var(--gray-3); + } + + &.selected { + background-color: var(--primary-9); + color: var(--gray-1); + + h3 { + color: var(--gray-0); + } + + .highlight { + background-color: var(--highlight-active); + color: var(--gray-0); + } + } + } +} + +.search-notfound { + display: none; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 32px 16px; + font-size: 14px; + line-height: 20px; + text-align: center; + color: var(--gray-10); + + a { + color: var(--gray-11); + text-underline-offset: 2px; + } +} + +nav.side { + display: flex; + flex-direction: column; + flex-grow: 1; + padding: 0 16px 16px 16px; + font-size: 14px; + line-height: 20px; + color: var(--gray-11); + -webkit-user-select: none; + user-select: none; + + .item { + display: flex; + gap: 8px; + align-items: center; + border-radius: 6px; + padding: 6px 8px; + + &:hover { + cursor: pointer; + background-color: var(--gray-3); + } + + &:has(a.target) { + background-color: var(--primary-9); + } + + a { + color: var(--gray-11); + text-decoration: none; + display: block; + width: 100%; + margin: -6px -8px -6px -4px; + padding: 6px 8px 6px 4px; + box-sizing: content-box; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + &:hover { + color: var(--gray-12); + } + + &.target { + font-weight: 500; + color: var(--gray-0); + } + } + } + + /* indentation */ + ol { + flex-grow: 1; + background-repeat: repeat-y; + + li.item { + padding-left: 32px; + } + + ol { + background-image: url('data:image/svg+xml,'); + + summary.item { + padding-left: 24px; + } + + li.item { + padding-left: 48px; + } + + ol { + background-image: url('data:image/svg+xml,'); + + summary.item { + padding-left: 40px; + } + + li.item { + padding-left: 64px; + } + } + } + } + + @media (prefers-color-scheme: dark) { + ol { + ol { + background-image: url('data:image/svg+xml,'); + + ol { + background-image: url('data:image/svg+xml,'); + } + } + } + } +} + +body>.resizer { + width: 8px; + cursor: col-resize; + + &.resizing, + &:hover { + border-left: 1px solid var(--gray-4); + } +} + +body>.expand-button { + display: none; + padding: 16px 8px 0 16px; + cursor: pointer; + + .tick { + color: var(--gray-8); + } + + >div { + display: flex; + align-items: center; + } + + &:hover { + border-right: 1px solid var(--gray-3); + padding-right: 7px; + + .tick { + color: var(--gray-12); + } + } +} + +body.sidenav-collapsed { + nav.left-pane { + display: none; + } + + >.expand-button { + display: block; + } +} + +#github-link { + display: flex; + align-items: center; + gap: 4px; + position: absolute; + top: 16px; + right: 16px; + font-size: 15px; + line-height: 20px; + font-weight: 400; + text-decoration: none; + color: var(--gray-11); + + &:hover { + color: var(--gray-12); + } +} + +#single-page-link { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + font-size: 14px; + line-height: 20px; + font-weight: 400; + text-decoration: none; + color: var(--gray-11); + border: 1px solid var(--gray-4); + border-radius: 6px; + padding: 5px 7px; + margin-top: 16px; + + &:hover { + color: var(--gray-12); + background-color: var(--gray-4); + } +} + +#note-nested-css { + color: var(--amber-12); + background-color: var(--amber-2); + border: 1px solid var(--amber-6); + border-radius: 6px; + margin: 20px; + padding: 12px 16px; + max-width: 850px; + font-size: 16px; + line-height: 24px; + + & { + /* Hide this note in supported browsers. */ + display: none; + } +} + +article { + flex: 1; + height: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; + align-items: center; + position: relative; + outline: none; + + >.content { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + max-width: 950px; + padding: 48px 64px; + padding-bottom: 50vh; + overflow-x: clip; + + color: var(--gray-11); + font-size: 16px; + line-height: 24px; + + p { + text-wrap: pretty; + } + + strong { + font-weight: 500; + color: var(--gray-12); + } + + a { + color: var(--primary-10); + text-decoration: none; + + &:hover { + text-decoration: underline; + text-underline-offset: 0.2em; + text-decoration-thickness: 1px; + text-decoration-color: var(--primary-8); + } + + &:visited { + color: var(--visited-link); + text-decoration-color: var(--visited-link); + } + } + + a>code, + a>strong { + color: var(--primary-10); + } + + a:visited>code, + a:visited>strong { + color: var(--visited-link); + } + + h1 { + font-weight: 600; + font-size: 48px; + line-height: 54px; + } + + h2 { + font-weight: 600; + font-size: 32px; + line-height: 36px; + } + + h3 { + font-weight: 600; + font-size: 24px; + line-height: 32px; + } + + h4 { + font-weight: 600; + font-size: 18px; + line-height: 24px; + } + + h1, + h2, + h3, + h4, + h5 { + scroll-margin-top: 24px; + margin-top: 16px; + + a { + color: var(--gray-12); + + &:visited { + color: var(--gray-12); + } + + &:hover { + text-decoration: none; + } + + code { + color: var(--gray-12); + word-break: break-all; + } + } + + a:visited>code { + color: var(--gray-12); + } + } + + h2 { + margin-top: 40px; + } + + h1, + h1+h2, + h2+h3, + h3+h4, + h4+h5, + h5+h6 { + margin-top: 0; + } + + .anchor::after { + content: ""; + display: inline-block; + background-image: url('data:image/svg+xml,'); + background-position-y: 2px; + width: 16px; + height: 16px; + margin-left: 4px; + opacity: 0; + transform-origin: bottom left; + } + + .anchor:hover::after { + opacity: 0.25; + } + + .target .anchor::after, + :target .anchor::after { + background-image: url('data:image/svg+xml,'); + opacity: 1; + } + + h1 .anchor::after { + scale: 1.5; + } + + h2 .anchor::after { + scale: 1.25; + } + + @media (prefers-color-scheme: dark) { + .anchor::after { + background-image: url('data:image/svg+xml,'); + } + + .target .anchor::after, + :target .anchor::after { + background-image: url('data:image/svg+xml,'); + } + } + + code { + color: var(--gray-12); + background-color: var(--gray-alpha-3); + border-radius: 4px; + padding: 4px; + } + + .code-wrapper { + position: relative; + + button { + position: absolute; + top: 8px; + right: 8px; + background-color: var(--gray-1); + color: var(--gray-10); + border: none; + border-radius: 4px; + padding: 8px; + + svg { + display: block; + } + + &:hover { + background-color: var(--gray-3); + cursor: pointer; + } + } + } + + blockquote, + .important { + font-style: italic; + padding: 0 0 0 24px; + position: relative; + } + + blockquote::before, + .important::before { + background-color: var(--primary-10); + border-radius: 2px; + content: ""; + display: block; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 4px; + } + + img { + max-width: 100%; + } + + ul, + ol { + display: flex; + flex-direction: column; + gap: 8px; + } + + li { + margin-left: 32px; + } + + div.table { + overflow: auto; + } + + table { + width: 100%; + overflow-x: auto; + border-collapse: separate; + border-spacing: 0; + + th { + text-align: left; + font-weight: 600; + background-color: var(--gray-2); + border-top: 1px solid var(--gray-5); + font-size: 14px; + line-height: 20px; + } + + th:first-child { + border-top-left-radius: 8px; + } + + th:last-child { + border-top-right-radius: 8px; + } + + th, + td { + padding: 8px 16px; + border-bottom: 1px solid var(--gray-5); + } + + th:first-child, + td:first-child { + border-left: 1px solid var(--gray-5); + } + + th:last-child, + td:last-child { + border-right: 1px solid var(--gray-5); + } + + tr:last-child { + td:first-child { + border-bottom-left-radius: 8px; + } + + td:last-child { + border-bottom-right-radius: 8px; + } + } + } + + details { + background-color: var(--gray-0); + border: 1px solid var(--gray-4); + border-radius: 8px; + padding: 16px 12px; + + >summary { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + line-height: 24px; + font-weight: 600; + background-color: var(--gray-1); + border-radius: 8px; + padding: 8px 12px; + margin: -16px -12px; + cursor: pointer; + + &:hover { + background-color: var(--gray-3); + } + } + + &[open]>summary { + border-bottom: 1px solid var(--gray-4); + border-radius: 8px 8px 0px 0px; + margin-bottom: 16px; + } + } + + .footnotes { + margin-top: 24px; + font-size: 13px; + line-height: 20px; + + >hr { + border: none; + border-top: 1px solid var(--gray-4); + } + + >ol { + padding: 12px 0; + + >li { + margin-left: 20px; + + >p { + color: var(--gray-11); + + >a { + color: var(--gray-9); + + &:hover { + text-decoration: none; + } + } + } + } + } + } + + .edit-link { + margin: 24px 0 48px 0; + padding: 12px 0; + display: flex; + align-items: center; + gap: 4px; + width: fit-content; + font-size: 13px; + line-height: 20px; + font-weight: 400; + text-decoration: none; + color: var(--gray-9); + + &:visited { + color: var(--gray-9); + } + + &:hover { + color: var(--gray-11); + text-decoration: none; + } + } + } +} + +@media (min-width: 810px) { + body { + display: flex; + height: 100vh; + } + + main { + flex: 1; + overflow-x: hidden; + } +} + +@media (max-width: 809px) { + nav.left-pane { + width: 100% !important; + overflow: visible; + position: sticky; + top: 0; + z-index: 1; + + >.top-wrapper { + .menu-button { + display: block; + + >.icon-burger { + display: block; + } + + >.icon-x { + display: none; + } + } + + .collapse-button { + display: none; + } + + .search-box { + display: none; + } + } + + >.search-results { + padding-right: 16px; + } + + >nav.side { + display: none; + padding-right: 16px; + } + } + + .mobile-expanded { + nav.left-pane { + >.top-wrapper { + .menu-button { + >.icon-burger { + display: none; + } + + >.icon-x { + display: block; + } + } + + .search-box { + display: block; + } + } + + >nav.side { + display: block; + } + + &.search-active { + >nav.side { + display: none; + } + } + } + + main { + display: none; + } + } + + body>.resizer { + display: none; + } + + article { + height: auto; + overflow-y: visible; + + >.content { + padding: 40px 16px; + + h1, + h2, + h3, + h4, + h5, + h6 { + scroll-margin-top: 72px; + } + + h1 { + font-size: 36px; + line-height: 40px; + } + + h2 { + font-size: 24px; + line-height: 32px; + } + + h3 { + font-size: 18px; + line-height: 24px; + } + + h4 { + font-size: 16px; + line-height: 24px; + } + + h5 { + font-size: 14px; + line-height: 20px; + } + } + } +} + +@media print { + + nav.left-pane, + body>.resizer, + body>.expand-button, + #github-link, + #single-page-link, + .edit-link, + .code-wrapper button { + display: none !important; + } + + body { + display: block; + height: unset; + } + + article { + display: block; + overflow-y: unset; + } + + pre { + white-space: pre-wrap; + word-break: break-word; + } +} diff --git a/ocam/src/docs_website/build.zig b/ocam/src/docs_website/build.zig new file mode 100644 index 00000000..f135a798 --- /dev/null +++ b/ocam/src/docs_website/build.zig @@ -0,0 +1,158 @@ +const std = @import("std"); +const Website = @import("src/website.zig").Website; +const docs = @import("src/docs.zig"); +const redirects = @import("src/redirects.zig"); + +pub const exclude_extensions: []const []const u8 = &.{ + ".DS_Store", +}; + +pub fn build(b: *std.Build) !void { + const url_prefix: []const u8 = b.option( + []const u8, + "url_prefix", + "Prefix links with this string", + ) orelse ""; + + const git_commit = b.option( + []const u8, + "git-commit", + "The git commit revision of the source code.", + ) orelse std.mem.trimRight(u8, b.run(&.{ "git", "rev-parse", "--verify", "HEAD" }), "\n"); + + const pandoc_bin = get_pandoc_bin(b) orelse return; + const vale_bin = get_vale_bin(b) orelse return; + + const check_spelling = std.Build.Step.Run.create(b, "run vale"); + hide_stdout(check_spelling); + check_spelling.addFileArg(vale_bin); + const md_files = b.run(&.{ "git", "ls-files", ":(glob)../../**/*.md" }); + var md_files_iter = std.mem.tokenizeScalar(u8, md_files, '\n'); + while (md_files_iter.next()) |md_file| { + check_spelling.addFileArg(b.path(md_file)); + } + + const content = b.addWriteFiles(); + { //TODO(zig): https://github.com/ziglang/zig/issues/20571 + var dir = try b.build_root.handle.openDir("assets", .{ .iterate = true }); + defer dir.close(); + + var walker = try dir.walk(b.allocator); + defer walker.deinit(); + + while (try walker.next()) |entry| { + if (entry.kind == .file) { + if (std.mem.eql(u8, entry.basename, ".DS_Store")) continue; + const source = b.path("assets").path(b, entry.path); + _ = content.addCopyFile(source, entry.path); + } + } + } + + content.step.dependOn(&check_spelling.step); + + const website = Website.init(b, url_prefix, pandoc_bin); + try docs.build(b, content, website); + try redirects.build(b, content, website); + + const clean_zigout_step = b.addRemoveDirTree(b.path("zig-out")); + + const install_content_step = b.addInstallDirectory(.{ + .source_dir = content.getDirectory(), + .install_dir = .prefix, + .install_subdir = ".", + }); + + install_content_step.step.dependOn(&clean_zigout_step.step); + + const service_worker_writer = b.addRunArtifact(b.addExecutable(.{ + .name = "service_worker_writer", + .root_source_file = b.path("src/service_worker_writer.zig"), + .target = b.graph.host, + })); + service_worker_writer.addArgs(&.{ url_prefix, git_commit }); + service_worker_writer.addDirectoryArg(content.getDirectory()); + + const service_worker = service_worker_writer.captureStdOut(); + + const file_checker = b.addRunArtifact(b.addExecutable(.{ + .name = "file_checker", + .root_source_file = b.path("src/file_checker.zig"), + .target = b.graph.host, + })); + file_checker.addArg("zig-out"); + + file_checker.step.dependOn(&install_content_step.step); + file_checker.step.dependOn(&b.addInstallFile(service_worker, "service-worker.js").step); + + b.getInstallStep().dependOn(&file_checker.step); +} + +fn get_pandoc_bin(b: *std.Build) ?std.Build.LazyPath { + const host = b.graph.host.result; + const name = switch (host.os.tag) { + .linux => switch (host.cpu.arch) { + .x86_64 => "pandoc_linux_amd64", + else => @panic("unsupported cpu arch"), + }, + .macos => switch (host.cpu.arch) { + .aarch64 => "pandoc_macos_arm64", + else => @panic("unsupported cpu arch"), + }, + else => @panic("unsupported os"), + }; + if (b.lazyDependency(name, .{})) |dep| { + return dep.path("bin/pandoc"); + } else { + return null; + } +} + +fn get_vale_bin(b: *std.Build) ?std.Build.LazyPath { + const host = b.graph.host.result; + const name = switch (host.os.tag) { + .linux => switch (host.cpu.arch) { + .x86_64 => "vale_linux_amd64", + else => @panic("unsupported cpu arch"), + }, + .macos => switch (host.cpu.arch) { + .aarch64 => "vale_macos_arm64", + else => @panic("unsupported cpu arch"), + }, + else => @panic("unsupported os"), + }; + if (b.lazyDependency(name, .{})) |dep| { + return dep.path("vale"); + } else { + return null; + } +} + +// Hide step's stdout unless it fails. Sadly, this requires overriding Build.Step.Run make function. +fn hide_stdout(run: *std.Build.Step.Run) void { + _ = run.captureStdOut(); + + const override = struct { + var global_map: std.AutoHashMapUnmanaged(usize, std.Build.Step.MakeFn) = .{}; + + fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) anyerror!void { + const original = global_map.get(@intFromPtr(step)).?; + original(step, options) catch |err| { + const run_step: *std.Build.Step.Run = @fieldParentPtr("step", step); + if (run_step.captured_stdout) |output| { + const file = try std.fs.cwd().openFile(output.generated_file.getPath(), .{}); + defer file.close(); + + const stdout = try file.readToEndAlloc(step.owner.allocator, 100 * 1024); + std.debug.print("{s}\n", .{stdout}); + } + return err; + }; + } + }; + + const original = run.step.makeFn; + const b = run.step.owner; + override.global_map.put(b.allocator, @intFromPtr(&run.step), original) catch @panic("OOM"); + run.step.makeFn = override.make; +} diff --git a/ocam/src/docs_website/build.zig.zon b/ocam/src/docs_website/build.zig.zon new file mode 100644 index 00000000..08f654de --- /dev/null +++ b/ocam/src/docs_website/build.zig.zon @@ -0,0 +1,28 @@ +.{ + .name = .tigerbeetle_docs, + .version = "0.0.0", + .dependencies = .{ + .pandoc_macos_arm64 = .{ + .url = "https://github.com/jgm/pandoc/releases/download/3.4/pandoc-3.4-arm64-macOS.zip", + .hash = "1220c2506a07845d667e7c127fd0811e4f5f7591e38ccc7fb4376450f3435048d87a", + .lazy = true, + }, + .pandoc_linux_amd64 = .{ + .url = "https://github.com/jgm/pandoc/releases/download/3.4/pandoc-3.4-linux-amd64.tar.gz", + .hash = "1220139a44886509d8a61b44d8b8a79d03bad29ea95493dc97cd921d3f2eb208562c", + .lazy = true, + }, + .vale_macos_arm64 = .{ + .url = "https://github.com/errata-ai/vale/releases/download/v3.9.5/vale_3.9.5_macOS_arm64.tar.gz", + .hash = "1220418afe2846224487326841b0228b1c44e77bb8440c96190fcc3189a2df1688eb", + .lazy = true, + }, + .vale_linux_amd64 = .{ + .url = "https://github.com/errata-ai/vale/releases/download/v3.9.5/vale_3.9.5_Linux_64-bit.tar.gz", + .hash = "1220b6041f1354d29a1b8ad5be5f8a98300142ae992ac147ab70f98e4df94b470917", + .lazy = true, + }, + }, + .paths = .{"."}, + .fingerprint = 0x3efa1a0dbea6a5e9, +} diff --git a/ocam/src/docs_website/pandoc/anchor-links.lua b/ocam/src/docs_website/pandoc/anchor-links.lua new file mode 100644 index 00000000..bfecd58a --- /dev/null +++ b/ocam/src/docs_website/pandoc/anchor-links.lua @@ -0,0 +1,13 @@ +-- Adds anchor links to headings with IDs. +function Header (h) + if h.identifier ~= '' then + local anchor_link = pandoc.Link( + h.content, -- content + '#' .. h.identifier, -- href + '', -- title + {class = 'anchor'} -- attributes + ) + h.content = {anchor_link} + return h + end +end diff --git a/ocam/src/docs_website/pandoc/code-block-buttons.lua b/ocam/src/docs_website/pandoc/code-block-buttons.lua new file mode 100644 index 00000000..0030636f --- /dev/null +++ b/ocam/src/docs_website/pandoc/code-block-buttons.lua @@ -0,0 +1,12 @@ +function CodeBlock (cb) + local button_html = [[ + + ]] + local wrapper = pandoc.Div( + { pandoc.RawInline("html", button_html), cb }, + pandoc.Attr("", { "code-wrapper" }) + ) + return wrapper +end diff --git a/ocam/src/docs_website/pandoc/edit-link-footer.lua b/ocam/src/docs_website/pandoc/edit-link-footer.lua new file mode 100644 index 00000000..e48bac74 --- /dev/null +++ b/ocam/src/docs_website/pandoc/edit-link-footer.lua @@ -0,0 +1,19 @@ +function Pandoc(doc) + local cwd_path = pandoc.system.get_working_directory() + local abs_path = PANDOC_STATE.input_files[1] + local website_path = "/src/docs_website"; + local repo_path = cwd_path:sub(1, -#website_path) + local rel_path = pandoc.path.make_relative(abs_path, repo_path) + local edit_url = "https://github.com/tigerbeetle/tigerbeetle/edit/main/"..rel_path + + local footer = pandoc.RawBlock("html", [[ + + + + + Edit this page +]]) + + table.insert(doc.blocks, footer) + return doc +end diff --git a/ocam/src/docs_website/pandoc/markdown-links.lua b/ocam/src/docs_website/pandoc/markdown-links.lua new file mode 100644 index 00000000..2117352d --- /dev/null +++ b/ocam/src/docs_website/pandoc/markdown-links.lua @@ -0,0 +1,43 @@ +is_readme = PANDOC_STATE.input_files[1]:sub(-9) == "README.md" + +function Link (link) + local is_external = link.target:sub(1, 8) == "https://" or link.target:sub(1, 7) == "http://" + local is_mailto = link.target:sub(1, 7) == "mailto:" + local is_absolute = link.target:sub(1, 1) == "/" + local is_anchor = link.target:sub(1, 1) == "#" + + local docs = "https://docs.tigerbeetle.com/" + if link.target:sub(1, #docs) == docs then + link.target = link.target:gsub(docs, "/") + is_external = false + is_absolute = true + end + + -- We have to adjust relative links to go up one more level. + if not (is_readme or is_external or is_mailto or is_absolute or is_anchor) then + if link.target:sub(1, 2) == "./" then + link.target = "." .. link.target + else + link.target = "../" .. link.target + end + end + + -- Links to client documentation + if link.target:sub(1, 12) == "/src/clients" then + local _, target_level = link.target:gsub("/", "") + local is_client_readme = link.target:find("README.md") and target_level == 4 + if is_client_readme then + link.target = "/coding" .. link.target:sub(5) -- Cut "/src" + else + -- Make GitHub link + link.target = "https://github.com/tigerbeetle/tigerbeetle/blob/main" .. link.target + end + end + + if not is_external then + link.target = link.target:gsub("README%.md", "") + link.target = link.target:gsub("%.md", "") + end + + return link +end diff --git a/ocam/src/docs_website/pandoc/table-wrapper.lua b/ocam/src/docs_website/pandoc/table-wrapper.lua new file mode 100644 index 00000000..a52235db --- /dev/null +++ b/ocam/src/docs_website/pandoc/table-wrapper.lua @@ -0,0 +1,4 @@ +-- Wraps table in div. This gives us more control for styling. +function Table (tbl) + return pandoc.Div(tbl, {class = 'table'}) +end diff --git a/ocam/src/docs_website/src/content.zig b/ocam/src/docs_website/src/content.zig new file mode 100644 index 00000000..f6a90485 --- /dev/null +++ b/ocam/src/docs_website/src/content.zig @@ -0,0 +1,156 @@ +//! Walk the `/docs` directory to build table of contents by parsing links to child pages from +//! the READMEs. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; + +const log = std.log.scoped(.content); +const cut_prefix = @import("./docs.zig").cut_prefix; +const cut = @import("./docs.zig").cut; + +pub const Page = struct { + content: PageContent, + path: []const u8, // Always ends in .md + children: []Page, +}; + +// Parsed content of a single .md file. +const PageContent = struct { + title: []const u8, + children: []Child, + + const Child = struct { + title: []const u8, + path: []const u8, + }; +}; + +pub fn load(arena: Allocator, base: std.fs.Dir, page_buffer: []u8) !Page { + return load_page(arena, base, "./", page_buffer); +} + +fn load_page(arena: Allocator, base: std.fs.Dir, path: []const u8, page_buffer: []u8) !Page { + errdefer log.err("error while loading '{s}'", .{path}); + const is_dir = std.mem.endsWith(u8, path, "/"); + const is_client = std.mem.indexOf(u8, path, "src/clients") != null; + + const file_path = if (is_dir) try std.fs.path.join(arena, &.{ path, "README.md" }) else path; + if (!std.mem.endsWith(u8, file_path, ".md")) { + return error.InvalidPath; + } + + const text = try read_file(base, file_path, page_buffer); + const content = try parse_page_content(arena, text, .{ + .parse_children = is_dir and !is_client, + }); + + var children: std.ArrayListUnmanaged(Page) = .{}; + for (content.children) |child| { + assert(is_dir); + const child_path = if (std.mem.startsWith(u8, child.path, "/src/clients")) + try std.fs.path.join(arena, &.{ + "..", + child.path, + }) + else + try std.fs.path.join(arena, &.{ + path, + cut_prefix(child.path, "./") orelse child.path, + }); + const child_page = try load_page(arena, base, child_path, page_buffer); + try children.append(arena, child_page); + } + + if (is_dir and !is_client) { + var dir = try base.openDir(path, .{ .iterate = true }); + defer dir.close(); + + var dir_iterator = dir.iterate(); + while (try dir_iterator.next()) |entry| { + if (std.mem.eql(u8, entry.name, "README.md")) continue; + if (std.mem.eql(u8, entry.name, "internals")) continue; + if (std.mem.eql(u8, entry.name, "TIGER_STYLE.md")) continue; + if (std.mem.eql(u8, entry.name, "ARCHITECTURE.md")) continue; + for (content.children) |child| { + const name = std.mem.trimRight( + u8, + cut_prefix(child.path, "./") orelse child.path, + "/", + ); + if (std.mem.eql(u8, name, entry.name)) break; + } else { + log.err("orphaned page: {s}{s}", .{ path, entry.name }); + return error.OrphanedPage; + } + } + } + + return .{ + .content = content, + .path = file_path, + .children = children.items, + }; +} + +fn parse_page_content(arena: Allocator, text: []const u8, options: struct { + parse_children: bool, +}) !PageContent { + var line_iterator = std.mem.tokenizeScalar(u8, text, '\n'); + var title_line = line_iterator.next() orelse return error.TitleInvalid; + if (std.mem.startsWith(u8, title_line, " + \\ + , .{ this_file, this_file }); + + ctx.header(1, ctx.docs.name); + ctx.paragraph(ctx.docs.description); + + { + ctx.header(2, "Prerequisites"); + ctx.print( + \\Linux >= 5.6 is the only production environment we + \\support. But for ease of development we also support macOS and Windows. + \\ + , .{}); + ctx.paragraph(ctx.docs.prerequisites); + } + + { + ctx.header(2, "Setup"); + ctx.paragraph("First, create a directory for your project and `cd` into the directory."); + + if (ctx.docs.project_file.len > 0) { + ctx.print( + "Then create `{s}` and copy this into it:\n\n", + .{ctx.docs.project_file_name}, + ); + _, const project_file_language = stdx.cut(ctx.docs.project_file_name, ".").?; + ctx.code(project_file_language, ctx.docs.project_file); + } + + if (ctx.docs.install_commands.len > 0) { + ctx.paragraph("Then, install the TigerBeetle client:"); + ctx.commands(ctx.docs.install_commands); + } + + ctx.print("Now, create `{s}{s}.{s}` and copy this into it:\n\n", .{ + ctx.docs.test_source_path, + ctx.docs.test_file_name, + ctx.docs.extension, + }); + ctx.code_section("imports"); + ctx.paragraph("Finally, build and run:"); + ctx.commands(ctx.docs.run_commands); + + ctx.paragraph( + \\Now that all prerequisites and dependencies are correctly set + \\up, let's dig into using TigerBeetle. + , + ); + } + + { + ctx.header(2, "Sample projects"); + ctx.paragraph( + \\This document is primarily a reference guide to + \\the client. Below are various sample projects demonstrating + \\features of TigerBeetle. + , + ); + // Absolute paths here are necessary for resolving within the docs site. + for (samples) |sample| { + if (try ctx.sample_exists(sample)) { + ctx.print("* [{s}](/src/clients/{s}/samples/{s}/): {s}\n", .{ + sample.proper_name, + ctx.docs.directory, + sample.directory, + sample.short_description, + }); + } + } + ctx.print("\n", .{}); + + if (ctx.docs.examples.len != 0) { + ctx.paragraph(ctx.docs.examples); + } + } + + { + ctx.header(2, "Creating a Client"); + ctx.paragraph( + \\A client is created with a cluster ID and replica + \\addresses for all replicas in the cluster. The cluster + \\ID and replica addresses are both chosen by the system that + \\starts the TigerBeetle cluster. + \\ + \\Clients are thread-safe and a single instance should be shared + \\between multiple concurrent tasks. This allows events to be + \\[automatically batched](https://docs.tigerbeetle.com/coding/requests/#batching-events). + \\ + \\Multiple clients are useful when connecting to more than + \\one TigerBeetle cluster. + \\ + \\In this example the cluster ID is `0` and there is one + \\replica. The address is read from the `TB_ADDRESS` + \\environment variable and defaults to port `3000`. + ); + ctx.code_section("client"); + ctx.paragraph(ctx.docs.client_object_documentation); + + ctx.paragraph( + \\The following are valid addresses: + \\* `3000` (interpreted as `127.0.0.1:3000`) + \\* `127.0.0.1:3000` (interpreted as `127.0.0.1:3000`) + \\* `127.0.0.1` (interpreted as `127.0.0.1:3001`, `3001` is the default port) + ); + } + + { + ctx.header(2, "Creating Accounts"); + ctx.paragraph( + \\See details for account fields in the [Accounts + \\reference](https://docs.tigerbeetle.com/reference/account). + ); + ctx.code_section("create-accounts"); + + ctx.paragraph( + \\See details for the recommended ID scheme in + \\[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + ); + + ctx.paragraph(ctx.docs.create_accounts_documentation); + + ctx.header(3, "Account Flags"); + ctx.paragraph( + \\The account flags value is a bitfield. See details for + \\these flags in the [Accounts + \\reference](https://docs.tigerbeetle.com/reference/account#flags). + ); + ctx.paragraph(ctx.docs.account_flags_documentation); + + ctx.paragraph( + \\For example, to link two accounts where the first account + \\additionally has the `debits_must_not_exceed_credits` constraint: + ); + ctx.code_section("account-flags"); + + ctx.header(3, "Response and Errors"); + ctx.paragraph( + \\The response is an array containing the _status code_ and the _timestamp_ of + \\each account in the request batch: + \\- Successfully created accounts with the status + \\ [`created`](https://docs.tigerbeetle.com/reference/requests/create_accounts#created) + \\ return the timestamp assigned to the `Account` object. + \\- Already existing accounts with the result + \\ [`exists`](https://docs.tigerbeetle.com/reference/requests/create_accounts#exists) + \\ return the timestamp of the original existing object. + \\- Failed accounts return the status code along with the timestamp when the validation + \\ occurred. See all error conditions in the + \\ [create_accounts reference](https://docs.tigerbeetle.com/reference/requests/create_accounts#status). + ); + + ctx.code_section("create-accounts-errors"); + + ctx.paragraph(ctx.docs.create_accounts_errors_documentation); + } + + { + ctx.header(2, "Account Lookup"); + ctx.paragraph( + \\Account lookup is batched, like account creation. Pass + \\in all IDs to fetch. The account for each matched ID is returned. + \\ + \\If no account matches an ID, no object is returned for + \\that account. So the order of accounts in the response is + \\not necessarily the same as the order of IDs in the + \\request. You can refer to the ID field in the response to + \\distinguish accounts. + ); + ctx.code_section("lookup-accounts"); + } + + { + ctx.header(2, "Create Transfers"); + ctx.paragraph( + \\This creates a journal entry between two accounts. + \\ + \\See details for transfer fields in the [Transfers + \\reference](https://docs.tigerbeetle.com/reference/transfer). + ); + ctx.code_section("create-transfers"); + + ctx.paragraph( + \\See details for the recommended ID scheme in + \\[time-based identifiers](https://docs.tigerbeetle.com/coding/data-modeling#tigerbeetle-time-based-identifiers-recommended). + ); + + ctx.header(3, "Response and Errors"); + ctx.paragraph( + \\The response is an array containing the _status code_ and the _timestamp_ of + \\each transfer in the request batch: + \\- Successfully created transfers with the result + \\ [`created`](https://docs.tigerbeetle.com/reference/requests/create_transfers#created) + \\ return the timestamp assigned to the `Transfer` object. + \\- Already existing transfers with the result + \\ [`exists`](https://docs.tigerbeetle.com/reference/requests/create_transfers#exists) + \\ return the timestamp of the original existing object. + \\- Failed transfers return the status code along with the timestamp when the validation + \\ occurred. See all error conditions in the + \\ [create_transfers reference](https://docs.tigerbeetle.com/reference/requests/create_transfers#status). + ); + ctx.code_section("create-transfers-errors"); + + ctx.paragraph(ctx.docs.create_transfers_errors_documentation); + } + + { + ctx.header(2, "Batching"); + ctx.paragraph( + \\TigerBeetle performance is maximized when you batch + \\API requests. + \\ + \\A client instance shared across multiple threads/tasks can automatically + \\batch concurrent requests, but the application must still send as many events + \\as possible in a single call. + \\ + \\For example, if you insert 1 million transfers sequentially, one at a time, + \\the insert rate will be a *fraction* of the potential, because the client will + \\wait for a reply between each one. + \\Instead, **always batch as much as you can**. + \\ + \\The maximum batch size is set in the TigerBeetle server. The default is 8189. + ); + ctx.code_section("batch"); + + ctx.header(3, "Queues and Workers"); + ctx.paragraph( + \\If you are making requests to TigerBeetle from workers + \\pulling jobs from a queue, you can batch requests to + \\TigerBeetle by having the worker act on multiple jobs from + \\the queue at once rather than one at a time. i.e. pulling + \\multiple jobs from the queue rather than just one. + ); + } + + { + ctx.header(2, "Transfer Flags"); + ctx.paragraph( + \\The transfer `flags` value is a bitfield. See details for these flags in + \\the [Transfers + \\reference](https://docs.tigerbeetle.com/reference/transfer#flags). + ); + ctx.paragraph(ctx.docs.transfer_flags_documentation); + ctx.paragraph("For example, to link `transfer0` and `transfer1`:"); + ctx.code_section("transfer-flags-link"); + + ctx.header(3, "Two-Phase Transfers"); + ctx.paragraph( + \\Two-phase transfers are supported natively by toggling the appropriate + \\flag. TigerBeetle will then adjust the `credits_pending` and + \\`debits_pending` fields of the appropriate accounts. A corresponding + \\post pending transfer then needs to be sent to post or void the + \\transfer. + ); + ctx.header(4, "Post a Pending Transfer"); + ctx.paragraph( + \\With `flags` set to `post_pending_transfer`, + \\TigerBeetle will post the transfer. TigerBeetle will atomically roll + \\back the changes to `debits_pending` and `credits_pending` of the + \\appropriate accounts and apply them to the `debits_posted` and + \\`credits_posted` balances. + ); + ctx.code_section("transfer-flags-post"); + + ctx.header(4, "Void a Pending Transfer"); + ctx.paragraph( + \\In contrast, with `flags` set to `void_pending_transfer`, + \\TigerBeetle will void the transfer. TigerBeetle will roll + \\back the changes to `debits_pending` and `credits_pending` of the + \\appropriate accounts and **not** apply them to the `debits_posted` and + \\`credits_posted` balances. + ); + ctx.code_section("transfer-flags-void"); + } + + { + ctx.header(2, "Transfer Lookup"); + ctx.paragraph( + \\NOTE: While transfer lookup exists, it is not a flexible query API. We + \\are developing query APIs and there will be new methods for querying + \\transfers in the future. + \\ + \\Transfer lookup is batched, like transfer creation. Pass in all `id`s to + \\fetch, and matched transfers are returned. + \\ + \\If no transfer matches an `id`, no object is returned for that + \\transfer. So the order of transfers in the response is not necessarily + \\the same as the order of `id`s in the request. You can refer to the + \\`id` field in the response to distinguish transfers. + ); + ctx.code_section("lookup-transfers"); + } + + { + ctx.header(2, "Get Account Transfers"); + ctx.paragraph( + \\NOTE: This is a preview API that is subject to breaking changes once we have + \\a stable querying API. + \\ + \\Fetches the transfers involving a given account, allowing basic filter and pagination + \\capabilities. + \\ + \\The transfers in the response are sorted by `timestamp` in chronological or + \\reverse-chronological order. + ); + ctx.code_section("get-account-transfers"); + } + + { + ctx.header(2, "Get Account Balances"); + ctx.paragraph( + \\NOTE: This is a preview API that is subject to breaking changes once we have + \\a stable querying API. + \\ + \\Fetches the point-in-time balances of a given account, allowing basic filter and + \\pagination capabilities. + \\ + \\Only accounts created with the flag + \\[`history`](https://docs.tigerbeetle.com/reference/account#flagshistory) set retain + \\[historical balances](https://docs.tigerbeetle.com/reference/requests/get_account_balances). + \\ + \\The balances in the response are sorted by `timestamp` in chronological or + \\reverse-chronological order. + ); + ctx.code_section("get-account-balances"); + } + + { + ctx.header(2, "Query Accounts"); + ctx.paragraph( + \\NOTE: This is a preview API that is subject to breaking changes once we have + \\a stable querying API. + \\ + \\Query accounts by the intersection of some fields and by timestamp range. + \\ + \\The accounts in the response are sorted by `timestamp` in chronological or + \\reverse-chronological order. + ); + ctx.code_section("query-accounts"); + } + + { + ctx.header(2, "Query Transfers"); + ctx.paragraph( + \\NOTE: This is a preview API that is subject to breaking changes once we have + \\a stable querying API. + \\ + \\Query transfers by the intersection of some fields and by timestamp range. + \\ + \\The transfers in the response are sorted by `timestamp` in chronological or + \\reverse-chronological order. + ); + ctx.code_section("query-transfers"); + } + + { + ctx.header(2, "Linked Events"); + ctx.paragraph( + \\When the `linked` flag is specified for an account when creating accounts or + \\a transfer when creating transfers, it links that event with the next event in the + \\batch, to create a chain of events, of arbitrary length, which all + \\succeed or fail together. The tail of a chain is denoted by the first + \\event without this flag. The last event in a batch may therefore never + \\have the `linked` flag set as this would leave a chain + \\open-ended. Multiple chains or individual events may coexist within a + \\batch to succeed or fail independently. + \\ + \\Events within a chain are executed within order, or are rolled back on + \\error, so that the effect of each event in the chain is visible to the + \\next, and so that the chain is either visible or invisible as a unit + \\to subsequent events after the chain. The event that was the first to + \\break the chain will have a unique error result. Other events in the + \\chain will have their error result set to `linked_event_failed`. + ); + ctx.code_section("linked-events"); + } + + { + ctx.header(2, "Imported Events"); + ctx.paragraph( + \\When the `imported` flag is specified for an account when creating accounts or + \\a transfer when creating transfers, it allows importing historical events with + \\a user-defined timestamp. + \\ + \\The entire batch of events must be set with the flag `imported`. + \\ + \\It's recommended to submit the whole batch as a `linked` chain of events, ensuring that + \\if any event fails, none of them are committed, preserving the last timestamp unchanged. + \\This approach gives the application a chance to correct failed imported events, re-submitting + \\the batch again with the same user-defined timestamps. + ); + ctx.code_section("imported-events"); + } + + { + ctx.header(2, "Timeouts And Cancellation"); + ctx.paragraph( + \\The Client retries indefinitely and doesn't impose any per-request timeout. Cancellation is + \\provided as a mechanism, and the specific cancellation policy is left to the + \\application. A Client instance can be closed at any time. On close, all in-flight + \\requests are canceled and return an error to the caller. Even if an error is returned, + \\a request might still be processed by the TigerBeetle server. + \\[Reliable transaction submission](https://docs.tigerbeetle.com/coding/reliable-transaction-submission/) + \\explains how to make transfers retry-proof using IDs for end-to-end idempotency. + ); + } + + ctx.ensure_final_newline(); +} + +fn readme_sample(ctx: *Context, sample: Sample) !void { + ctx.print( + \\ + \\ + , .{ this_file, this_file }); + + ctx.print( + \\# {s} {s} Sample + \\ + \\Code for this sample is in [./{s}{s}.{s}](./{s}{s}.{s}). + \\ + \\ + , .{ + sample.proper_name, + ctx.docs.proper_name, + ctx.docs.test_source_path, + ctx.docs.test_file_name, + ctx.docs.extension, + ctx.docs.test_source_path, + ctx.docs.test_file_name, + ctx.docs.extension, + }); + + { + ctx.header(2, "Prerequisites"); + ctx.print( + \\Linux >= 5.6 is the only production environment we + \\support. But for ease of development we also support macOS and Windows. + \\ + , .{}); + ctx.paragraph(ctx.docs.prerequisites); + } + + { + ctx.header(2, "Setup"); + ctx.paragraph(try ctx.shell.fmt( + \\First, clone this repo and `cd` into `tigerbeetle/src/clients/{s}/samples/{s}`. + , .{ ctx.docs.directory, sample.directory })); + + ctx.paragraph("Then, install the TigerBeetle client:"); + ctx.commands(ctx.docs.install_commands); + } + + { + ctx.header(2, "Start the TigerBeetle server"); + ctx.paragraph( + \\Follow steps in the repo README to [run + \\TigerBeetle](/README.md#running-tigerbeetle). + \\ + \\If you are not running on port `localhost:3000`, set + \\the environment variable `TB_ADDRESS` to the full + \\address of the TigerBeetle server you started. + ); + } + + { + ctx.header(2, "Run this sample"); + ctx.paragraph("Now you can run this sample:"); + ctx.commands(ctx.docs.run_commands); + } + + { + ctx.header(2, "Walkthrough"); + ctx.paragraph("Here's what this project does."); + ctx.paragraph(sample.long_description); + } + + ctx.ensure_final_newline(); +} + +const Context = struct { + shell: *Shell, + arena: std.mem.Allocator, + buffer: std.ArrayList(u8), + docs: Docs, + walkthrough: []const u8, + + fn sample_exists(ctx: *Context, sample: @TypeOf(samples[0])) !bool { + const sample_directory = try ctx.shell.fmt("samples/{s}/", .{sample.directory}); + return try ctx.shell.dir_exists(sample_directory); + } + + // Pulls a single "section" of code out of the entire walkthrough sample. + // + // A section is delimited by a pair of `section:SECTION_NAME` and `endsection:SECTION_NAME` + // comments. If there are several such pairs, their contents is concatenated (see the `imports` + // section in the Java sample for a motivational example for concatenation behavior). + fn read_section(ctx: *Context, section_name: []const u8) []const u8 { + var section_content = std.ArrayList(u8).init(ctx.arena); + const section_start = + ctx.shell.fmt("section:{s}\n", .{section_name}) catch @panic("OOM"); + const section_end = + ctx.shell.fmt("endsection:{s}\n", .{section_name}) catch @panic("OOM"); + + var rest = ctx.walkthrough; + for (0..10) |_| { + _, rest = stdx.cut(rest, section_start) orelse break; + + var section, rest = stdx.cut(rest, section_end).?; + const newline_index = std.mem.lastIndexOfScalar(u8, section, '\n') orelse { + log.warn("empty section fragment: {s}", .{section_name}); + @panic("empty section fragement"); + }; + section = section[0..newline_index]; + + var indent_min: usize = std.math.maxInt(usize); + var lines = std.mem.splitScalar(u8, section, '\n'); + while (lines.next()) |line| { + if (line.len == 0) continue; + var indent_line: usize = 0; + while (line[indent_line] == ' ' or line[indent_line] == '\t') indent_line += 1; + indent_min = @min(indent_min, indent_line); + } + assert(indent_min < 18); + + lines = std.mem.splitScalar(u8, section, '\n'); + while (lines.next()) |line| { + if (line.len > 0) { + assert(line.len > indent_min); + section_content.appendSlice(line[indent_min..]) catch unreachable; + } + section_content.append('\n') catch unreachable; + } + } else @panic("too many parts in a section"); + assert(section_content.pop() == '\n'); + + const result = section_content.items; + assert(result.len > 0); + return result; + } + + fn header(ctx: *Context, comptime level: u8, content: []const u8) void { + ctx.print(("#" ** level) ++ " {s}\n\n", .{content}); + } + + fn paragraph(ctx: *Context, content: []const u8) void { + // Don't print empty lines. + if (content.len == 0) return; + ctx.print("{s}\n\n", .{content}); + } + + fn code(ctx: *Context, language: []const u8, content: []const u8) void { + // Don't print empty lines. + if (content.len == 0) return; + ctx.print("```{s}\n{s}\n```\n\n", .{ language, content }); + } + + fn code_section(ctx: *Context, section_name: []const u8) void { + const section_content = ctx.read_section(section_name); + ctx.code(ctx.docs.markdown_name, section_content); + } + + fn commands(ctx: *Context, content: []const u8) void { + ctx.code("console", content); + } + + fn print(ctx: *Context, comptime fmt: []const u8, args: anytype) void { + ctx.buffer.writer().print(fmt, args) catch @panic("OOM"); + } + + fn ensure_final_newline(ctx: *Context) void { + assert(ctx.buffer.pop() == '\n'); + assert(std.mem.endsWith(u8, ctx.buffer.items, "\n")); + assert(!std.mem.endsWith(u8, ctx.buffer.items, "\n\n")); + } +}; diff --git a/ocam/src/scripts/devhub.zig b/ocam/src/scripts/devhub.zig new file mode 100644 index 00000000..71f6b704 --- /dev/null +++ b/ocam/src/scripts/devhub.zig @@ -0,0 +1,479 @@ +//! Runs a set of macro-benchmarks whose result is displayed at . +//! +//! Specifically: +//! +//! - This script is run by the CI infrastructure on every merge to main. +//! - It runs a set of "benchmarks", where a "benchmark" can be anything (eg, measuring the size of +//! the binary). +//! - The results of all measurements are serialized as a single JSON object, `Run`. +//! - The key part: this JSON is then stored in a "distributed database" for our visualization +//! front-end to pick up. This "database" is just a newline-delimited JSON file in a git repo +//! +//! To generate a DEVHUBDB_PAT (used by cfo and CI): +//! 1. Go to https://github.com/settings/personal-access-tokens/new +//! 2. Fill out token name (e.g. "cfo/ci devhubdb token"). +//! 3. Resource owner: "tigerbeetle" +//! 4. Expiry: "366 days" (maximum available) +//! 5. Repository access: "Only select repositories" +//! 6. Select repositories: "tigerbeetle/devhubdb" +//! 7. Add permissions: "Metadata" +//! 8. Add permissions: "Contents"; Access: "Read and write". +//! 9. "Generate token". +//! 10. (Copy token.) +//! +//! To update token in TigerBeetle CI: +//! 1. https://github.com/tigerbeetle/tigerbeetle/settings/environments +//! 2. Click "devhub". +//! 3. Environment Secrets > Edit DEVHUBDB_PAT +//! 4. Paste token; "Update secret" +//! +//! (Also need to update the DEVHUBDB_PAT environment variable passed to the CFO supervisors.) +const std = @import("std"); +const assert = std.debug.assert; + +const stdx = @import("stdx"); +const Shell = stdx.Shell; +const changelog = @import("./changelog.zig"); +const Release = @import("../multiversion.zig").Release; + +const MiB = stdx.MiB; + +const log = std.log; + +pub const CLIArgs = struct { + sha: []const u8, + skip_kcov: bool = false, +}; + +pub fn main(shell: *Shell, _: std.mem.Allocator, cli_args: CLIArgs) !void { + try devhub_metrics(shell, cli_args); + + if (!cli_args.skip_kcov) { + try devhub_coverage(shell); + } else { + log.info("--skip-kcov enabled, not computing coverage.", .{}); + } +} + +fn devhub_coverage(shell: *Shell) !void { + var section = try shell.open_section("coverage"); + defer section.close(); + + const kcov_version = shell.exec_stdout("kcov --version", .{}) catch { + return error.NoKcov; + }; + log.info("kcov version {s}", .{kcov_version}); + + try shell.exec_zig("build test:unit:build", .{}); + try shell.exec_zig("build vopr:build", .{}); + try shell.exec_zig("build fuzz:build", .{}); + + // Put results into src/devhub, as that folder is deployed as GitHub pages. + try shell.project_root.deleteTree("./src/devhub/coverage"); + try shell.project_root.makePath("./src/devhub/coverage"); + + const kcov: []const []const u8 = &.{ "kcov", "--include-path=./src", "./src/devhub/coverage" }; + inline for (.{ + "{kcov} ./zig-out/bin/test-unit", + "{kcov} ./zig-out/bin/fuzz --events-max=500000 lsm_tree 92", + "{kcov} ./zig-out/bin/fuzz --events-max=500000 lsm_forest 92", + "{kcov} ./zig-out/bin/vopr 92", + }) |command| { + try shell.exec(command, .{ .kcov = kcov }); + } + + var coverage_dir = try shell.cwd.openDir("./src/devhub/coverage", .{ .iterate = true }); + defer coverage_dir.close(); + + // kcov adds some symlinks to the output, which prevents upload to GitHub actions from working. + var it = coverage_dir.iterate(); + while (try it.next()) |entry| { + if (entry.kind == .sym_link) { + try coverage_dir.deleteFile(entry.name); + } + } +} + +fn devhub_metrics(shell: *Shell, cli_args: CLIArgs) !void { + var section = try shell.open_section("metrics"); + defer section.close(); + + const commit_timestamp_str = + try shell.exec_stdout("git show -s --format=%ct {sha}", .{ .sha = cli_args.sha }); + const commit_timestamp = try stdx.parse_int(u64, commit_timestamp_str, .{}); + + // Only build the TigerBeetle binary to test build speed and build size. Throw it away once + // done, and use a release build from `zig-out/dist/` to run the benchmark. + var timer = try std.time.Timer.start(); + + const build_time_debug_ms = blk: { + timer.reset(); + try shell.exec_zig("build install", .{}); + defer shell.project_root.deleteFile("tigerbeetle") catch unreachable; + + break :blk timer.read() / std.time.ns_per_ms; + }; + + const build_time_ms, const executable_size_bytes = blk: { + timer.reset(); + try shell.project_root.deleteTree(".zig-cache/tmp/devhub_cache"); + try shell.exec_zig("build -Drelease install", .{}); + defer shell.project_root.deleteFile("tigerbeetle") catch unreachable; + + break :blk .{ + timer.lap() / std.time.ns_per_ms, + (try shell.cwd.statFile("tigerbeetle")).size, + }; + }; + + // When doing a release, the latest release in the changelog on main will be newer than the + // latest release on GitHub. In this case, don't pass in --no-changelog - as doing that causes + // the release code to try and look for a version which doesn't yet exist! + const no_changelog_flag = blk: { + const changelog_text = try shell.project_root.readFileAlloc( + shell.arena.allocator(), + "CHANGELOG.md", + 1 * MiB, + ); + var changelog_iterator = changelog.ChangelogIterator.init(changelog_text); + + const last_release_changelog = changelog_iterator.next_changelog().?.release orelse + break :blk true; + const last_release_published = try Release.parse(try shell.exec_stdout( + "gh release list --json tagName --jq {query} --limit 1", + .{ .query = ".[].tagName" }, + )); + + if (Release.less_than({}, last_release_published, last_release_changelog)) { + break :blk false; + } else { + break :blk true; + } + }; + + if (no_changelog_flag) { + try shell.exec_zig( + \\build scripts -- release --build --no-changelog --sha={sha} + \\ --language=zig --devhub + , .{ .sha = cli_args.sha }); + } else { + try shell.exec_zig( + \\build scripts -- release --build --sha={sha} + \\ --language=zig --devhub + , .{ .sha = cli_args.sha }); + } + try shell.project_root.deleteFile("tigerbeetle"); + + try shell.unzip_executable( + "zig-out/dist/tigerbeetle/tigerbeetle-x86_64-linux.zip", + "tigerbeetle", + ); + + // `--log-debug-replica` is explicitly enabled, to measure the performance hit from debug + // logging and count the log lines. + const benchmark_result, const benchmark_stderr = try shell.exec_stdout_stderr( + \\./tigerbeetle benchmark + \\ --validate --id-order=sequential + \\ --checksum-performance --log-debug-replica + \\ --file=datafile-devhub + , + .{}, + ); + + const integrity_time_ms = blk: { + timer.reset(); + + try shell.exec( + "./tigerbeetle inspect integrity datafile-devhub", + .{}, + ); + + break :blk timer.read() / std.time.ns_per_ms; + }; + + shell.cwd.deleteFile("datafile-devhub") catch unreachable; + + const replica_log_lines = std.mem.count(u8, benchmark_stderr, "\n"); + const tps = try get_measurement(benchmark_result, "load accepted", "tx/s"); + const batch_p100_ms = try get_measurement(benchmark_result, "batch latency p100", "ms"); + const query_p100_ms = try get_measurement(benchmark_result, "query latency p100", "ms"); + const rss_bytes = try get_measurement(benchmark_result, "rss", "bytes"); + const datafile_bytes = try get_measurement(benchmark_result, "datafile", "bytes"); + const datafile_empty_bytes = try get_measurement(benchmark_result, "datafile empty", "bytes"); + const checksum_message_size_max_us = try get_measurement( + benchmark_result, + "checksum message size max", + "us", + ); + const format_time_ms = blk: { + timer.reset(); + + try shell.exec( + "./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 datafile-devhub", + .{}, + ); + + break :blk timer.read() / std.time.ns_per_ms; + }; + defer shell.cwd.deleteFile("datafile-devhub") catch unreachable; + + const stats_count = blk: { + const stats_inspect_result = try shell.exec_stdout("./tigerbeetle inspect metrics", .{}); + var stats_count: u32 = 0; + var lines = std.mem.splitScalar( + u8, + stats_inspect_result, + '\n', + ); + while (lines.next()) |line| { + // line looks like + // timing: compact_mutable_suffix(tree)=136 + if (line.len != 0) { + _, const value_string = stdx.cut(line, "=").?; + stats_count += try stdx.parse_int(u32, value_string, .{}); + } + } + break :blk stats_count; + }; + + const startup_time_ms, const repl_single_command_ms = blk: { + timer.reset(); + + var process = try shell.spawn( + .{ + .stdin_behavior = .Pipe, + .stdout_behavior = .Pipe, + .stderr_behavior = .Ignore, + }, + "./tigerbeetle start --addresses=0 --cache-grid=8GiB datafile-devhub", + .{}, + ); + + defer { + process.stdin.?.close(); + process.stdin = null; + _ = process.wait() catch {}; + } + + const port: u16 = b: { + var buffer: [std.fmt.count("{}\n", .{std.math.maxInt(u16)})]u8 = undefined; + const size = try process.stdout.?.readAll(&buffer); + break :b try stdx.parse_int(u16, buffer[0 .. size - 1], .{}); + }; + + // TODO: This sends a ping manually; once register connection speed has been fixed, this can + // use the benchmark or repl via CLI. + // + // Use Header directly with a blocking TCP connection here, to avoid pulling in half of TB. + const Header = @import("../vsr/message_header.zig").Header; + + var ping = Header.PingClient{ + .command = .ping_client, + .cluster = 0, + .release = Release.minimum, + .client = 1, + .ping_timestamp_monotonic = 0, + .session = 0, + }; + ping.set_checksum_body(&[0]u8{}); + ping.set_checksum(); + + // The release of the built binary is not readily available, since it's set by + // `zig build scripts -- release`. Instead, the ping above is sent with + // .release == Release.minimum. This will always be below a release build's + // release_client_min, so expect the eviction. + var eviction: Header.Eviction = undefined; + + const peer = try std.net.Address.parseIp4("127.0.0.1", port); + const stream = try std.net.tcpConnectToAddress(peer); + defer stream.close(); + + var writer = stream.writer(); + try writer.writeAll(std.mem.asBytes(&ping)[0..@sizeOf(Header)]); + + const reader = stream.reader(); + _ = try reader.readAll(std.mem.asBytes(&eviction)[0..@sizeOf(Header)]); + + assert(eviction.command == .eviction); + assert(eviction.valid_checksum()); + assert(eviction.valid_checksum_body(&[0]u8{})); + + const startup_time_ms = timer.read() / std.time.ns_per_ms; + + // While there's a running instance, check how long the repl takes to connect and run a + // command. + timer.reset(); + + try shell.exec( + "./tigerbeetle repl --addresses={port} --cluster=0 --command={command}", + .{ .port = port, .command = "create_accounts id=1 ledger=1 code=1" }, + ); + + const repl_single_command_ms = timer.read() / std.time.ns_per_ms; + + break :blk .{ startup_time_ms, repl_single_command_ms }; + }; + + const ci_pipeline_duration_s: ?u64 = blk: { + const times_gh = try shell.exec_stdout("gh run list -c {sha} -e merge_group " ++ + "--json startedAt,updatedAt -L 1 --template {template}", .{ + .sha = cli_args.sha, + .template = "{{range .}}{{.startedAt}} {{.updatedAt}}{{end}}", + }); + const iso8601_started_at, const iso8601_updated_at = stdx.cut(times_gh, " ") orelse { + log.err("error parsing run list", .{}); + log.err("output: {s}", .{times_gh}); + break :blk null; + }; + + const epoch_started_at = try shell.iso8601_to_timestamp_seconds(iso8601_started_at); + const epoch_updated_at = try shell.iso8601_to_timestamp_seconds(iso8601_updated_at); + + break :blk epoch_updated_at - epoch_started_at; + } orelse blk: { + // Return 0 instead of null when running locally or without DEVHUBDB_PAT set - the results + // won't be uploaded, and this allows the rest of the code to succeed. + if ((shell.env_get("DEVHUBDB_PAT") catch null) == null) { + break :blk 0; + } else { + break :blk null; + } + }; + + const batch = MetricBatch{ + .timestamp = commit_timestamp, + .attributes = .{ + .git_repo = "https://github.com/tigerbeetle/tigerbeetle", + .git_commit = cli_args.sha, + .branch = "main", + }, + .metrics = &[_]Metric{ + .{ .name = "ci pipeline duration", .value = ci_pipeline_duration_s.?, .unit = "s" }, + .{ .name = "executable size", .value = executable_size_bytes, .unit = "bytes" }, + .{ .name = "TPS", .value = tps, .unit = "count" }, + .{ .name = "batch p100", .value = batch_p100_ms, .unit = "ms" }, + .{ .name = "query p100", .value = query_p100_ms, .unit = "ms" }, + .{ .name = "RSS", .value = rss_bytes, .unit = "bytes" }, + .{ .name = "datafile", .value = datafile_bytes, .unit = "bytes" }, + .{ .name = "datafile empty", .value = datafile_empty_bytes, .unit = "bytes" }, + .{ .name = "replica log lines", .value = replica_log_lines, .unit = "count" }, + .{ + .name = "checksum(message_size_max)", + .value = checksum_message_size_max_us, + .unit = "us", + }, + .{ .name = "build time debug", .value = build_time_debug_ms, .unit = "ms" }, + .{ .name = "build time", .value = build_time_ms, .unit = "ms" }, + .{ .name = "format time", .value = format_time_ms, .unit = "ms" }, + .{ .name = "startup time - 8GiB grid cache", .value = startup_time_ms, .unit = "ms" }, + .{ .name = "stats count", .value = stats_count, .unit = "count" }, + .{ .name = "repl single command", .value = repl_single_command_ms, .unit = "ms" }, + .{ .name = "inspect integrity time", .value = integrity_time_ms, .unit = "ms" }, + }, + }; + + for (batch.metrics) |metric| { + log.info("{s} = {} {s}", .{ metric.name, metric.value, metric.unit }); + } + + upload_run(shell, &batch) catch |err| { + log.err("failed to upload devhubdb metrics: {}", .{err}); + }; + + upload_nyrkio(shell, &batch) catch |err| { + log.err("failed to upload Nyrkiö metrics: {}", .{err}); + }; +} + +fn get_measurement( + benchmark_stdout: []const u8, + comptime label: []const u8, + comptime unit: []const u8, +) !u64 { + errdefer { + std.log.err("can't extract '" ++ label ++ "' measurement", .{}); + } + + _, const rest = stdx.cut(benchmark_stdout, label ++ " = ") orelse + return error.BadMeasurement; + const value_string, _ = stdx.cut(rest, " " ++ unit) orelse return error.BadMeasurement; + + return try stdx.parse_int(u64, value_string, .{}); +} + +fn upload_run(shell: *Shell, batch: *const MetricBatch) !void { + const token = shell.env_get_option("DEVHUBDB_PAT"); + try shell.exec( + \\git clone --single-branch --depth 1 + \\ https://oauth2:{token}@github.com/tigerbeetle/devhubdb.git + \\ devhubdb + , .{ + .token = token orelse "", + }); + + try shell.pushd("./devhubdb"); + defer shell.popd(); + + for (0..32) |_| { + try shell.exec("git fetch origin main", .{}); + try shell.exec("git reset --hard origin/main", .{}); + + { + const file = try shell.cwd.openFile("./devhub/data.json", .{ + .mode = .write_only, + }); + defer file.close(); + + try file.seekFromEnd(0); + try std.json.stringify(batch, .{}, file.writer()); + try file.writeAll("\n"); + } + + try shell.exec("git add ./devhub/data.json", .{}); + try shell.git_env_setup(.{ .use_hostname = false }); + try shell.exec("git commit -m 📈", .{}); + if (token) |_| { + if (shell.exec("git push", .{})) { + log.info("metrics uploaded", .{}); + break; + } else |_| { + log.info("conflict, retrying", .{}); + } + } else { + return error.NoToken; + } + } else { + log.err("can't push new data to devhub", .{}); + return error.CanNotPush; + } +} + +const Metric = struct { + name: []const u8, + unit: []const u8, + value: u64, +}; + +const MetricBatch = struct { + timestamp: u64, + metrics: []const Metric, + attributes: struct { + git_repo: []const u8, + branch: []const u8, + git_commit: []const u8, + }, +}; + +fn upload_nyrkio(shell: *Shell, batch: *const MetricBatch) !void { + const url = "https://nyrkio.com/api/v0/result/devhub"; + const token = try shell.env_get("NYRKIO_TOKEN"); + const payload = try std.json.stringifyAlloc( + shell.arena.allocator(), + [_]*const MetricBatch{batch}, // Nyrkiö needs an _array_ of batches. + .{}, + ); + _ = try shell.http_post(url, payload, .{ + .content_type = .json, + .authorization = try shell.fmt("Bearer {s}", .{token}), + }); +} diff --git a/ocam/src/scripts/release.zig b/ocam/src/scripts/release.zig new file mode 100644 index 00000000..e79825f7 --- /dev/null +++ b/ocam/src/scripts/release.zig @@ -0,0 +1,1301 @@ +//! Orchestrates building and publishing a distribution of tigerbeetle --- a collection of (source +//! and binary) artifacts which constitutes a release and which we upload to various registries. +//! +//! Concretely, the artifacts are: +//! +//! - TigerBeetle binary build for all supported architectures +//! - TigerBeetle clients build for all supported languages +//! +//! This is implemented as a standalone zig script, rather as a step in build.zig, because this is +//! a "meta" build system --- we need to orchestrate `zig build`, `go build`, `npm publish` and +//! friends, and treat them as peers. +//! +//! Note on verbosity: to ease debugging, try to keep the output to O(1) lines per command. The idea +//! here is that, if something goes wrong, you can see _what_ goes wrong and easily copy-paste +//! specific commands to your local terminal, but, at the same time, you don't want to sift through +//! megabytes of info-level noise first. + +const builtin = @import("builtin"); +const std = @import("std"); +const stdx = @import("stdx"); +const log = std.log; +const assert = std.debug.assert; + +const Shell = stdx.Shell; +const multiversion = @import("../multiversion.zig"); +const changelog = @import("./changelog.zig"); +const ci_dotnet = @import("../clients/dotnet/ci.zig"); +const ci_go = @import("../clients/go/ci.zig"); +const ci_java = @import("../clients/java/ci.zig"); +const ci_node = @import("../clients/node/ci.zig"); +const ci_python = @import("../clients/python/ci.zig"); +const ci_rust = @import("../clients/rust/ci.zig"); + +const MiB = stdx.MiB; + +const multiversion_binary_size_max = multiversion.multiversion_binary_size_max; + +const Language = enum { dotnet, go, java, node, python, ruby, rust, zig, docker }; +const LanguageSet = std.enums.EnumSet(Language); +pub const CLIArgs = struct { + sha: []const u8, + language: ?Language = null, + build: bool = false, + publish: bool = false, + // Set if there's no changelog entry for the current code. That is, if the top changelog + // entry describes a past release, and not the release we are creating here. + // + // This flag is used to test the release process on the main branch. + no_changelog: bool = false, + // Allow targeting only production x86_64 Linux, to speed up when invoked via devhub. + devhub: bool = false, +}; + +const VersionInfo = struct { + // release_triple is a comptime parameter of the VSR used for the upgrade protocol. + release_triple: []const u8, + release_triple_client_min: []const u8, + // tag is the symbolic name of the release used as a git tag and a version for client libraries. + // Normally, the tag and the release_triple match, but it is possible to have different tags + // with matching release_triples, for hot-fixes. + tag: []const u8, + // The git tag/GitHub release to download to include in a multiversion binary. + tag_multiversion: []const u8, + commit_sha: []const u8, + commit_timestamp: stdx.InstantUnix, +}; + +pub fn main(shell: *Shell, gpa: std.mem.Allocator, cli_args: CLIArgs) !void { + _ = gpa; + + const languages = if (cli_args.language) |language| + LanguageSet.initOne(language) + else + LanguageSet.initFull(); + + if (cli_args.devhub) { + if (cli_args.language == null or cli_args.language.? != .zig) { + @panic("--devhub is only supported with --languages=zig."); + } + } + + const changelog_text = try shell.project_root.readFileAlloc( + shell.arena.allocator(), + "CHANGELOG.md", + 1 * MiB, + ); + var changelog_iteratator = changelog.ChangelogIterator.init(changelog_text); + const release, const release_multiversion, const changelog_body = blk: { + if (cli_args.no_changelog) { + assert(cli_args.devhub); + assert(!cli_args.publish); + + var last_release = changelog_iteratator.next_changelog().?; + while (last_release.release == null) { + last_release = changelog_iteratator.next_changelog().?; + } + + break :blk .{ + multiversion.Release.from(.{ .major = 65535, .minor = 0, .patch = 0 }), + last_release.release.?, + "", + }; + } else { + const changelog_current = changelog_iteratator.next_changelog().?; + if (changelog_current.release == null) { + @panic("The last changelog entry must have a release version."); + } + const changelog_previous = while (changelog_iteratator.next_changelog()) |entry| { + // The release number can be null if it was tagged as "unreleased". + if (entry.release == null) continue; + break entry; + } else unreachable; + break :blk .{ + changelog_current.release.?, + changelog_previous.release.?, + changelog_current.text_body, + }; + } + }; + assert(multiversion.Release.less_than({}, release_multiversion, release)); + + // Ensure we're building a version newer than the first multiversion release. That was + // bootstrapped with code to do a custom build of the release before that (see git history) + // whereas now past binaries are downloaded and the multiversion parts extracted. + const first_multiversion_release = "0.15.4"; + assert(release.value > + (try multiversion.Release.parse(first_multiversion_release)).value); + + const version_info = VersionInfo{ + .release_triple = try shell.fmt("{[major]}.{[minor]}.{[patch]}", release.triple()), + .release_triple_client_min = @import("vsr_options").release_client_min, + .tag = try shell.fmt( + "{[major]}.{[minor]}.{[patch]}", + release.triple(), + ), + .tag_multiversion = try shell.fmt( + "{[major]}.{[minor]}.{[patch]}", + release_multiversion.triple(), + ), + .commit_sha = cli_args.sha, + .commit_timestamp = try shell.git_commit_timestamp(cli_args.sha), + }; + + // Typically GitHub tag matches the release triple in the binary exactly. For exceptional + // hot-fix releases, the tag can be different. To make a hot-fix release, set the tag manually + // here and remove the assert. + assert(std.mem.eql(u8, version_info.release_triple, version_info.tag)); + + log.info("release={s} sha={s}", .{ version_info.release_triple, version_info.commit_sha }); + if (!std.mem.eql(u8, version_info.release_triple, version_info.tag)) { + log.warn("tag != release, tag={s}", .{version_info.tag}); + } + + if (cli_args.build) { + try build(shell, languages, version_info, cli_args.devhub); + } + + if (cli_args.publish) { + assert(!cli_args.no_changelog); + assert(!cli_args.devhub); + try publish(shell, languages, changelog_body, version_info); + } +} + +fn build(shell: *Shell, languages: LanguageSet, info: VersionInfo, devhub: bool) !void { + var section = try shell.open_section("build all"); + defer section.close(); + + try shell.project_root.deleteTree("zig-out/dist"); + var dist_dir = try shell.project_root.makeOpenPath("zig-out/dist", .{}); + defer dist_dir.close(); + + log.info("building TigerBeetle distribution into {s}", .{ + try dist_dir.realpathAlloc(shell.arena.allocator(), "."), + }); + + if (languages.contains(.zig)) { + var dist_dir_tigerbeetle = try dist_dir.makeOpenPath("tigerbeetle", .{}); + defer dist_dir_tigerbeetle.close(); + + if (devhub) { + try build_tigerbeetle_target(shell, info, dist_dir_tigerbeetle, false, "x86_64-linux"); + } else { + try build_tigerbeetle(shell, info, dist_dir_tigerbeetle); + } + + var dist_dir_vortex = try dist_dir.makeOpenPath("vortex", .{}); + defer dist_dir_vortex.close(); + + const vortex_targets = .{ + "x86_64-linux", + "aarch64-linux", + }; + inline for (vortex_targets) |target| { + try build_vortex_driver_target(shell, info, dist_dir_vortex, target); + } + } + + if (languages.contains(.dotnet)) { + var dist_dir_dotnet = try dist_dir.makeOpenPath("dotnet", .{}); + defer dist_dir_dotnet.close(); + + try build_dotnet(shell, info, dist_dir_dotnet); + } + + if (languages.contains(.go)) { + var dist_dir_go = try dist_dir.makeOpenPath("go", .{}); + defer dist_dir_go.close(); + + try build_go(shell, info, dist_dir_go); + } + + if (languages.contains(.java)) { + var dist_dir_java = try dist_dir.makeOpenPath("java", .{}); + defer dist_dir_java.close(); + + try build_java(shell, info, dist_dir_java); + } + + if (languages.contains(.node)) { + var dist_dir_node = try dist_dir.makeOpenPath("node", .{}); + defer dist_dir_node.close(); + + try build_node(shell, info, dist_dir_node); + } + + if (languages.contains(.python)) { + var dist_dir_python = try dist_dir.makeOpenPath("python", .{}); + defer dist_dir_python.close(); + + try build_python(shell, info, dist_dir_python); + } + + if (languages.contains(.ruby)) { + var dist_dir_ruby = try dist_dir.makeOpenPath("ruby", .{}); + defer dist_dir_ruby.close(); + + try build_ruby(shell, info, dist_dir_ruby); + } + + if (languages.contains(.rust)) { + // Currently disabled. + _ = &build_rust; + } +} + +fn build_tigerbeetle(shell: *Shell, info: VersionInfo, dist_dir: std.fs.Dir) !void { + const targets = .{ + "x86_64-linux", + "x86_64-windows", + "aarch64-linux", + "aarch64-macos", // Will build a universal binary. + }; + + inline for (.{ true, false }) |debug| { + inline for (targets) |target| { + try build_tigerbeetle_target(shell, info, dist_dir, debug, target); + } + } +} + +fn build_tigerbeetle_target( + shell: *Shell, + info: VersionInfo, + dist_dir: std.fs.Dir, + comptime debug: bool, + comptime target: []const u8, +) !void { + var section = try shell.open_section( + "build tigerbeetle - " ++ target ++ " debug=" ++ if (debug) "true" else "false", + ); + defer section.close(); + + // Build tigerbeetle binary for all OS/CPU combinations we support and copy the result to + // `dist`. + try shell.exec_zig( + \\build + \\ -Dtarget={target} + \\ -Drelease={release} + \\ -Dgit-commit={commit} + \\ -Dconfig-release={release_triple} + \\ -Dconfig-release-client-min={release_triple_client_min} + \\ -Dmultiversion={tag_multiversion} + , .{ + .target = target, + .release = if (debug) "false" else "true", + .commit = info.commit_sha, + .release_triple = info.release_triple, + .release_triple_client_min = info.release_triple_client_min, + .tag_multiversion = info.tag_multiversion, + }); + + const linux_aarch64 = comptime std.mem.eql(u8, target, "aarch64-linux"); + const linux_x86_64 = comptime std.mem.eql(u8, target, "x86_64-linux"); + const windows = comptime std.mem.eql(u8, target, "x86_64-windows"); + const macos = comptime std.mem.eql(u8, target, "aarch64-macos"); + + const exe_name = "tigerbeetle" ++ if (windows) ".exe" else ""; + const zip_name = "tigerbeetle-" ++ + (if (macos) "universal-macos" else target) ++ + (if (debug) "-debug" else "") ++ + ".zip"; + + if ((linux_aarch64 and builtin.target.os.tag == .linux and builtin.cpu.arch == .aarch64) or + (linux_x86_64 and builtin.target.os.tag == .linux and builtin.cpu.arch == .x86_64) or + (macos and builtin.target.os.tag == .macos) or + (windows and builtin.target.os.tag == .windows)) + { + const output = try shell.exec_stdout("./{exe_name} version --verbose", .{ + .exe_name = exe_name, + }); + assert(debug == (std.mem.indexOf(u8, output, "process.verify=true") != null)); + const build_mode = if (debug) + "build.mode=builtin.OptimizeMode.Debug" + else + "build.mode=builtin.OptimizeMode.ReleaseSafe"; + assert(std.mem.indexOf(u8, output, build_mode) != null); + } + + const zip_file = try dist_dir.createFile(zip_name, .{ .truncate = false, .exclusive = true }); + defer zip_file.close(); + + try shell.zip_executable( + zip_file, + .{ + .executable_name = exe_name, + .executable_mtime = info.commit_timestamp, + .max_size = multiversion.multiversion_binary_size_max, + }, + ); +} + +fn build_vortex_driver_target( + shell: *Shell, + info: VersionInfo, + dist_dir: std.fs.Dir, + comptime target: []const u8, +) !void { + var section = try shell.open_section("build vortex:driver:zig - " ++ target); + defer section.close(); + + try shell.exec_zig( + \\build vortex:driver:zig + \\ -Dtarget={target} + \\ -Dconfig-release={release_triple} + \\ -Dconfig-release-client-min={release_triple_client_min} + , .{ + .target = target, + .release_triple = info.release_triple, + .release_triple_client_min = info.release_triple_client_min, + }); + + const zip_name = try shell.fmt("vortex-driver-zig-{s}.zip", .{target}); + const zip_file = try dist_dir.createFile(zip_name, .{ .truncate = false, .exclusive = true }); + defer zip_file.close(); + + try shell.pushd("./zig-out/bin"); + defer shell.popd(); + + try shell.zip_executable( + zip_file, + .{ + .executable_name = "vortex-driver-zig", + .executable_mtime = info.commit_timestamp, + .max_size = 16 * MiB, + }, + ); +} + +fn build_dotnet(shell: *Shell, info: VersionInfo, dist_dir: std.fs.Dir) !void { + var section = try shell.open_section("build dotnet"); + defer section.close(); + + try shell.pushd("./src/clients/dotnet"); + defer shell.popd(); + + const dotnet_version = shell.exec_stdout("dotnet --version", .{}) catch { + return error.NoDotnet; + }; + log.info("dotnet version {s}", .{dotnet_version}); + + try shell.exec_zig( + \\build clients:dotnet -Drelease -Dconfig-release={release_triple} + \\ -Dconfig-release-client-min={release_triple_client_min} + , .{ + .release_triple = info.release_triple, + .release_triple_client_min = info.release_triple_client_min, + }); + try shell.exec( + \\dotnet pack TigerBeetle --configuration Release + \\/p:AssemblyVersion={tag} /p:Version={tag} + , .{ .tag = info.tag }); + + try Shell.copy_path( + shell.cwd, + try shell.fmt("TigerBeetle/bin/Release/tigerbeetle.{s}.nupkg", .{info.tag}), + dist_dir, + try shell.fmt("tigerbeetle.{s}.nupkg", .{info.tag}), + ); +} + +fn build_go(shell: *Shell, info: VersionInfo, dist_dir: std.fs.Dir) !void { + var section = try shell.open_section("build go"); + defer section.close(); + + try shell.pushd("./src/clients/go"); + defer shell.popd(); + + try shell.exec_zig( + \\build clients:go -Drelease -Dconfig-release={release_triple} + \\ -Dconfig-release-client-min={release_triple_client_min} + , .{ + .release_triple = info.release_triple, + .release_triple_client_min = info.release_triple_client_min, + }); + + const files = try shell.exec_stdout("git ls-files", .{}); + var files_lines = std.mem.tokenizeScalar(u8, files, '\n'); + var copied_count: u32 = 0; + while (files_lines.next()) |file| { + assert(file.len > 3); + try Shell.copy_path(shell.cwd, file, dist_dir, file); + copied_count += 1; + } + assert(copied_count >= 10); + + const native_files = try shell.find(.{ .where = &.{"."}, .extensions = &.{ ".a", ".lib" } }); + copied_count = 0; + for (native_files) |native_file| { + try Shell.copy_path(shell.cwd, native_file, dist_dir, native_file); + copied_count += 1; + } + // 5 = 3 + 2 + // 3 = x86_64 for mac, windows and linux + // 2 = aarch64 for mac and linux + assert(copied_count == 5); + + const readme = try shell.fmt( + \\# tigerbeetle-go + \\This repo has been automatically generated from + \\[tigerbeetle/tigerbeetle@{[sha]s}](https://github.com/tigerbeetle/tigerbeetle/commit/{[sha]s}) + \\to keep binary blobs out of the monorepo. + \\ + \\Please see + \\ + \\for documentation and contributions. + , .{ .sha = info.commit_sha }); + try dist_dir.writeFile(.{ .sub_path = "README.md", .data = readme }); +} + +fn build_java(shell: *Shell, info: VersionInfo, dist_dir: std.fs.Dir) !void { + var section = try shell.open_section("build java"); + defer section.close(); + + try shell.pushd("./src/clients/java"); + defer shell.popd(); + + const java_version = shell.exec_stdout("java --version", .{}) catch { + return error.NoJava; + }; + log.info("java version {s}", .{java_version}); + + try shell.exec_zig( + \\build clients:java -Drelease -Dconfig-release={release_triple} + \\ -Dconfig-release-client-min={release_triple_client_min} + , .{ + .release_triple = info.release_triple, + .release_triple_client_min = info.release_triple_client_min, + }); + + try backup_create(shell.cwd, "pom.xml"); + defer backup_restore(shell.cwd, "pom.xml"); + + try shell.exec( + \\mvn --batch-mode --quiet --file pom.xml + \\versions:set -DnewVersion={tag} + , .{ .tag = info.tag }); + + try shell.exec( + \\mvn --batch-mode --quiet --file pom.xml + \\ -Dmaven.test.skip -Djacoco.skip + \\ package + , .{}); + + try Shell.copy_path( + shell.cwd, + try shell.fmt("target/tigerbeetle-java-{s}.jar", .{info.tag}), + dist_dir, + try shell.fmt("tigerbeetle-java-{s}.jar", .{info.tag}), + ); +} + +fn build_node(shell: *Shell, info: VersionInfo, dist_dir: std.fs.Dir) !void { + var section = try shell.open_section("build node"); + defer section.close(); + + try shell.pushd("./src/clients/node"); + defer shell.popd(); + + const node_version = shell.exec_stdout("node --version", .{}) catch { + return error.NoNode; + }; + log.info("node version {s}", .{node_version}); + + try shell.exec_zig( + \\build clients:node -Drelease -Dconfig-release={release_triple} + \\ -Dconfig-release-client-min={release_triple_client_min} + , .{ + .release_triple = info.release_triple, + .release_triple_client_min = info.release_triple_client_min, + }); + + try backup_create(shell.cwd, "package.json"); + defer backup_restore(shell.cwd, "package.json"); + + try backup_create(shell.cwd, "package-lock.json"); + defer backup_restore(shell.cwd, "package-lock.json"); + + try shell.exec( + "npm version --no-git-tag-version {tag}", + .{ .tag = info.tag }, + ); + try shell.exec("npm ci", .{}); + try shell.exec("npm run prepare", .{}); + try shell.exec("npm pack --quiet", .{}); + + try Shell.copy_path( + shell.cwd, + try shell.fmt("tigerbeetle-node-{s}.tgz", .{info.tag}), + dist_dir, + try shell.fmt("tigerbeetle-node-{s}.tgz", .{info.tag}), + ); +} + +fn build_python(shell: *Shell, info: VersionInfo, dist_dir: std.fs.Dir) !void { + var section = try shell.open_section("build python"); + defer section.close(); + + try shell.pushd("./src/clients/python"); + defer shell.popd(); + + const python_version = shell.exec_stdout("python3 --version", .{}) catch { + return error.NoPython; + }; + log.info("{s}", .{python_version}); + + try shell.exec_zig( + \\build clients:python -Drelease -Dconfig-release={release_triple} + \\ -Dconfig-release-client-min={release_triple_client_min} + , .{ + .release_triple = info.release_triple, + .release_triple_client_min = info.release_triple_client_min, + }); + + const python_wheel = @import("../clients/python/wheel.zig"); + try python_wheel.make( + shell, + info.tag, + info.commit_timestamp, + try shell.fmt("dist/tigerbeetle-{s}-py3-none-any.whl", .{info.tag}), + ); + + try Shell.copy_path( + shell.cwd, + try shell.fmt("dist/tigerbeetle-{s}-py3-none-any.whl", .{info.tag}), + dist_dir, + try shell.fmt("tigerbeetle-{s}-py3-none-any.whl", .{info.tag}), + ); +} + +fn build_ruby(shell: *Shell, info: VersionInfo, dist_dir: std.fs.Dir) !void { + var section = try shell.open_section("build ruby"); + defer section.close(); + + try shell.pushd("./src/clients/ruby"); + defer shell.popd(); + + const ruby_version = shell.exec_stdout("ruby --version", .{}) catch { + return error.NoRuby; + }; + log.info("{s}", .{ruby_version}); + + try shell.exec_zig( + \\build clients:ruby -Drelease -Dconfig-release={release_triple} + \\ -Dconfig-release-client-min={release_triple_client_min} + , .{ + .release_triple = info.release_triple, + .release_triple_client_min = info.release_triple_client_min, + }); + + try backup_create(shell.cwd, "src/tigerbeetle/version.rb"); + defer backup_restore(shell.cwd, "src/tigerbeetle/version.rb"); + + const version_rb = try shell.cwd.readFileAlloc( + shell.arena.allocator(), + "src/tigerbeetle/version.rb", + 1 * MiB, + ); + const version_line = try shell.fmt( + "VERSION = \"{s}\"", + .{info.tag}, + ); + const version_rb_updated = try std.mem.replaceOwned( + u8, + shell.arena.allocator(), + version_rb, + "VERSION = \"0.0.1\"", + version_line, + ); + assert(std.mem.indexOf(u8, version_rb_updated, version_line) != null); + + try shell.cwd.writeFile(.{ + .sub_path = "src/tigerbeetle/version.rb", + .data = version_rb_updated, + }); + + try shell.exec("gem build tigerbeetle.gemspec", .{}); + + try Shell.copy_path( + shell.cwd, + try shell.fmt("tigerbeetle-{s}.gem", .{info.tag}), + dist_dir, + try shell.fmt("tigerbeetle-{s}.gem", .{info.tag}), + ); +} + +fn build_rust(shell: *Shell, info: VersionInfo, dist_dir: std.fs.Dir) !void { + var section = try shell.open_section("build rust"); + defer section.close(); + + try shell.pushd("./src/clients/rust"); + defer shell.popd(); + + const cargo_version = shell.exec_stdout("cargo --version", .{}) catch { + return error.NoCargo; + }; + log.info("{s}", .{cargo_version}); + + try shell.exec_zig( + \\build clients:rust -Drelease -Dconfig-release={release_triple} + \\ -Dconfig-release-client-min={release_triple_client_min} + , .{ + .release_triple = info.release_triple, + .release_triple_client_min = info.release_triple_client_min, + }); + + try backup_create(shell.cwd, "Cargo.toml"); + defer backup_restore(shell.cwd, "Cargo.toml"); + + const cargo_toml = try shell.cwd.readFileAlloc( + shell.arena.allocator(), + "Cargo.toml", + 1 * MiB, + ); + const version_line = try shell.fmt( + "version = \"{s}\"", + .{info.tag}, + ); + const cargo_toml_updated = try std.mem.replaceOwned( + u8, + shell.arena.allocator(), + cargo_toml, + "version = \"0.0.0\"", + version_line, + ); + assert(std.mem.indexOf(u8, cargo_toml_updated, version_line) != null); + + try shell.cwd.writeFile(.{ + .sub_path = "Cargo.toml", + .data = cargo_toml_updated, + }); + + try shell.exec("cargo package --allow-dirty", .{}); + + try Shell.copy_path( + shell.cwd, + try shell.fmt("target/package/tigerbeetle-{s}.crate", .{info.tag}), + dist_dir, + try shell.fmt("tigerbeetle-{s}.crate", .{info.tag}), + ); +} + +fn publish( + shell: *Shell, + languages: LanguageSet, + changelog_body: []const u8, + info: VersionInfo, +) !void { + var section = try shell.open_section("publish all"); + defer section.close(); + + { // Sanity check that the new release doesn't exist but the multiversion does. + var tag_multiversion_exists = false; + var tag_exists = false; + const tags_exiting = try shell.exec_stdout( + "gh release list --json tagName --jq {query}", + .{ .query = ".[].tagName" }, + ); + var it = std.mem.splitScalar(u8, tags_exiting, '\n'); + while (it.next()) |tag_existing| { + assert(std.mem.trim(u8, tag_existing, " \t\n\r").len == tag_existing.len); + if (std.mem.eql(u8, tag_existing, info.release_triple)) { + tag_exists = true; + } + if (std.mem.eql(u8, tag_existing, info.tag_multiversion)) { + tag_multiversion_exists = true; + } + } + assert(!tag_exists); + assert(tag_multiversion_exists); + } + + assert(try shell.dir_exists("zig-out/dist")); + + if (languages.contains(.zig)) { + _ = try shell.env_get("GITHUB_TOKEN"); + const gh_version = shell.exec_stdout("gh --version", .{}) catch { + return error.NoGh; + }; + log.info("gh version {s}", .{gh_version}); + + const release_included_min = blk: { + shell.project_root.deleteFile("tigerbeetle") catch {}; + defer shell.project_root.deleteFile("tigerbeetle") catch {}; + + try shell.unzip_executable( + "zig-out/dist/tigerbeetle/tigerbeetle-x86_64-linux.zip", + "tigerbeetle", + ); + + const past_binary_contents = try shell.cwd.readFileAllocOptions( + shell.arena.allocator(), + "tigerbeetle", + multiversion_binary_size_max, + null, + 8, + null, + ); + + const parsed_offsets = try multiversion.parse_elf(past_binary_contents); + const header_bytes = + past_binary_contents[parsed_offsets.x86_64.?.header_offset..][0..@sizeOf( + multiversion.MultiversionHeader, + )]; + + const header = try multiversion.MultiversionHeader.init_from_bytes(header_bytes); + const release_min = header.past.releases[0]; + const release_max = header.past.releases[header.past.count - 1]; + assert(release_min < release_max); + + break :blk multiversion.Release{ .value = release_min }; + }; + + const notes = try shell.fmt( + \\# {[tag]s} + \\ + \\### Supported upgrade versions + \\ + \\Oldest supported client version: {[release_triple_client_min]s} + \\Oldest upgradable replica version: {[release_included_min]s} + \\ + \\## Server + \\ + \\* Binary: Download the zip for your OS and architecture from this page and unzip. + \\* Docker: `docker pull ghcr.io/tigerbeetle/tigerbeetle:{[tag]s}` + \\* Docker (debug image): `docker pull ghcr.io/tigerbeetle/tigerbeetle:{[tag]s}-debug` + \\ + \\## Clients + \\ + \\**NOTE**: Because of package manager caching, it may take a few + \\minutes after the release for this version to appear in the package + \\manager. + \\ + \\You cannot run a newer client against an older cluster: clients are only compatible + \\with replicas from their own release *or newer*, subject to the newer release's + \\`Oldest supported client version`. + \\ + \\* .NET: `dotnet add package tigerbeetle --version {[tag]s}` + \\* Go: `go mod edit -require github.com/tigerbeetle/tigerbeetle-go@v{[tag]s}` + \\* Java: Update the version of `com.tigerbeetle.tigerbeetle-java` in `pom.xml` + \\ to `{[tag]s}`. + \\* Node.js: `npm install --save-exact tigerbeetle-node@{[tag]s}` + \\* Python: `pip install tigerbeetle=={[tag]s}` + \\ + \\## Changelog + \\ + \\{[changelog]s} + , .{ + .tag = info.tag, + .release_triple_client_min = info.release_triple_client_min, + .release_included_min = release_included_min, + .changelog = changelog_body, + }); + + try shell.exec( + \\gh release create --draft + \\ --target {sha} + \\ --notes {notes} + \\ {tag} + , .{ + .sha = info.commit_sha, + .notes = notes, + .tag = info.tag, + }); + + // Here and elsewhere for publishing we explicitly spell out the files we are uploading + // instead of using a for loop to double-check the logic in `build`. + const artifacts: []const []const u8 = &.{ + "zig-out/dist/tigerbeetle/tigerbeetle-aarch64-linux-debug.zip", + "zig-out/dist/tigerbeetle/tigerbeetle-aarch64-linux.zip", + "zig-out/dist/tigerbeetle/tigerbeetle-universal-macos-debug.zip", + "zig-out/dist/tigerbeetle/tigerbeetle-universal-macos.zip", + "zig-out/dist/tigerbeetle/tigerbeetle-x86_64-linux-debug.zip", + "zig-out/dist/tigerbeetle/tigerbeetle-x86_64-linux.zip", + "zig-out/dist/tigerbeetle/tigerbeetle-x86_64-windows-debug.zip", + "zig-out/dist/tigerbeetle/tigerbeetle-x86_64-windows.zip", + "zig-out/dist/vortex/vortex-driver-zig-aarch64-linux.zip", + "zig-out/dist/vortex/vortex-driver-zig-x86_64-linux.zip", + }; + try shell.exec("gh release upload {tag} {artifacts}", .{ + .tag = info.tag, + .artifacts = artifacts, + }); + } + + if (languages.contains(.docker)) try publish_docker(shell, info); + if (languages.contains(.dotnet)) try publish_dotnet(shell, info); + if (languages.contains(.go)) try publish_go(shell, info); + if (languages.contains(.java)) try publish_java(shell, info); + if (languages.contains(.node)) try publish_node(shell, info); + if (languages.contains(.python)) try publish_python(shell, info); + if (languages.contains(.ruby)) try publish_ruby(shell, info); + // Currently disabled. + _ = &publish_rust; + + if (languages.contains(.zig)) { + try shell.exec( + \\gh release edit --draft=false --latest=true + \\ {tag} + , .{ .tag = info.tag }); + + // Build our docs last so that if it fails everything else is still released. + try publish_docs(shell, info); + } +} + +fn publish_dotnet(shell: *Shell, info: VersionInfo) !void { + var section = try shell.open_section("publish dotnet"); + defer section.close(); + + assert(try shell.dir_exists("zig-out/dist/dotnet")); + + if (try is_already_published(shell, ci_dotnet, info)) return; + + const nuget_key = try shell.env_get("NUGET_KEY"); + try shell.exec( + \\dotnet nuget push + \\ --api-key {nuget_key} + \\ --source https://api.nuget.org/v3/index.json + \\ --skip-duplicate + \\ {package} + , .{ + .nuget_key = nuget_key, + .package = try shell.fmt("zig-out/dist/dotnet/tigerbeetle.{s}.nupkg", .{ + info.tag, + }), + }); +} + +fn publish_go(shell: *Shell, info: VersionInfo) !void { + var section = try shell.open_section("publish go"); + defer section.close(); + + assert(try shell.dir_exists("zig-out/dist/go")); + + if (try is_already_published(shell, ci_go, info)) return; + + const token = try shell.env_get("TIGERBEETLE_GO_PAT"); + try shell.exec( + \\git clone --no-checkout --depth 1 + \\ https://oauth2:{token}@github.com/tigerbeetle/tigerbeetle-go.git tigerbeetle-go + , .{ .token = token }); + defer { + shell.project_root.deleteTree("tigerbeetle-go") catch {}; + } + + const dist_files = try shell.find(.{ .where = &.{"zig-out/dist/go"} }); + assert(dist_files.len > 10); + for (dist_files) |file| { + try Shell.copy_path( + shell.project_root, + file, + shell.project_root, + try std.mem.replaceOwned( + u8, + shell.arena.allocator(), + file, + "zig-out/dist/go", + "tigerbeetle-go", + ), + ); + } + + try shell.pushd("./tigerbeetle-go"); + defer shell.popd(); + + try shell.exec("git add .", .{}); + // Native libraries are ignored in this repository, but we want to push them to the + // tigerbeetle-go one! + try shell.exec("git add --force native", .{}); + + try shell.git_env_setup(.{ .use_hostname = false }); + try shell.exec("git commit --message {message}", .{ + .message = try shell.fmt( + "Autogenerated commit from tigerbeetle/tigerbeetle@{s}", + .{info.commit_sha}, + ), + }); + + try shell.exec("git tag tigerbeetle-{sha}", .{ .sha = info.commit_sha }); + try shell.exec("git tag v{tag}", .{ .tag = info.tag }); + + try shell.exec("git push origin main", .{}); + try shell.exec("git push origin tigerbeetle-{sha}", .{ .sha = info.commit_sha }); + try shell.exec("git push origin v{tag}", .{ .tag = info.tag }); +} + +fn publish_java(shell: *Shell, info: VersionInfo) !void { + var section = try shell.open_section("publish java"); + defer section.close(); + + assert(try shell.dir_exists("zig-out/dist/java")); + + if (try is_already_published(shell, ci_java, info)) return; + + // These variables don't have a special meaning in maven, and instead are a part of + // settings.xml generated by GitHub actions. + _ = try shell.env_get("MAVEN_USERNAME"); + _ = try shell.env_get("MAVEN_CENTRAL_TOKEN"); + _ = try shell.env_get("MAVEN_GPG_PASSPHRASE"); + + // TODO: Maven uniquely doesn't support uploading pre-build package, so here we just rebuild + // from source and upload a _different_ artifact. This is wrong. + // + // As far as I can tell, there isn't a great solution here. See, for example: + // + // + // + // I think what we should do here is for `build` to deploy to the local repo, and then use + // + // + // + // to move the contents of that local repo to maven central. But this is todo, just rebuild now. + try backup_create(shell.project_root, "src/clients/java/pom.xml"); + defer backup_restore(shell.project_root, "src/clients/java/pom.xml"); + + try shell.exec( + \\mvn --batch-mode --quiet --file src/clients/java/pom.xml + \\ versions:set -DnewVersion={tag} + , .{ .tag = info.tag }); + + // Retrying in case of timeout: + const attempts_max = 5; + for (0..attempts_max) |index| { + return shell.exec_options(.{ .timeout = .minutes(5) }, + \\mvn --batch-mode --quiet --file src/clients/java/pom.xml + \\ -Dmaven.test.skip -Djacoco.skip + \\ deploy + , .{}) catch |err| switch (err) { + error.ExecTimeout => { + const attempt = index + 1; + log.warn("java deploy timed out. Attempt={}", .{attempt}); + if (attempt == attempts_max) return err; + continue; + }, + else => err, + }; + } else unreachable; +} + +fn publish_node(shell: *Shell, info: VersionInfo) !void { + var section = try shell.open_section("publish node"); + defer section.close(); + + assert(try shell.dir_exists("zig-out/dist/node")); + + if (try is_already_published(shell, ci_node, info)) return; + + try shell.exec("npm publish {package}", .{ + .package = try shell.fmt("zig-out/dist/node/tigerbeetle-node-{s}.tgz", .{ + info.tag, + }), + }); +} + +fn publish_python(shell: *Shell, info: VersionInfo) !void { + var section = try shell.open_section("publish python"); + defer section.close(); + + assert(try shell.dir_exists("zig-out/dist/python")); + + if (try is_already_published(shell, ci_python, info)) return; + + _ = try shell.env_get("TWINE_USERNAME"); + _ = try shell.env_get("TWINE_PASSWORD"); + + try shell.exec("python3 -m twine upload {package}", .{ + .package = try shell.fmt("zig-out/dist/python/tigerbeetle-{s}-py3-none-any.whl", .{ + info.tag, + }), + }); +} + +fn publish_ruby(shell: *Shell, info: VersionInfo) !void { + var section = try shell.open_section("publish ruby"); + defer section.close(); + + assert(try shell.dir_exists("zig-out/dist/ruby")); + + const token = try publish_ruby_trusted_publishing_token(shell); + assert(token.len > 0); + try shell.env.put("GEM_HOST_API_KEY", token); + + try shell.exec("gem push {package}", .{ + .package = try shell.fmt("zig-out/dist/ruby/tigerbeetle-{s}.gem", .{info.tag}), + }); +} + +fn publish_ruby_trusted_publishing_token(shell: *Shell) ![]const u8 { + const trusted_publishing_token = try shell.env_get("ACTIONS_ID_TOKEN_REQUEST_TOKEN"); + const trusted_publishing_url = try shell.env_get("ACTIONS_ID_TOKEN_REQUEST_URL"); + + const oidc_response = try shell.http_get( + try shell.fmt("{s}&audience={%}", .{ + trusted_publishing_url, + std.Uri.Component{ .raw = "rubygems.org" }, + }), + .{ + .authorization = try shell.fmt("bearer {s}", .{trusted_publishing_token}), + }, + ); + const oidc = try std.json.parseFromSliceLeaky( + struct { value: []const u8 }, + shell.arena.allocator(), + oidc_response, + .{ .ignore_unknown_fields = true }, + ); + + const rubygems_request = try std.json.stringifyAlloc( + shell.arena.allocator(), + .{ .jwt = oidc.value }, + .{}, + ); + const rubygems_response = try shell.http_post( + "https://rubygems.org/api/v1/oidc/trusted_publisher/exchange_token", + rubygems_request, + .{ + .content_type = .json, + .expected_response_code = .created, + }, + ); + const rubygems = try std.json.parseFromSliceLeaky( + struct { rubygems_api_key: []const u8 }, + shell.arena.allocator(), + rubygems_response, + .{ .ignore_unknown_fields = true }, + ); + + return rubygems.rubygems_api_key; +} + +fn publish_rust(shell: *Shell, info: VersionInfo) !void { + var section = try shell.open_section("publish rust"); + defer section.close(); + + assert(try shell.dir_exists("zig-out/dist/rust")); + + if (try is_already_published(shell, ci_rust, info)) return; + + const token = try shell.env_get("CRATES_IO_TOKEN"); + + try shell.pushd("./src/clients/rust"); + defer shell.popd(); + + try shell.exec_zig( + \\build clients:rust -Drelease -Dconfig-release={release_triple} + \\ -Dconfig-release-client-min={release_triple_client_min} + , .{ + .release_triple = info.release_triple, + .release_triple_client_min = info.release_triple_client_min, + }); + + try backup_create(shell.cwd, "Cargo.toml"); + defer backup_restore(shell.cwd, "Cargo.toml"); + + const cargo_toml = try shell.cwd.readFileAlloc( + shell.arena.allocator(), + "Cargo.toml", + 1 * MiB, + ); + const version_line = try shell.fmt( + "version = \"{s}\"", + .{info.tag}, + ); + const cargo_toml_updated = try std.mem.replaceOwned( + u8, + shell.arena.allocator(), + cargo_toml, + "version = \"0.0.0\"", + version_line, + ); + assert(std.mem.indexOf(u8, cargo_toml_updated, version_line) != null); + + try shell.cwd.writeFile(.{ + .sub_path = "Cargo.toml", + .data = cargo_toml_updated, + }); + + try shell.exec("cargo publish --token {token} --allow-dirty", .{ + .token = token, + }); +} + +// Docker is not required and not recommended for running TigerBeetle. A container is published +// just for convenience of consumers expecting one! +fn publish_docker(shell: *Shell, info: VersionInfo) !void { + var section = try shell.open_section("publish docker"); + defer section.close(); + + assert(try shell.dir_exists("zig-out/dist/tigerbeetle")); + + if (try is_already_published(shell, ci_docker, info)) return; + + try shell.exec( + \\docker login --username tigerbeetle --password {password} ghcr.io + , .{ + .password = try shell.env_get("GITHUB_TOKEN"), + }); + + try shell.exec( + \\docker buildx create --use + , .{}); + + for ([_]bool{ true, false }) |debug| { + const triples = [_][]const u8{ "aarch64-linux", "x86_64-linux" }; + const docker_arches = [_][]const u8{ "arm64", "amd64" }; + for (triples, docker_arches) |triple, docker_arch| { + // We need to unzip binaries from dist. For simplicity, don't bother with a temporary + // directory. + shell.project_root.deleteFile("tigerbeetle") catch {}; + + const zip_path = try shell.fmt( + "./zig-out/dist/tigerbeetle/tigerbeetle-{s}{s}.zip", + .{ triple, if (debug) "-debug" else "" }, + ); + try shell.unzip_executable(zip_path, "tigerbeetle"); + + try shell.project_root.rename( + "tigerbeetle", + try shell.fmt("tigerbeetle-{s}", .{docker_arch}), + ); + } + // Build docker container by copying pre-build executable inside. + // + // TigerBeetle doesn't install its own signal handlers, and PID 1 doesn't have a default + // SIGTERM signal handler. (See https://github.com/krallin/tini#why-tini). Using "tini" as + // PID 1 ensures that signals work as expected, so e.g. "docker stop" will not hang. + try shell.exec_options( + .{ + .stdin_slice = + \\FROM alpine:latest + \\RUN apk add --no-cache tini + \\ARG TARGETARCH + \\COPY tigerbeetle-${TARGETARCH} /tigerbeetle + \\ENTRYPOINT ["tini", "--", "/tigerbeetle"] + , + }, + \\docker buildx build + \\ --file - . + \\ --platform linux/amd64,linux/arm64 + \\ --tag ghcr.io/tigerbeetle/tigerbeetle:{tag}{debug} + \\ {tag_latest} + \\ --push + , + .{ + .tag = info.tag, + .debug = if (debug) "-debug" else "", + .tag_latest = @as( + []const []const u8, + if (debug) &.{} else &.{ "--tag", "ghcr.io/tigerbeetle/tigerbeetle:latest" }, + ), + }, + ); + + // Sadly, there isn't an easy way to locally build & test a multiplatform image without + // pushing it out to the registry first. As docker testing isn't covered under not rocket + // science rule, let's do a best effort after-the-fact testing here. + const version_verbose = try shell.exec_stdout( + \\docker run ghcr.io/tigerbeetle/tigerbeetle:{tag}{debug} version --verbose + , .{ + .tag = info.tag, + .debug = if (debug) "-debug" else "", + }); + const mode = if (debug) "Debug" else "ReleaseSafe"; + assert(std.mem.indexOf(u8, version_verbose, mode) != null); + assert(std.mem.indexOf(u8, version_verbose, info.release_triple) != null); + } +} + +const ci_docker = struct { + fn release_published_latest(shell: *Shell) ![]const u8 { + // output: "TigerBeetle version X.Y.Z+git_sha". + const output = try shell.exec_stdout( + \\docker run --rm --platform linux/amd64 ghcr.io/tigerbeetle/tigerbeetle:latest version + , .{}); + const prefix = "TigerBeetle version "; + const version_start = std.mem.indexOf(u8, output, prefix).? + prefix.len; + const version_end = std.mem.indexOf(u8, output, "+").?; + return output[version_start..version_end]; + } +}; + +fn publish_docs(shell: *Shell, info: VersionInfo) !void { + var section = try shell.open_section("publish docs"); + defer section.close(); + + { + try shell.pushd("./src/docs_website"); + defer shell.popd(); + + try shell.exec_zig("build", .{}); + } + + const token = try shell.env_get("TIGERBEETLE_DOCS_PAT"); + try shell.exec( + \\git clone --no-checkout --depth 1 + \\ https://oauth2:{token}@github.com/tigerbeetle/docs.git tigerbeetle-docs + , .{ .token = token }); + defer { + shell.project_root.deleteTree("tigerbeetle-docs") catch {}; + } + + const docs_files = try shell.find(.{ .where = &.{"src/docs_website/zig-out"} }); + assert(docs_files.len > 10); + for (docs_files) |file| { + try Shell.copy_path( + shell.project_root, + file, + shell.project_root, + try std.mem.replaceOwned( + u8, + shell.arena.allocator(), + file, + "src/docs_website/zig-out", + "tigerbeetle-docs/", + ), + ); + } + + try shell.pushd("./tigerbeetle-docs"); + defer shell.popd(); + + try shell.exec("git add .", .{}); + try shell.env.put("GIT_AUTHOR_NAME", "TigerBeetle Bot"); + try shell.env.put("GIT_AUTHOR_EMAIL", "bot@tigerbeetle.com"); + try shell.env.put("GIT_COMMITTER_NAME", "TigerBeetle Bot"); + try shell.env.put("GIT_COMMITTER_EMAIL", "bot@tigerbeetle.com"); + // We want to push a commit even if there are no changes to the docs, to make sure + // that the latest commit message on the docs repo points to the latest tigerbeetle + // release. + try shell.exec("git commit --allow-empty --message {message}", .{ + .message = try shell.fmt( + "Autogenerated commit from tigerbeetle/tigerbeetle@{s}", + .{info.commit_sha}, + ), + }); + + try shell.exec("git push origin main", .{}); +} + +fn is_already_published(shell: *Shell, comptime ci: type, info: VersionInfo) !bool { + const published_tag = try ci.release_published_latest(shell); + const published_release = try multiversion.Release.parse(published_tag); + const release = try multiversion.Release.parse(info.tag); + assert(published_release.value <= release.value); + if (std.mem.eql(u8, published_tag, info.tag)) { + log.info("{s} is already published.", .{info.tag}); + return true; + } + return false; +} + +fn backup_create(dir: std.fs.Dir, comptime file: []const u8) !void { + try Shell.copy_path(dir, file, dir, file ++ ".backup"); +} + +fn backup_restore(dir: std.fs.Dir, comptime file: []const u8) void { + dir.deleteFile(file) catch {}; + Shell.copy_path(dir, file ++ ".backup", dir, file) catch {}; + dir.deleteFile(file ++ ".backup") catch {}; +} diff --git a/ocam/src/stack.zig b/ocam/src/stack.zig new file mode 100644 index 00000000..eea24030 --- /dev/null +++ b/ocam/src/stack.zig @@ -0,0 +1,260 @@ +const std = @import("std"); +const stdx = @import("stdx"); +const assert = std.debug.assert; + +const constants = @import("./constants.zig"); + +pub const StackLink = extern struct { + next: ?*StackLink = null, +}; + +/// An intrusive last in/first out linked list (LIFO). +/// The element type T must have a field called "next" of type StackType(T).Link. +pub fn StackType(comptime T: type) type { + return struct { + any: StackAny, + + pub const Link = StackLink; + const Stack = @This(); + + pub inline fn init(options: struct { + capacity: u32, + verify_push: bool, + }) Stack { + return .{ .any = .{ + .capacity = options.capacity, + .verify_push = options.verify_push, + } }; + } + + pub inline fn count(self: *const Stack) u32 { + return self.any.count; + } + + pub inline fn capacity(self: *const Stack) u32 { + return self.any.capacity; + } + + /// Pushes a new node to the first position of the Stack. + pub inline fn push(self: *Stack, node: *T) void { + self.any.push(&node.link); + } + + /// Returns the first element of the Stack list, and removes it. + pub inline fn pop(self: *Stack) ?*T { + const link = self.any.pop() orelse return null; + return @fieldParentPtr("link", link); + } + + /// Returns the first element of the Stack list, but does not remove it. + pub inline fn peek(self: *const Stack) ?*T { + const link = self.any.peek() orelse return null; + return @fieldParentPtr("link", link); + } + + /// Checks if the Stack is empty. + pub inline fn empty(self: *const Stack) bool { + return self.any.empty(); + } + + /// Returns whether the linked list contains the given *exact element* (pointer comparison). + inline fn contains(self: *const Stack, needle: *const T) bool { + return self.any.contains(&needle.link); + } + }; +} + +// Non-generic implementation for smaller binary and faster compile times. +const StackAny = struct { + head: ?*StackLink = null, + + count: u32 = 0, + capacity: u32, + + // If the number of elements is large, the constants.verify check in push() can be too + // expensive. Allow the user to gate it. + verify_push: bool, + + fn push(self: *StackAny, link: *StackLink) void { + if (constants.verify and self.verify_push) assert(!self.contains(link)); + + assert((self.count == 0) == (self.head == null)); + assert(link.next == null); + assert(self.count < self.capacity); + + // Insert the new element at the head. + link.next = self.head; + self.head = link; + self.count += 1; + } + + fn pop(self: *StackAny) ?*StackLink { + assert((self.count == 0) == (self.head == null)); + + const link = self.head orelse return null; + self.head = link.next; + link.next = null; + self.count -= 1; + return link; + } + + fn peek(self: *const StackAny) ?*StackLink { + return self.head; + } + + fn empty(self: *const StackAny) bool { + assert((self.count == 0) == (self.head == null)); + return self.head == null; + } + + fn contains(self: *const StackAny, needle: *const StackLink) bool { + assert(self.count <= self.capacity); + var next = self.head; + for (0..self.count + 1) |_| { + const link = next orelse return false; + if (link == needle) return true; + next = link.next; + } else unreachable; + } +}; + +test "Stack: fuzz" { + // Fuzzy test to compare behavior of Stack against std.ArrayList (reference model). + comptime assert(constants.verify); + + const allocator = std.testing.allocator; + + var prng = stdx.PRNG.from_seed_testing(); + + const Item = struct { + id: u32, + link: StackType(@This()).Link, + }; + const Stack = StackType(Item); + + const item_count_max = 1024; + const events_max = 1 << 10; + + const Event = enum { push, pop }; + const event_weights = stdx.PRNG.EnumWeightsType(Event){ + .push = 2, + .pop = 1, + }; + + // Allocate a pool of nodes. + var items = try allocator.alloc(Item, item_count_max); + defer allocator.free(items); + + for (items, 0..) |*item, i| { + item.* = Item{ .id = @intCast(i), .link = .{} }; + } + + // A bit set that tracks which nodes are available. + var items_free = try std.DynamicBitSetUnmanaged.initFull(allocator, item_count_max); + defer items_free.deinit(allocator); + + var stack = Stack.init(.{ + .capacity = item_count_max, + .verify_push = true, + }); + + // Reference model: a dynamic array of node IDs in Stack order (last is the top). + var model = try std.ArrayList(u32).initCapacity(allocator, item_count_max); + defer model.deinit(); + + // Run a sequence of randomized events. + for (0..events_max) |_| { + assert(model.items.len <= item_count_max); + assert(model.items.len == stack.count()); + assert(model.items.len == 0 or !stack.empty()); + + const event = prng.enum_weighted(Event, event_weights); + switch (event) { + .push => { + // Only push if a free node is available. + const free_index = items_free.findFirstSet() orelse continue; + const item = &items[free_index]; + stack.push(item); + try model.append(item.id); + items_free.unset(item.id); + }, + .pop => { + if (stack.pop()) |item| { + // The reference model should have the same node at the top. + const id = item.id; + const expected = model.pop(); + assert(id == expected); + items_free.set(id); + } else { + assert(model.items.len == 0); + assert(stack.empty()); + assert(stack.count() == 0); + assert(stack.peek() == null); + } + }, + } + // Verify that peek() returns the same as the last element in our model. + if (model.items.len > 0) { + const top = stack.peek() orelse unreachable; + const top_ref = model.pop().?; + assert(top.id == top_ref); + try model.append(top_ref); + } else { + assert(stack.empty()); + assert(stack.count() == 0); + assert(stack.peek() == null); + } + } + + // Finally, empty the Stack and ensure our reference model agrees. + while (stack.pop()) |item| { + const id = item.id; + const expected = model.pop(); + assert(id == expected); + items_free.set(id); + } + assert(model.items.len == 0); + assert(stack.empty()); + assert(stack.count() == 0); + assert(stack.peek() == null); +} + +test "Stack: push/pop/peek/empty" { + const testing = @import("std").testing; + const Item = struct { link: StackLink = .{} }; + + var one: Item = .{}; + var two: Item = .{}; + var three: Item = .{}; + + var stack: StackType(Item) = StackType(Item).init(.{ + .capacity = 3, + .verify_push = true, + }); + + try testing.expect(stack.empty()); + + // Push one element and verify + stack.push(&one); + try testing.expect(!stack.empty()); + try testing.expectEqual(@as(?*Item, &one), stack.peek()); + try testing.expect(stack.contains(&one)); + try testing.expect(!stack.contains(&two)); + try testing.expect(!stack.contains(&three)); + + // Push two more elements + stack.push(&two); + stack.push(&three); + try testing.expect(!stack.empty()); + try testing.expectEqual(@as(?*Item, &three), stack.peek()); + try testing.expect(stack.contains(&one)); + try testing.expect(stack.contains(&two)); + try testing.expect(stack.contains(&three)); + + // Pop elements and check Stack order + try testing.expectEqual(@as(?*Item, &three), stack.pop()); + try testing.expectEqual(@as(?*Item, &two), stack.pop()); + try testing.expectEqual(@as(?*Item, &one), stack.pop()); + try testing.expect(stack.empty()); + try testing.expectEqual(@as(?*Item, null), stack.pop()); +} diff --git a/ocam/src/state_machine/auditor.zig b/ocam/src/state_machine/auditor.zig new file mode 100644 index 00000000..24bcf407 --- /dev/null +++ b/ocam/src/state_machine/auditor.zig @@ -0,0 +1,1010 @@ +//! The Auditor constructs the expected state of its corresponding StateMachine from requests and +//! replies. It validates replies against its local state. +//! +//! The Auditor expects replies in ascending commit order. +const std = @import("std"); +const stdx = @import("stdx"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const log = std.log.scoped(.test_auditor); + +const tb = @import("../tigerbeetle.zig"); +const IdPermutation = @import("../testing/id.zig").IdPermutation; +const TimestampRange = @import("../lsm/timestamp_range.zig").TimestampRange; + +const PriorityQueue = std.PriorityQueue; +const Storage = @import("../testing/storage.zig").Storage; +const StateMachine = @import("../state_machine.zig").StateMachineType(Storage); + +pub const CreateAccountStatusSet = std.enums.EnumSet(tb.CreateAccountStatus); +// TODO(zig): See `Ordered` comments. +pub const CreateTransferStatusSet = std.enums.EnumSet(tb.CreateTransferStatus.Ordered); + +/// Batch sizes apply to both `create` and `lookup` operations. +/// (More ids would fit in the `lookup` request, but then the response wouldn't fit.) +const accounts_batch_size_max = StateMachine.batch_max.create_accounts; +const transfers_batch_size_max = StateMachine.batch_max.create_transfers; + +const InFlightKey = struct { + client_index: usize, + /// This index corresponds to Auditor.creates_sent/Auditor.creates_delivered. + client_request: usize, +}; + +/// Store expected possible results for an in-flight request. +/// This reply validation takes advantage of the Workload's additional context about the request. +const InFlight = union(enum) { + create_accounts: [accounts_batch_size_max]CreateAccountStatusSet, + create_transfers: [transfers_batch_size_max]CreateTransferStatusSet, +}; + +const InFlightQueue = std.AutoHashMapUnmanaged(InFlightKey, InFlight); + +const PendingTransfer = struct { + amount: u128, + debit_account_index: usize, + credit_account_index: usize, + query_intersection_index: usize, +}; + +const PendingExpiry = struct { + transfer_id: u128, + transfer_timestamp: u64, + expires_at: u64, +}; + +const PendingExpiryQueue = PriorityQueue(PendingExpiry, void, struct { + /// Order by ascending expiration date and then by transfer's timestamp. + fn compare(_: void, a: PendingExpiry, b: PendingExpiry) std.math.Order { + const order = switch (std.math.order(a.expires_at, b.expires_at)) { + .eq => std.math.order(a.transfer_timestamp, b.transfer_timestamp), + else => |order| order, + }; + assert(order != .eq); + return order; + } +}.compare); + +pub const AccountingAuditor = struct { + pub const AccountState = struct { + /// Set to true when `create_accounts` returns `.created` for an account. + created: bool = false, + /// The number of transfers created on the debit side. + dr_transfer_count: u32 = 0, + /// The number of transfers created on the credit side. + cr_transfer_count: u32 = 0, + /// Timestamp of the first transfer recorded. + transfer_timestamp_min: u64 = 0, + /// Timestamp of the last transfer recorded. + transfer_timestamp_max: u64 = 0, + + fn update( + state: *AccountState, + comptime entry: enum { dr, cr }, + transfer_timestamp: u64, + ) void { + assert(state.created); + switch (entry) { + .dr => state.dr_transfer_count += 1, + .cr => state.cr_transfer_count += 1, + } + + if (state.transfer_timestamp_min == 0) { + assert(state.transfer_timestamp_max == 0); + state.transfer_timestamp_min = transfer_timestamp; + } + state.transfer_timestamp_max = transfer_timestamp; + } + + pub fn transfers_count(self: *const AccountState, flags: tb.AccountFilterFlags) u32 { + var transfer_count: u32 = 0; + if (flags.debits) { + transfer_count += self.dr_transfer_count; + } + if (flags.credits) { + transfer_count += self.cr_transfer_count; + } + return transfer_count; + } + }; + + pub const Options = struct { + accounts_max: usize, + account_id_permutation: IdPermutation, + client_count: usize, + + /// The maximum number of pending transfers that can be expired per pulse. + pulse_expiries_max: u32, + + /// This is the maximum number of pending transfers, not counting those that have timed + /// out. + /// + /// NOTE: Transfers that have posted/voided successfully (or not) that have _not_ yet + /// reached their expiry are still included in this count — see `pending_expiries`. + transfers_pending_max: usize, + + /// This is the maximum number of changes events needs to be tracked. + changes_events_max: u32, + + /// From the Auditor's point-of-view, all stalled requests are still in-flight, even if + /// their reply has actually arrived at the ReplySequence. + /// + /// A request stops being "in-flight" when `on_reply` is called. + /// + /// This should equal the ReplySequence's `stalled_queue_capacity`. + in_flight_max: usize, + }; + + pub const QueryIntersection = struct { + user_data_64: u64, + user_data_32: u32, + code: u16, + + accounts: QueryIntersectionState = .{}, + transfers: QueryIntersectionState = .{}, + }; + + pub const QueryIntersectionState = struct { + /// The number of objects recorded. + count: u32 = 0, + /// Timestamp of the first object recorded. + timestamp_min: u64 = 0, + /// Timestamp of the last object recorded. + timestamp_max: u64 = 0, + }; + + pub const ChangesTracker = struct { + const EnumArray = std.EnumArray(tb.ChangeEventType, u32); + const Counter = struct { + /// The number of events recorded. + count: EnumArray, + /// Timestamp of the first event recorded. + timestamp_min: u64, + /// Timestamp of the last event recorded. + timestamp_max: u64, + + fn init() Counter { + return .{ + .count = EnumArray.initFill(0), + .timestamp_min = 0, + .timestamp_max = 0, + }; + } + + pub fn count_total(self: *const Counter) u32 { + const timestamp_valid: bool = + TimestampRange.valid(self.timestamp_min) and + TimestampRange.valid(self.timestamp_max); + maybe(timestamp_valid); + + var total: u32 = 0; + for (self.count.values) |value| total += value; + assert((total > 0) == timestamp_valid); + return total; + } + }; + + current: Counter, + snapshot: ?Counter, + changes_events_max: u32, + + fn init(changes_events_max: u32) ChangesTracker { + return .{ + .current = Counter.init(), + .snapshot = null, + .changes_events_max = changes_events_max, + }; + } + + fn update(self: *ChangesTracker, change: union(enum) { + transfer: struct { + timestamp: u64, + flags: tb.TransferFlags, + }, + expiry: struct { + timestamp: u64, + expired_count: u32, + }, + }) void { + defer assert(self.current.count_total() <= self.changes_events_max); + + const count: u32 = switch (change) { + .transfer => 1, + .expiry => |expiry| expiry.expired_count, + }; + assert(count > 0); + if (self.current.count_total() + count > self.changes_events_max) { + // Reset the counters if we reach the maximum size. + self.current = Counter.init(); + // Too many events to keep track of. + if (count > self.changes_events_max) return; + } + + switch (change) { + .transfer => |transfer| { + assert(TimestampRange.valid(transfer.timestamp)); + if (self.current.timestamp_min == 0 and + self.current.timestamp_max == 0) + { + self.current.timestamp_min = transfer.timestamp; + self.current.timestamp_max = transfer.timestamp; + } else { + assert(TimestampRange.valid(self.current.timestamp_min)); + assert(TimestampRange.valid(self.current.timestamp_max)); + assert(self.current.timestamp_min <= self.current.timestamp_max); + assert(transfer.timestamp > self.current.timestamp_max); + self.current.timestamp_max = transfer.timestamp; + } + + if (transfer.flags.pending) { + self.current.count.getPtr(.two_phase_pending).* += 1; + } else if (transfer.flags.post_pending_transfer) { + self.current.count.getPtr(.two_phase_posted).* += 1; + } else if (transfer.flags.void_pending_transfer) { + self.current.count.getPtr(.two_phase_voided).* += 1; + } else { + self.current.count.getPtr(.single_phase).* += 1; + } + }, + .expiry => |expiry| { + assert(TimestampRange.valid(expiry.timestamp)); + if (self.current.timestamp_min == 0 and + self.current.timestamp_max == 0) + { + const timestamp_first: u64 = expiry.timestamp - expiry.expired_count; + assert(TimestampRange.valid(timestamp_first)); + self.current.timestamp_min = timestamp_first; + self.current.timestamp_max = expiry.timestamp; + } else { + assert(TimestampRange.valid(self.current.timestamp_min)); + assert(TimestampRange.valid(self.current.timestamp_max)); + assert(self.current.timestamp_min <= self.current.timestamp_max); + assert(expiry.timestamp > self.current.timestamp_max); + self.current.timestamp_max = expiry.timestamp; + } + self.current.count.getPtr(.two_phase_expired).* += expiry.expired_count; + }, + } + } + + pub fn acquire_snapshot(self: *ChangesTracker) ?Counter { + // Snapshot already in use for another query. + if (self.snapshot != null) return null; + // No events. + if (self.current.count_total() == 0) return null; + + assert(self.snapshot == null); + self.snapshot = self.current; + return self.snapshot.?; + } + + fn release_snapshot(self: *ChangesTracker) Counter { + assert(self.snapshot != null); + defer self.snapshot = null; + + return self.snapshot.?; + } + }; + + prng: *stdx.PRNG, + options: Options, + + /// The timestamp of the last processed reply. + timestamp: u64 = 0, + + /// The account configuration. Balances are in sync with the remote StateMachine for a + /// given commit (double-double entry accounting). + accounts: []tb.Account, + + /// Additional account state. Keyed by account index. + accounts_state: []AccountState, + + /// Known intersection values for a particular combination of secondary indexes. + /// Counters are in sync with the remote StateMachine tracking the number of objects + /// with such fields. + query_intersections: []QueryIntersection, + + /// Map pending transfers to the (pending) amount and accounts. + /// + /// * Added in `on_create_transfers` for pending transfers. + /// * Removed after a transfer is posted, voided, or timed out. + /// + /// All entries in `pending_transfers` have a corresponding entry in `pending_expiries`. + pending_transfers: std.AutoHashMapUnmanaged(u128, PendingTransfer), + + /// After a transfer is posted/voided, the entry in `pending_expiries` is untouched. + /// The timeout will not impact account balances (because the `pending_transfers` entry is + /// removed), but until timeout the transfer still counts against `transfers_pending_max`. + pending_expiries: PendingExpiryQueue, + + /// Records the number of events in a given timestamp span. + /// Used to validate the `get_change_events` results. + changes_tracker: ChangesTracker, + + /// Track the expected result of the in-flight request for each client. + /// Each member queue corresponds to entries of the client's request queue, but omits + /// `register` messages. + in_flight: InFlightQueue, + + /// The number of `create_accounts`/`create_transfers` sent, per client. Keyed by client index. + creates_sent: []usize, + + /// The number of `create_accounts`/`create_transfers` delivered (i.e. replies received), + /// per client. Keyed by client index. + creates_delivered: []usize, + + pub fn init( + gpa: std.mem.Allocator, + prng: *stdx.PRNG, + options: Options, + ) !AccountingAuditor { + assert(options.accounts_max >= 2); + assert(options.client_count > 0); + + const accounts = try gpa.alloc(tb.Account, options.accounts_max); + errdefer gpa.free(accounts); + @memset(accounts, undefined); + + const accounts_state = try gpa.alloc(AccountState, options.accounts_max); + errdefer gpa.free(accounts_state); + @memset(accounts_state, AccountState{}); + + // The number of known intersection values for the secondary indices is kept low enough to + // explore different cardinalities. + const query_intersections = try gpa.alloc( + QueryIntersection, + options.accounts_max / 2, + ); + errdefer gpa.free(query_intersections); + for (query_intersections, 1..) |*query_intersection, index| { + query_intersection.* = .{ + .user_data_64 = @intCast(index * 1_000_000), + .user_data_32 = @intCast(index * 1_000), + .code = @intCast(index), // It will be used to recover the index. + }; + } + + var pending_transfers = std.AutoHashMapUnmanaged(u128, PendingTransfer){}; + errdefer pending_transfers.deinit(gpa); + try pending_transfers.ensureTotalCapacity( + gpa, + @intCast(options.transfers_pending_max), + ); + + var pending_expiries = PendingExpiryQueue.init(gpa, {}); + errdefer pending_expiries.deinit(); + try pending_expiries.ensureTotalCapacity(options.transfers_pending_max); + + var in_flight = InFlightQueue{}; + errdefer in_flight.deinit(gpa); + try in_flight.ensureTotalCapacity(gpa, @intCast(options.in_flight_max)); + + const creates_sent = try gpa.alloc(usize, options.client_count); + errdefer gpa.free(creates_sent); + @memset(creates_sent, 0); + + const creates_delivered = try gpa.alloc(usize, options.client_count); + errdefer gpa.free(creates_delivered); + @memset(creates_delivered, 0); + + return .{ + .prng = prng, + .options = options, + .accounts = accounts, + .accounts_state = accounts_state, + .query_intersections = query_intersections, + .pending_transfers = pending_transfers, + .pending_expiries = pending_expiries, + .changes_tracker = ChangesTracker.init(options.changes_events_max), + .in_flight = in_flight, + .creates_sent = creates_sent, + .creates_delivered = creates_delivered, + }; + } + + pub fn deinit(self: *AccountingAuditor, gpa: std.mem.Allocator) void { + gpa.free(self.creates_delivered); + gpa.free(self.creates_sent); + self.in_flight.deinit(gpa); + self.pending_expiries.deinit(); + self.pending_transfers.deinit(gpa); + gpa.free(self.query_intersections); + gpa.free(self.accounts_state); + gpa.free(self.accounts); + } + + pub fn done(self: *const AccountingAuditor) bool { + if (self.in_flight.count() != 0) return false; + + for (self.creates_sent, 0..) |sent, client_index| { + if (sent != self.creates_delivered[client_index]) return false; + } + // Don't check pending_transfers; the workload might not have posted/voided every transfer. + + return true; + } + + pub fn expect_create_accounts( + self: *AccountingAuditor, + client_index: usize, + ) []CreateAccountStatusSet { + const result = self.in_flight.getOrPutAssumeCapacity(.{ + .client_index = client_index, + .client_request = self.creates_sent[client_index], + }); + assert(!result.found_existing); + + self.creates_sent[client_index] += 1; + result.value_ptr.* = .{ .create_accounts = undefined }; + return result.value_ptr.*.create_accounts[0..]; + } + + pub fn expect_create_transfers( + self: *AccountingAuditor, + client_index: usize, + ) []CreateTransferStatusSet { + const result = self.in_flight.getOrPutAssumeCapacity(.{ + .client_index = client_index, + .client_request = self.creates_sent[client_index], + }); + assert(!result.found_existing); + + self.creates_sent[client_index] += 1; + result.value_ptr.* = .{ .create_transfers = undefined }; + return result.value_ptr.*.create_transfers[0..]; + } + + /// Expire pending transfers that have not been posted or voided. + pub fn expire_pending_transfers(self: *AccountingAuditor, timestamp: u64) void { + assert(self.timestamp < timestamp); + defer self.timestamp = timestamp; + + var expired_count: u32 = 0; + while (self.pending_expiries.peek()) |expiration| { + if (timestamp < expiration.expires_at) break; + defer _ = self.pending_expiries.remove(); + + // Ignore the transfer if it was already posted/voided. + const pending_transfer = + self.pending_transfers.get(expiration.transfer_id) orelse continue; + assert(self.pending_transfers.remove(expiration.transfer_id)); + assert(self.accounts_state[pending_transfer.debit_account_index].created); + assert(self.accounts_state[pending_transfer.credit_account_index].created); + + const dr = &self.accounts[pending_transfer.debit_account_index]; + const cr = &self.accounts[pending_transfer.credit_account_index]; + dr.debits_pending -= pending_transfer.amount; + cr.credits_pending -= pending_transfer.amount; + assert(!dr.debits_exceed_credits(0)); + assert(!dr.credits_exceed_debits(0)); + assert(!cr.debits_exceed_credits(0)); + assert(!cr.credits_exceed_debits(0)); + + // Each expiration round can expire at most one batch of transfers. + expired_count += 1; + if (expired_count == self.options.pulse_expiries_max) break; + } + + if (expired_count > 0) { + self.changes_tracker.update(.{ .expiry = .{ + .timestamp = timestamp, + .expired_count = expired_count, + } }); + } + } + + pub fn on_create_accounts_sparse( + self: *AccountingAuditor, + client_index: usize, + timestamp: u64, + accounts: []const tb.Account, + results_sparse: []const tb.CreateAccountErrorResult, + ) void { + assert(accounts.len >= results_sparse.len); + assert(self.timestamp < timestamp or + // Zero-sized batches packed in a multi-batch message: + (accounts.len == 0 and self.timestamp == timestamp)); + defer self.timestamp = timestamp; + + const results_expect = self.take_in_flight(client_index).create_accounts; + var iterator: ResultsSparseIteratorType(tb.CreateAccountErrorResult) = .init( + results_sparse, + ); + defer assert(iterator.results.len == 0); + + for (accounts, 0..) |*account, i| { + const account_timestamp = timestamp - accounts.len + i + 1; + + const result_actual = iterator.take(i) orelse .created; + if (!results_expect[i].contains(result_actual)) { + log.err("on_create_accounts_sparse: account={} expect={} result={}", .{ + account.*, + results_expect[i], + result_actual, + }); + @panic("on_create_accounts_sparse: unexpected result"); + } + + if (result_actual == .created) { + self.on_create_account_ok(account_timestamp, account); + } + } + } + + pub fn on_create_accounts( + self: *AccountingAuditor, + client_index: usize, + timestamp: u64, + accounts: []const tb.Account, + results: []const tb.CreateAccountResult, + ) void { + assert(accounts.len == results.len); + assert(self.timestamp < timestamp or + // Zero-sized batches packed in a multi-batch message: + (accounts.len == 0 and self.timestamp == timestamp)); + defer self.timestamp = timestamp; + + const results_expect = self.take_in_flight(client_index).create_accounts; + for (accounts, results, 0..) |*account, *result, i| { + assert(result.reserved == 0); + const account_timestamp = timestamp - accounts.len + i + 1; + + if (!results_expect[i].contains(result.status)) { + log.err("on_create_accounts: account={} expect={} result={}", .{ + account.*, + results_expect[i], + result, + }); + @panic("on_create_accounts: unexpected result"); + } + + switch (result.status) { + .created => { + assert(result.timestamp == account_timestamp); + self.on_create_account_ok(account_timestamp, account); + }, + .exists => assert(result.timestamp == self.get_account(account.id).?.timestamp), + else => assert(result.timestamp > 0), + } + } + } + + fn on_create_account_ok( + self: *AccountingAuditor, + timestamp: u64, + account: *const tb.Account, + ) void { + const account_index = self.account_id_to_index(account.id); + assert(account_index < self.accounts.len); + assert(!self.accounts_state[account_index].created); + self.accounts_state[account_index].created = true; + self.accounts[account_index] = account.*; + self.accounts[account_index].timestamp = timestamp; + + const query_intersection_index = account.code - 1; + const query_intersection = &self.query_intersections[query_intersection_index]; + assert(account.user_data_64 == query_intersection.user_data_64); + assert(account.user_data_32 == query_intersection.user_data_32); + assert(account.code == query_intersection.code); + query_intersection.accounts.count += 1; + if (query_intersection.accounts.timestamp_min == 0) { + query_intersection.accounts.timestamp_min = timestamp; + } + query_intersection.accounts.timestamp_max = timestamp; + } + + pub fn on_create_transfers_sparse( + self: *AccountingAuditor, + client_index: usize, + timestamp: u64, + transfers: []const tb.Transfer, + results_sparse: []const tb.CreateTransferErrorResult, + ) void { + assert(transfers.len >= results_sparse.len); + assert(self.timestamp < timestamp or + // Zero-sized batches packed in a multi-batch message: + (transfers.len == 0 and self.timestamp == timestamp)); + defer self.timestamp = timestamp; + + const results_expect = self.take_in_flight(client_index).create_transfers; + var iterator: ResultsSparseIteratorType(tb.CreateTransferErrorResult) = .init( + results_sparse, + ); + defer assert(iterator.results.len == 0); + + for (transfers, 0..) |*transfer, i| { + const transfer_timestamp = timestamp - transfers.len + i + 1; + + const result_actual = iterator.take(i) orelse .created; + if (!results_expect[i].contains(result_actual.to_ordered())) { + log.err("on_create_transfers_sparse: transfer={} expect={} result={}", .{ + transfer.*, + results_expect[i], + result_actual, + }); + @panic("on_create_transfers_sparse: unexpected result"); + } + + if (result_actual == .created) self.on_create_transfer_ok( + transfer_timestamp, + transfer, + ); + } + } + + pub fn on_create_transfers( + self: *AccountingAuditor, + client_index: usize, + timestamp: u64, + transfers: []const tb.Transfer, + results: []const tb.CreateTransferResult, + ) void { + assert(transfers.len == results.len); + assert(self.timestamp < timestamp or + // Zero-sized batches packed in a multi-batch message: + (transfers.len == 0 and self.timestamp == timestamp)); + defer self.timestamp = timestamp; + + const results_expect = self.take_in_flight(client_index).create_transfers; + for (transfers, results, 0..) | + *transfer, + result, + i, + | { + const transfer_timestamp = timestamp - transfers.len + i + 1; + + if (!results_expect[i].contains(result.status.to_ordered())) { + log.err("on_create_transfers: transfer={} expect={} result={}", .{ + transfer.*, + results_expect[i], + result, + }); + @panic("on_create_transfers: unexpected result"); + } + + assert(result.timestamp > 0); + switch (result.status) { + .created => { + assert(result.timestamp == transfer_timestamp); + self.on_create_transfer_ok(transfer_timestamp, transfer); + }, + .exists => assert(result.timestamp < transfer_timestamp), + else => {}, + } + } + } + + fn on_create_transfer_ok( + self: *AccountingAuditor, + timestamp: u64, + transfer: *const tb.Transfer, + ) void { + self.changes_tracker.update(.{ .transfer = .{ + .timestamp = timestamp, + .flags = transfer.flags, + } }); + + const query_intersection_index = transfer.code - 1; + const query_intersection = &self.query_intersections[query_intersection_index]; + assert(transfer.user_data_64 == query_intersection.user_data_64); + assert(transfer.user_data_32 == query_intersection.user_data_32); + assert(transfer.code == query_intersection.code); + query_intersection.transfers.count += 1; + if (query_intersection.transfers.timestamp_min == 0) { + query_intersection.transfers.timestamp_min = timestamp; + } + query_intersection.transfers.timestamp_max = timestamp; + + if (transfer.flags.post_pending_transfer or transfer.flags.void_pending_transfer) { + const p = self.pending_transfers.get(transfer.pending_id).?; + const dr_state = &self.accounts_state[p.debit_account_index]; + const cr_state = &self.accounts_state[p.credit_account_index]; + dr_state.update(.dr, timestamp); + cr_state.update(.cr, timestamp); + + const dr = &self.accounts[p.debit_account_index]; + const cr = &self.accounts[p.credit_account_index]; + + assert(self.pending_transfers.remove(transfer.pending_id)); + // The transfer may still be in `pending_expiries` — removal would be O(n), + // so don't bother. + + dr.debits_pending -= p.amount; + cr.credits_pending -= p.amount; + if (transfer.flags.post_pending_transfer) { + const amount = @min(transfer.amount, p.amount); + dr.debits_posted += amount; + cr.credits_posted += amount; + } + + assert(!dr.debits_exceed_credits(0)); + assert(!dr.credits_exceed_debits(0)); + assert(!cr.debits_exceed_credits(0)); + assert(!cr.credits_exceed_debits(0)); + } else { + const dr_index = self.account_id_to_index(transfer.debit_account_id); + const cr_index = self.account_id_to_index(transfer.credit_account_id); + const dr_state = &self.accounts_state[dr_index]; + const cr_state = &self.accounts_state[cr_index]; + dr_state.update(.dr, timestamp); + cr_state.update(.cr, timestamp); + + const dr = &self.accounts[dr_index]; + const cr = &self.accounts[cr_index]; + + if (transfer.flags.pending) { + if (transfer.timeout > 0) { + self.pending_transfers.putAssumeCapacity(transfer.id, .{ + .amount = transfer.amount, + .debit_account_index = dr_index, + .credit_account_index = cr_index, + .query_intersection_index = transfer.code - 1, + }); + self.pending_expiries.add(.{ + .transfer_id = transfer.id, + .transfer_timestamp = timestamp, + .expires_at = timestamp + transfer.timeout_ns(), + }) catch unreachable; + // PriorityQueue lacks an "unmanaged" API, so verify that the workload + // hasn't created more pending transfers than permitted. + assert(self.pending_expiries.count() <= self.options.transfers_pending_max); + } + dr.debits_pending += transfer.amount; + cr.credits_pending += transfer.amount; + } else { + dr.debits_posted += transfer.amount; + cr.credits_posted += transfer.amount; + } + + assert(!dr.debits_exceed_credits(0)); + assert(!dr.credits_exceed_debits(0)); + assert(!cr.debits_exceed_credits(0)); + assert(!cr.credits_exceed_debits(0)); + } + } + + pub fn on_lookup_accounts( + self: *AccountingAuditor, + client_index: usize, + timestamp: u64, + ids: []const u128, + results: []const tb.Account, + ) void { + _ = client_index; + assert(ids.len >= results.len); + assert(self.timestamp <= timestamp); + defer self.timestamp = timestamp; + + var results_iterator = IteratorForLookupType(tb.Account).init(results); + defer assert(results_iterator.results.len == 0); + + for (ids) |account_id| { + const account_index = self.account_id_to_index(account_id); + const account_lookup = results_iterator.take(account_id); + + if (account_index < self.accounts.len and + self.accounts_state[account_index].created) + { + // If this assertion fails, `lookup_accounts` didn't return an account when it + // should have. + assert(account_lookup != null); + assert(!account_lookup.?.debits_exceed_credits(0)); + assert(!account_lookup.?.credits_exceed_debits(0)); + + const account_expect = &self.accounts[account_index]; + if (!std.mem.eql( + u8, + std.mem.asBytes(account_lookup.?), + std.mem.asBytes(account_expect), + )) { + log.err("on_lookup_accounts: account data mismatch " ++ + "account_id={} expect={} lookup={}", .{ + account_id, + account_expect, + account_lookup.?, + }); + @panic("on_lookup_accounts: account data mismatch"); + } + } else { + // If this assertion fails, `lookup_accounts` returned an account when it shouldn't. + assert(account_lookup == null); + } + } + } + + /// Most `lookup_transfers` validation is handled by Workload. + /// (Workload has more context around transfers, so it can be much stricter.) + pub fn on_lookup_transfers( + self: *AccountingAuditor, + client_index: usize, + timestamp: u64, + ids: []const u128, + results: []const tb.Transfer, + ) void { + _ = client_index; + assert(ids.len >= results.len); + assert(self.timestamp <= timestamp); + defer self.timestamp = timestamp; + + var results_iterator = IteratorForLookupType(tb.Transfer).init(results); + defer assert(results_iterator.results.len == 0); + + for (ids) |id| { + const result = results_iterator.take(id); + assert(result == null or result.?.id == id); + } + } + + /// Returns a random account matching the given criteria. + /// Returns null when no account matches the given criteria. + pub fn pick_account( + self: *const AccountingAuditor, + match: struct { + /// Whether the account is known to be created + /// (we have received an `ok` for the respective `create_accounts`). + created: ?bool, + debits_must_not_exceed_credits: ?bool, + credits_must_not_exceed_debits: ?bool, + /// Don't match this account. + exclude: ?u128 = null, + }, + ) ?*const tb.Account { + const offset = self.prng.int_inclusive(usize, self.accounts.len - 1); + var i: usize = 0; + // Iterate `accounts`, starting from a random offset. + while (i < self.accounts.len) : (i += 1) { + const account_index = (offset + i) % self.accounts.len; + if (match.created) |expect_created| { + if (self.accounts_state[account_index].created) { + if (!expect_created) continue; + } else { + if (expect_created) continue; + } + } + + const account = &self.accounts[account_index]; + if (match.debits_must_not_exceed_credits) |b| { + if (account.flags.debits_must_not_exceed_credits != b) continue; + } + + if (match.credits_must_not_exceed_debits) |b| { + if (account.flags.credits_must_not_exceed_debits != b) continue; + } + + if (match.exclude) |exclude_id| { + if (account.id == exclude_id) continue; + } + return account; + } + return null; + } + + pub fn on_get_change_events( + self: *AccountingAuditor, + timestamp: u64, + filter: tb.ChangeEventsFilter, + results: []const tb.ChangeEvent, + ) void { + _ = timestamp; + const filter_valid = filter.limit != 0 and + TimestampRange.valid(filter.timestamp_min) and + TimestampRange.valid(filter.timestamp_max) and + stdx.zeroed(&filter.reserved) and + (filter.timestamp_max == 0 or filter.timestamp_min <= filter.timestamp_max); + if (!filter_valid) { + assert(results.len == 0); + return; + } + + const snapshot = self.changes_tracker.release_snapshot(); + assert(filter.limit >= snapshot.count_total()); + assert(filter.timestamp_min == snapshot.timestamp_min); + assert(filter.timestamp_max == snapshot.timestamp_max); + assert(results.len == snapshot.count_total()); + + var timestamp_previous: u64 = 0; + var count = ChangesTracker.EnumArray.initFill(0); + for (results) |*result| { + assert(result.timestamp > timestamp_previous); + timestamp_previous = result.timestamp; + + if (filter.timestamp_min > 0) { + assert(result.timestamp >= filter.timestamp_min); + } + if (filter.timestamp_max > 0) { + assert(result.timestamp <= filter.timestamp_max); + } + + count.getPtr(result.type).* += 1; + } + + var iterator = count.iterator(); + while (iterator.next()) |kv| { + const expected = snapshot.count.getPtrConst(kv.key).*; + assert(kv.value.* == expected); + } + } + + pub fn account_id_to_index(self: *const AccountingAuditor, id: u128) usize { + // -1 because id=0 is not valid, so index=0→id=1. + return @as(usize, @intCast(self.options.account_id_permutation.decode(id))) - 1; + } + + pub fn account_index_to_id(self: *const AccountingAuditor, index: usize) u128 { + // +1 so that index=0 is encoded as a valid id. + return self.options.account_id_permutation.encode(index + 1); + } + + pub fn get_account(self: *const AccountingAuditor, id: u128) ?*const tb.Account { + const index = self.account_id_to_index(id); + return if (index < self.accounts.len) &self.accounts[index] else null; + } + + pub fn get_account_state(self: *const AccountingAuditor, id: u128) ?*const AccountState { + const index = self.account_id_to_index(id); + return if (index < self.accounts_state.len) &self.accounts_state[index] else null; + } + + fn take_in_flight(self: *AccountingAuditor, client_index: usize) InFlight { + const key: InFlightKey = .{ + .client_index = client_index, + .client_request = self.creates_delivered[client_index], + }; + self.creates_delivered[client_index] += 1; + + const in_flight = self.in_flight.get(key).?; + assert(self.in_flight.remove(key)); + return in_flight; + } +}; + +pub fn ResultsSparseIteratorType(comptime Result: type) type { + assert(Result == tb.CreateAccountErrorResult or Result == tb.CreateTransferErrorResult); + + return struct { + const IteratorForCreate = @This(); + + results: []const Result, + + pub fn init(results: []const Result) IteratorForCreate { + return .{ .results = results }; + } + + pub fn take( + self: *IteratorForCreate, + event_index: usize, + ) ?@FieldType(Result, "result") { + if (self.results.len > 0 and self.results[0].index == event_index) { + defer self.results = self.results[1..]; + + return self.results[0].result; + } else { + return null; + } + } + }; +} + +pub fn IteratorForLookupType(comptime Result: type) type { + assert(Result == tb.Account or Result == tb.Transfer); + + return struct { + const IteratorForLookup = @This(); + + results: []const Result, + + pub fn init(results: []const Result) IteratorForLookup { + return .{ .results = results }; + } + + pub fn take(self: *IteratorForLookup, id: u128) ?*const Result { + if (self.results.len > 0 and self.results[0].id == id) { + defer self.results = self.results[1..]; + + return &self.results[0]; + } else { + return null; + } + } + }; +} diff --git a/ocam/src/state_machine/workload.zig b/ocam/src/state_machine/workload.zig new file mode 100644 index 00000000..6af099d7 --- /dev/null +++ b/ocam/src/state_machine/workload.zig @@ -0,0 +1,2190 @@ +//! The Workload drives an end-to-end test: from client requests, through consensus and the state +//! machine, down to the storage engine, and back. +//! +//! The Workload constructs messages to create and query accounts and transfers, and validates the +//! replies. +//! +//! Goals: +//! +//! * Run in a fixed amount of memory. (For long-running tests or performance testing). +//! * Query and verify transfers arbitrarily far back. (To exercise the storage engine). +//! +//! Transfer Encoding: +//! +//! * `Transfer.id` is a deterministic, reversible permutation of an ascending index. +//! * With the transfer's index as a seed, the Workload knows the eventual outcome of the transfer. +//! * `Transfer.user_data` is a checksum of the remainder of the transfer's data +//! (excluding `timestamp` and `user_data` itself). This helps `on_lookup_transfers` to +//! validate its results. +//! +const std = @import("std"); +const assert = std.debug.assert; + +const stdx = @import("stdx"); +const maybe = stdx.maybe; +const Ratio = stdx.PRNG.Ratio; +const ratio = stdx.PRNG.ratio; + +const constants = @import("../constants.zig"); +const tb = @import("../tigerbeetle.zig"); +const vsr = @import("../vsr.zig"); +const accounting_auditor = @import("auditor.zig"); +const Auditor = accounting_auditor.AccountingAuditor; +const ResultsSparseIteratorType = accounting_auditor.ResultsSparseIteratorType; +const IdPermutation = @import("../testing/id.zig").IdPermutation; +const TimestampRange = @import("../lsm/timestamp_range.zig").TimestampRange; +const fuzz = @import("../testing/fuzz.zig"); + +const PriorityQueue = std.PriorityQueue; + +const TransferOutcome = enum { + /// The transfer is guaranteed to commit. + /// For example, a single-phase transfer between valid accounts without balance limits. + success, + /// The transfer is invalid. For example, the `ledger` field is missing. + failure, + /// Due to races with timeouts or other transfers, the outcome of the transfer is uncertain. + /// For example, post/void-pending transfers race with their timeout. + unknown, +}; + +/// A Transfer generated from the plan is guaranteed to have a matching `outcome`, but it may use a +/// different Method. (For example, `method=pending` may fall back to `method=single_phase` if the +/// Auditor's pending transfer queue is full). +const TransferPlan = struct { + /// When false, send invalid payments that are guaranteed to be rejected with an error. + valid: bool, + + /// When `limit` is set, at least one of the following is true: + /// + /// * the debit account has debits_must_not_exceed_credits + /// * the credit account has credits_must_not_exceed_debits + /// + limit: bool, + + method: Method, + + const Method = enum { + single_phase, + pending, + post_pending, + void_pending, + }; + + fn outcome(self: TransferPlan) TransferOutcome { + if (!self.valid) return .failure; + if (self.limit) return .unknown; + return switch (self.method) { + .single_phase, .pending => .success, + .post_pending, .void_pending => .unknown, + }; + } +}; + +const TransferTemplate = struct { + ledger: u32, + result: accounting_auditor.CreateTransferStatusSet, +}; + +const TransferBatchQueue = PriorityQueue(TransferBatch, void, struct { + /// Ascending order. + fn compare(_: void, a: TransferBatch, b: TransferBatch) std.math.Order { + assert(a.min != b.min); + assert(a.max != b.max); + return std.math.order(a.min, b.min); + } +}.compare); + +const TransferBatch = struct { + /// Index of the first transfer in the batch. + min: usize, + /// Index of the last transfer in the batch. + max: usize, +}; + +/// Indexes: [valid:bool][limit:bool][method] +const transfer_templates = table: { + @setEvalBranchQuota(4_000); + + const SNGL = @intFromEnum(TransferPlan.Method.single_phase); + const PEND = @intFromEnum(TransferPlan.Method.pending); + const POST = @intFromEnum(TransferPlan.Method.post_pending); + const VOID = @intFromEnum(TransferPlan.Method.void_pending); + const Result = accounting_auditor.CreateTransferStatusSet; + const result = Result.init; + + const InitValues = std.enums.EnumFieldStruct( + tb.CreateTransferStatus.Ordered, + bool, + false, + ); + const two_phase_ok: InitValues = .{ + .created = true, + .pending_transfer_already_posted = true, + .pending_transfer_already_voided = true, + .pending_transfer_expired = true, + }; + + const limits = result(.{ + .exceeds_credits = true, + .exceeds_debits = true, + }); + + const either = struct { + fn either(a: Result, b: Result) Result { + var c = a; + c.setUnion(b); + return c; + } + }.either; + + const template = struct { + fn template(ledger: u32, transfer_result: Result) TransferTemplate { + return .{ + .ledger = ledger, + .result = transfer_result, + }; + } + }.template; + + // [valid:bool][limit:bool][method] + var templates: [2][2][std.meta.fields(TransferPlan.Method).len]TransferTemplate = undefined; + + // template(ledger, result) + templates[0][0][SNGL] = template(0, result(.{ .ledger_must_not_be_zero = true })); + templates[0][0][PEND] = template(0, result(.{ .ledger_must_not_be_zero = true })); + templates[0][0][POST] = template(9, result(.{ .pending_transfer_has_different_ledger = true })); + templates[0][0][VOID] = template(9, result(.{ .pending_transfer_has_different_ledger = true })); + + templates[0][1][SNGL] = template(0, result(.{ .ledger_must_not_be_zero = true })); + templates[0][1][PEND] = template(0, result(.{ .ledger_must_not_be_zero = true })); + templates[0][1][POST] = template(9, result(.{ .pending_transfer_has_different_ledger = true })); + templates[0][1][VOID] = template(9, result(.{ .pending_transfer_has_different_ledger = true })); + + templates[1][0][SNGL] = template(1, result(.{ .created = true })); + templates[1][0][PEND] = template(1, result(.{ .created = true })); + templates[1][0][POST] = template(1, result(two_phase_ok)); + templates[1][0][VOID] = template(1, result(two_phase_ok)); + + templates[1][1][SNGL] = template(1, either(limits, result(.{ .created = true }))); + templates[1][1][PEND] = template(1, either(limits, result(.{ .created = true }))); + templates[1][1][POST] = template(1, either(limits, result(two_phase_ok))); + templates[1][1][VOID] = template(1, either(limits, result(two_phase_ok))); + + break :table templates; +}; + +pub fn WorkloadType(comptime AccountingStateMachine: type) type { + const Operation = AccountingStateMachine.Operation; + + const Action = enum(u8) { + create_accounts = @intFromEnum(Operation.create_accounts), + create_transfers = @intFromEnum(Operation.create_transfers), + lookup_accounts = @intFromEnum(Operation.lookup_accounts), + lookup_transfers = @intFromEnum(Operation.lookup_transfers), + get_account_transfers = @intFromEnum(Operation.get_account_transfers), + get_account_balances = @intFromEnum(Operation.get_account_balances), + query_accounts = @intFromEnum(Operation.query_accounts), + query_transfers = @intFromEnum(Operation.query_transfers), + get_change_events = @intFromEnum(Operation.get_change_events), + + deprecated_create_accounts_sparse = @intFromEnum( + Operation.deprecated_create_accounts_sparse, + ), + deprecated_create_transfers_sparse = @intFromEnum( + Operation.deprecated_create_transfers_sparse, + ), + deprecated_create_accounts_unbatched = @intFromEnum( + Operation.deprecated_create_accounts_unbatched, + ), + deprecated_create_transfers_unbatched = @intFromEnum( + Operation.deprecated_create_transfers_unbatched, + ), + deprecated_lookup_accounts_unbatched = @intFromEnum( + Operation.deprecated_lookup_accounts_unbatched, + ), + deprecated_lookup_transfers_unbatched = @intFromEnum( + Operation.deprecated_lookup_transfers_unbatched, + ), + deprecated_get_account_transfers_unbatched = @intFromEnum( + Operation.deprecated_get_account_transfers_unbatched, + ), + deprecated_get_account_balances_unbatched = @intFromEnum( + Operation.deprecated_get_account_balances_unbatched, + ), + deprecated_query_accounts_unbatched = @intFromEnum( + Operation.deprecated_query_accounts_unbatched, + ), + deprecated_query_transfers_unbatched = @intFromEnum( + Operation.deprecated_query_transfers_unbatched, + ), + }; + + const Lookup = enum { + /// Query a transfer that has either been committed or rejected. + delivered, + /// Query a transfer whose `create_transfers` is in-flight. + sending, + }; + + return struct { + const Workload = @This(); + + pub const Options = OptionsType(AccountingStateMachine, Action, Lookup); + + prng: *stdx.PRNG, + auditor: Auditor, + options: Options, + + transfer_plan_seed: u64, + + /// Whether a `create_accounts` message has ever been sent. + accounts_sent: bool = false, + + /// The index of the next transfer to send. + transfers_sent: usize = 0, + + /// All transfers below this index have been delivered. + /// Any transfers above this index that have been delivered are stored in + /// `transfers_delivered_recently`. + transfers_delivered_past: usize = 0, + + /// Track index ranges of `create_transfers` batches that have committed but are greater + /// than or equal to `transfers_delivered_past` (which is still in-flight). + transfers_delivered_recently: TransferBatchQueue, + + /// Track the number of pending transfers that have been sent but not committed. + transfers_pending_in_flight: usize = 0, + + /// Transfers that succeeded and must result in `exists` when retried. + transfers_retry_exists: std.ArrayListUnmanaged(tb.Transfer), + + /// IDs of transfers that failed with transient codes + /// and must result in `id_already_failed` when retried. + transfers_retry_failed: std.AutoArrayHashMapUnmanaged(u128, void), + + pub fn init( + allocator: std.mem.Allocator, + prng: *stdx.PRNG, + options: Options, + ) !Workload { + assert(options.accounts_batch_size_span + options.accounts_batch_size_min <= + AccountingStateMachine.batch_max.create_accounts); + assert(options.accounts_batch_size_span >= 1); + assert(options.transfers_batch_size_span + options.transfers_batch_size_min <= + AccountingStateMachine.batch_max.create_transfers); + assert(options.transfers_batch_size_span >= 1); + + var auditor = try Auditor.init(allocator, prng, options.auditor_options); + errdefer auditor.deinit(allocator); + + var transfers_delivered_recently = TransferBatchQueue.init(allocator, {}); + errdefer transfers_delivered_recently.deinit(); + try transfers_delivered_recently.ensureTotalCapacity( + options.auditor_options.client_count * constants.client_request_queue_max, + ); + + for (auditor.accounts, 0..) |*account, i| { + const query_intersection = + auditor.query_intersections[prng.index(auditor.query_intersections)]; + + account.* = std.mem.zeroInit(tb.Account, .{ + .id = auditor.account_index_to_id(i), + .user_data_64 = query_intersection.user_data_64, + .user_data_32 = query_intersection.user_data_32, + .code = query_intersection.code, + .ledger = 1, + }); + + if (prng.chance(options.account_limit_probability)) { + const b = prng.boolean(); + account.flags.debits_must_not_exceed_credits = b; + account.flags.credits_must_not_exceed_debits = !b; + } + + account.flags.history = prng.chance(options.account_history_probability); + } + + var transfers_retry_failed: std.AutoArrayHashMapUnmanaged(u128, void) = .{}; + try transfers_retry_failed.ensureTotalCapacity( + allocator, + options.transfers_retry_failed_max, + ); + errdefer transfers_retry_failed.deinit(allocator); + + var transfers_retry_exists: std.ArrayListUnmanaged(tb.Transfer) = try .initCapacity( + allocator, + options.transfers_retry_exists_max, + ); + errdefer transfers_retry_exists.deinit(allocator); + + return .{ + .prng = prng, + .auditor = auditor, + .options = options, + .transfer_plan_seed = prng.int(u64), + .transfers_delivered_recently = transfers_delivered_recently, + .transfers_retry_failed = transfers_retry_failed, + .transfers_retry_exists = transfers_retry_exists, + }; + } + + pub fn deinit(self: *Workload, allocator: std.mem.Allocator) void { + self.auditor.deinit(allocator); + self.transfers_delivered_recently.deinit(); + self.transfers_retry_failed.deinit(allocator); + self.transfers_retry_exists.deinit(allocator); + } + + pub fn done(self: *const Workload) bool { + if (self.transfers_delivered_recently.len != 0) return false; + return self.auditor.done(); + } + + /// A client may build multiple requests to queue up while another is in-flight. + pub fn build_request( + self: *Workload, + client_index: usize, + body_buffer: []align(constants.cache_line_size) u8, + ) struct { + operation: Operation, + size: usize, + } { + assert(client_index < self.auditor.options.client_count); + assert(body_buffer.len == constants.message_body_size_max); + + const action = action: { + if (!self.accounts_sent and self.prng.boolean()) { + // Early in the test make sure some accounts get created. + self.accounts_sent = true; + break :action .create_accounts; + } + + break :action self.prng.enum_weighted(Action, self.options.operations); + }; + + const operation: Operation = @enumFromInt(@intFromEnum(action)); + const event_size: u32 = operation.event_size(); + const event_max: u32 = operation.event_max(self.options.batch_size_limit); + assert(event_max > 0); + assert(body_buffer.len >= event_size * event_max); + + const result_size: u32 = operation.result_size(); + const result_max = operation.result_max(self.options.batch_size_limit); + assert(result_max > 0); + assert(constants.message_body_size_max >= + result_size * result_max); + + if (!operation.is_multi_batch()) { + const size = self.build_request_batch( + client_index, + action, + body_buffer, + event_max, + ); + assert(size <= body_buffer.len); + return .{ + .operation = operation, + .size = size, + }; + } + assert(operation.is_multi_batch()); + + var body_encoder = vsr.multi_batch.MultiBatchEncoder.init( + body_buffer[0..self.options.batch_size_limit], + .{ + .element_size = event_size, + }, + ); + var event_count: u32 = 0; + var result_count: u32 = 0; + for (0..self.options.multi_batch_per_request_limit) |_| { + const writable = body_encoder.writable() orelse break; + if (writable.len == 0) break; + + const event_count_remain: u32 = + if (operation.is_batchable()) + event_max - event_count + else + 1; + const batch_size = self.build_request_batch( + client_index, + action, + writable, + event_count_remain, + ); + assert(batch_size <= writable.len); + + // Checking if the expected result will fit in the multi-batch reply. + const reply_trailer_size: u32 = vsr.multi_batch.trailer_total_size(.{ + .element_size = result_size, + .batch_count = body_encoder.batch_count + 1, + }); + const result_count_expected: u32 = + operation.result_count_expected(writable[0..batch_size]); + const reply_message_size: u32 = + ((result_count + result_count_expected) * result_size) + reply_trailer_size; + if (reply_message_size > constants.message_body_size_max) { + // For operations that produce 1:1 result per event + // (e.g., `create_*` and `lookup_*`), this was already validated + // when checking if `event_count` fits within the multi-batch request. + // For queries, this means the reply size cannot fit within the same message. + assert(body_encoder.batch_count > 0); + assert(!operation.is_batchable()); + break; + } + assert(result_count + result_count_expected <= result_max); + + body_encoder.add(@intCast(batch_size)); + event_count += @intCast(@divExact(batch_size, event_size)); + assert(event_count <= event_max); + + result_count += result_count_expected; + assert(result_count <= result_max); + + // Maybe single-batch request. + if (body_encoder.batch_count == 1 and self.prng.boolean()) break; + } + maybe(event_count == 0); + assert(result_count == 0 or event_count > 0); + assert(body_encoder.batch_count > 0); + assert(body_encoder.batch_count <= self.options.multi_batch_per_request_limit); + + const bytes_written = body_encoder.finish(); + assert(bytes_written <= self.options.batch_size_limit); + + return .{ + .operation = operation, + .size = bytes_written, + }; + } + + fn build_request_batch( + self: *Workload, + client_index: usize, + action: Action, + body: []u8, + batch_limit: u32, + ) usize { + switch (action) { + inline else => |action_comptime| { + const operation_comptime: Operation = comptime @enumFromInt(@intFromEnum( + action_comptime, + )); + const Event = operation_comptime.EventType(); + const event_size: u32 = operation_comptime.event_size(); + const batchable: []Event = self.batch( + Event, + action_comptime, + body, + batch_limit, + ); + assert(batchable.len <= batch_limit); + + const count = switch (action_comptime) { + .create_accounts, + .deprecated_create_accounts_sparse, + .deprecated_create_accounts_unbatched, + => self.build_create_accounts( + client_index, + batchable, + ), + .create_transfers, + .deprecated_create_transfers_sparse, + .deprecated_create_transfers_unbatched, + => self.build_create_transfers( + client_index, + batchable, + ), + .lookup_accounts, + .deprecated_lookup_accounts_unbatched, + => self.build_lookup_accounts(batchable), + .lookup_transfers, + .deprecated_lookup_transfers_unbatched, + => self.build_lookup_transfers(batchable), + .get_account_transfers, + .get_account_balances, + .deprecated_get_account_transfers_unbatched, + .deprecated_get_account_balances_unbatched, + => self.build_get_account_filter( + client_index, + action_comptime, + batchable, + ), + .query_accounts, + .query_transfers, + .deprecated_query_accounts_unbatched, + .deprecated_query_transfers_unbatched, + => self.build_query_filter( + client_index, + action_comptime, + batchable, + ), + .get_change_events => self.build_get_change_events_filter( + client_index, + batchable, + ), + }; + assert(count <= batchable.len); + assert(count <= batch_limit); + + const batch_size: usize = count * event_size; + assert(batch_size <= body.len); + maybe(batch_size == 0); + return batch_size; + }, + } + } + + /// `on_reply` is called for replies in commit order. + pub fn on_reply( + self: *Workload, + client_index: usize, + operation: Operation, + timestamp: u64, + request_body: []align(constants.cache_line_size) const u8, + reply_body: []align(constants.cache_line_size) const u8, + ) void { + assert(timestamp != 0); + assert(request_body.len <= constants.message_body_size_max); + assert(reply_body.len <= constants.message_body_size_max); + + if (!operation.is_multi_batch()) { + return self.on_reply_batch( + client_index, + operation, + timestamp, + request_body, + reply_body, + ); + } + assert(operation.is_multi_batch()); + + const event_size: u32 = operation.event_size(); + const result_size: u32 = operation.result_size(); + var body_decoder = vsr.multi_batch.MultiBatchDecoder.init(request_body, .{ + .element_size = event_size, + }) catch unreachable; + assert(body_decoder.batch_count() > 0); + var reply_decoder = vsr.multi_batch.MultiBatchDecoder.init(reply_body, .{ + .element_size = result_size, + }) catch unreachable; + assert(reply_decoder.batch_count() > 0); + assert(body_decoder.batch_count() == reply_decoder.batch_count()); + + const prepare_nanoseconds = struct { + fn prepare_nanoseconds( + operation_inner: Operation, + input_len: usize, + batch_size_limit: u32, + ) u64 { + return switch (operation_inner) { + .pulse => Operation.create_transfers.event_max( + batch_size_limit, + ), + .create_accounts, + .deprecated_create_accounts_sparse, + => @divExact(input_len, @sizeOf(tb.Account)), + .create_transfers, + .deprecated_create_transfers_sparse, + => @divExact(input_len, @sizeOf(tb.Transfer)), + .lookup_accounts => 0, + .lookup_transfers => 0, + .get_account_transfers => 0, + .get_account_balances => 0, + .query_accounts => 0, + .query_transfers => 0, + .get_change_events => 0, + else => unreachable, + }; + } + }.prepare_nanoseconds; + var batch_timestamp: u64 = timestamp - prepare_nanoseconds( + operation, + body_decoder.payload.len, + self.options.batch_size_limit, + ); + while (body_decoder.pop()) |batch_body| { + const batch_reply = reply_decoder.pop().?; + batch_timestamp += prepare_nanoseconds( + operation, + batch_body.len, + self.options.batch_size_limit, + ); + self.on_reply_batch( + client_index, + operation, + batch_timestamp, + batch_body, + batch_reply, + ); + } + assert(reply_decoder.pop() == null); + } + + pub fn on_reply_batch( + self: *Workload, + client_index: usize, + operation: Operation, + timestamp: u64, + request_body: []const u8, + reply_body: []const u8, + ) void { + switch (operation) { + .create_accounts, + => self.auditor.on_create_accounts( + client_index, + timestamp, + stdx.bytes_as_slice(.exact, tb.Account, request_body), + stdx.bytes_as_slice(.exact, tb.CreateAccountResult, reply_body), + ), + .deprecated_create_accounts_sparse, + .deprecated_create_accounts_unbatched, + => self.auditor.on_create_accounts_sparse( + client_index, + timestamp, + stdx.bytes_as_slice(.exact, tb.Account, request_body), + stdx.bytes_as_slice(.exact, tb.CreateAccountErrorResult, reply_body), + ), + .create_transfers, + => self.on_create_transfers( + client_index, + timestamp, + stdx.bytes_as_slice(.exact, tb.Transfer, request_body), + stdx.bytes_as_slice(.exact, tb.CreateTransferResult, reply_body), + ), + .deprecated_create_transfers_sparse, + .deprecated_create_transfers_unbatched, + => self.on_create_transfers_sparse( + client_index, + timestamp, + stdx.bytes_as_slice(.exact, tb.Transfer, request_body), + stdx.bytes_as_slice(.exact, tb.CreateTransferErrorResult, reply_body), + ), + .lookup_accounts, + .deprecated_lookup_accounts_unbatched, + => self.auditor.on_lookup_accounts( + client_index, + timestamp, + stdx.bytes_as_slice(.exact, u128, request_body), + stdx.bytes_as_slice(.exact, tb.Account, reply_body), + ), + .lookup_transfers, + .deprecated_lookup_transfers_unbatched, + => self.on_lookup_transfers( + client_index, + timestamp, + stdx.bytes_as_slice(.exact, u128, request_body), + stdx.bytes_as_slice(.exact, tb.Transfer, reply_body), + ), + inline .get_account_transfers, + .deprecated_get_account_transfers_unbatched, + => |operation_comptime| self.on_get_account_transfers( + operation_comptime, + timestamp, + stdx.bytes_as_slice(.exact, tb.AccountFilter, request_body), + stdx.bytes_as_slice(.exact, tb.Transfer, reply_body), + ), + inline .get_account_balances, + .deprecated_get_account_balances_unbatched, + => |operation_comptime| self.on_get_account_balances( + operation_comptime, + timestamp, + stdx.bytes_as_slice(.exact, tb.AccountFilter, request_body), + stdx.bytes_as_slice(.exact, tb.AccountBalance, reply_body), + ), + inline .query_accounts, + .deprecated_query_accounts_unbatched, + => |operation_comptime| self.on_query( + operation_comptime, + timestamp, + stdx.bytes_as_slice(.exact, tb.QueryFilter, request_body), + stdx.bytes_as_slice(.exact, tb.Account, reply_body), + ), + inline .query_transfers, + .deprecated_query_transfers_unbatched, + => |operation_comptime| self.on_query( + operation_comptime, + timestamp, + stdx.bytes_as_slice(.exact, tb.QueryFilter, request_body), + stdx.bytes_as_slice(.exact, tb.Transfer, reply_body), + ), + .get_change_events => self.on_get_change_events( + timestamp, + stdx.bytes_as_slice(.exact, tb.ChangeEventsFilter, request_body), + stdx.bytes_as_slice(.exact, tb.ChangeEvent, reply_body), + ), + //Not handled by the client. + .pulse => unreachable, + } + } + + /// `on_pulse` is called for pulse operations in commit order. + pub fn on_pulse( + self: *Workload, + operation: Operation, + timestamp: u64, + ) void { + assert(timestamp != 0); + assert(operation == .pulse); + + self.auditor.expire_pending_transfers(timestamp); + } + + fn build_create_accounts( + self: *Workload, + client_index: usize, + accounts: []tb.Account, + ) usize { + const results = self.auditor.expect_create_accounts(client_index); + for (accounts, 0..) |*account, i| { + const account_index = self.prng.index(self.auditor.accounts); + account.* = self.auditor.accounts[account_index]; + account.debits_pending = 0; + account.debits_posted = 0; + account.credits_pending = 0; + account.credits_posted = 0; + account.timestamp = 0; + results[i] = accounting_auditor.CreateAccountStatusSet{}; + + if (self.prng.chance(self.options.create_account_invalid_probability)) { + account.ledger = 0; + // The result depends on whether the id already exists: + results[i].insert(.exists_with_different_ledger); + results[i].insert(.ledger_must_not_be_zero); + } else { + if (!self.auditor.accounts_state[account_index].created) { + results[i].insert(.created); + } + // Even if the account doesn't exist yet, we may race another request. + results[i].insert(.exists); + } + assert(results[i].count() > 0); + } + return accounts.len; + } + + fn build_create_transfers( + self: *Workload, + client_index: usize, + transfers: []tb.Transfer, + ) usize { + const results = self.auditor.expect_create_transfers(client_index); + assert(results.len >= transfers.len); + var transfers_count: usize = transfers.len; + var i: usize = 0; + while (i < transfers_count) { + const transfer_index = self.transfers_sent; + const transfer_plan = self.transfer_index_to_plan(transfer_index); + const transfer_id = self.transfer_index_to_id(transfer_index); + results[i] = self.build_transfer( + transfer_id, + transfer_plan, + &transfers[i], + ) orelse { + // This transfer index can't be built; stop with what we have so far. + // Hopefully it will be unblocked before the next `create_transfers`. + transfers_count = i; + break; + }; + + if (i != 0 and results[i].count() == 1 and results[i - 1].count() == 1) { + // To support random `lookup_transfers`, linked transfers can't be planned. + // Instead, link transfers opportunistically, when consecutive transfers can be + // linked without altering any of their outcomes. + + if (results[i].contains(.created) and results[i - 1].contains(.created) and + self.prng.chance(self.options.linked_valid_probability)) + { + transfers[i - 1].flags.linked = true; + } + + if (!results[i].contains(.created) and !results[i - 1].contains(.created) and + self.prng.chance(self.options.linked_invalid_probability)) + { + // Convert the previous transfer to a single-phase no-limit transfer, but + // link it to the current transfer — it will still fail. + const result_set_opt = self.build_transfer(transfers[i - 1].id, .{ + .valid = true, + .limit = false, + .method = .single_phase, + }, &transfers[i - 1]); + if (result_set_opt) |result_set| { + assert(result_set.count() == 1); + assert(result_set.contains(.created)); + + transfers[i - 1].flags.linked = true; + results[i - 1] = accounting_auditor.CreateTransferStatusSet.init(.{ + .linked_event_failed = true, + }); + } + } + } + assert(results[i].count() > 0); + + if (transfers[i].flags.pending) self.transfers_pending_in_flight += 1; + i += 1; + self.transfers_sent += 1; + } + assert(transfers_count == i); + assert(transfers_count <= transfers.len); + + self.build_retry_transfers(transfers[0..transfers_count], results); + + // Checksum transfers only after the whole batch is ready. + // The opportunistic linking backtracks to modify transfers. + for (transfers[0..transfers_count]) |*transfer| { + transfer.user_data_128 = vsr.checksum(std.mem.asBytes(transfer)); + } + + return transfers_count; + } + + fn build_retry_transfers( + self: *Workload, + transfers: []tb.Transfer, + results: []accounting_auditor.CreateTransferStatusSet, + ) void { + assert(results.len >= transfers.len); + + // Neither the first nor the last id can regress to preserve the + // `transfers_delivered_recently` and `transfers_delivered_past` logic. + // So we must insert retries in the middle of the batch. + if (transfers.len <= 1) return; + for (1..transfers.len - 1) |i| { + if (self.transfers_retry_exists.items.len == 0 and + self.transfers_retry_failed.count() == 0) break; + + // To support random `lookup_transfers`, we replace the transfer with a retry, + // without altering the outcome for this specific `transfer_index`. + const transfer_index = self.transfer_id_to_index(transfers[i].id); + const transfer_plan = self.transfer_index_to_plan(transfer_index); + const can_retry = !transfer_plan.valid and + !transfers[i].flags.linked and + !transfers[i - 1].flags.linked; + if (can_retry and + self.prng.chance(self.options.create_transfer_retry_probability)) + { + switch (self.prng.chances(.{ + .exists = @intFromBool(self.transfers_retry_exists.items.len > 0), + .failed = @intFromBool(self.transfers_retry_failed.count() > 0), + })) { + .exists => { + // Retry a successfully completed transfer, result == `exists`. + const index = self.prng.index(self.transfers_retry_exists.items); + transfers[i] = self.transfers_retry_exists.swapRemove(index); + results[i] = .initOne(.exists); + }, + .failed => { + // Retry a failed transfer ID, result == `id_already_failed`. + const index = self.prng.index(self.transfers_retry_failed.keys()); + const id_failed = self.transfers_retry_failed.keys()[index]; + self.transfers_retry_failed.swapRemoveAt(index); + transfers[i] = std.mem.zeroInit(tb.Transfer, .{ .id = id_failed }); + results[i] = .initOne(.id_already_failed); + }, + } + } + } + } + + fn build_lookup_accounts(self: *Workload, lookup_ids: []u128) usize { + for (lookup_ids) |*id| { + if (self.prng.chance(self.options.lookup_account_invalid_probability)) { + // Pick an account with valid index (rather than "random.int(u128)") because the + // Auditor must decode the id to check for a matching account. + id.* = self.auditor.account_index_to_id(self.prng.int(usize)); + } else { + const account_index = self.prng.index(self.auditor.accounts); + id.* = self.auditor.accounts[account_index].id; + } + } + return lookup_ids.len; + } + + fn build_lookup_transfers(self: *const Workload, lookup_ids: []u128) usize { + const delivered = self.transfers_delivered_past; + const lookup_window = self.prng.enum_weighted(Lookup, self.options.lookup_transfer); + const lookup_window_start = switch (lookup_window) { + .delivered => self.prng.int_inclusive(usize, delivered), + .sending => self.prng.range_inclusive( + usize, + delivered, + self.transfers_sent, + ), + }; + + // +1 to make the span-max inclusive. + const lookup_window_size = @min( + fuzz.random_int_exponential( + self.prng, + usize, + self.options.lookup_transfer_span_mean, + ), + self.transfers_sent - lookup_window_start, + ); + if (lookup_window_size == 0) return 0; + + for (lookup_ids) |*lookup_id| { + lookup_id.* = self.transfer_index_to_id( + lookup_window_start + self.prng.int_inclusive(usize, lookup_window_size - 1), + ); + } + return lookup_ids.len; + } + + fn build_get_account_filter( + self: *const Workload, + client_index: usize, + comptime action: Action, + body: []tb.AccountFilter, + ) usize { + _ = client_index; + comptime assert(action == .get_account_transfers or + action == .get_account_balances or + action == .deprecated_get_account_transfers_unbatched or + action == .deprecated_get_account_balances_unbatched); + assert(body.len == 1); + const account_filter = &body[0]; + account_filter.* = tb.AccountFilter{ + .account_id = 0, + .user_data_128 = 0, + .user_data_64 = 0, + .user_data_32 = 0, + .code = 0, + .limit = 0, + .flags = .{ + .credits = false, + .debits = false, + .reversed = false, + }, + .timestamp_min = 0, + .timestamp_max = 0, + }; + + account_filter.account_id = if (self.auditor.pick_account(.{ + .created = null, + .debits_must_not_exceed_credits = null, + .credits_must_not_exceed_debits = null, + })) |account| account.id else + // Pick an account with valid index (rather than "random.int(u128)") because the + // Auditor must decode the id to check for a matching account. + self.auditor.account_index_to_id(self.prng.int(usize)); + + // It may be an invalid account. + const account_state: ?*const Auditor.AccountState = self.auditor.get_account_state( + account_filter.account_id, + ); + + account_filter.flags.reversed = self.prng.boolean(); + + const operation = comptime std.enums.nameCast(Operation, action); + const batch_result_max = operation.result_max(self.options.batch_size_limit); + + // The timestamp range is restrictive to the number of transfers inserted at the + // moment the filter was generated. Only when this filter is in place we can assert + // the expected result count. + if (account_state != null and + self.prng.chance(self.options.account_filter_timestamp_range_probability)) + { + account_filter.flags.credits = true; + account_filter.flags.debits = true; + account_filter.limit = @min( + account_state.?.transfers_count(account_filter.flags), + batch_result_max, + ); + account_filter.timestamp_min = account_state.?.transfer_timestamp_min; + account_filter.timestamp_max = account_state.?.transfer_timestamp_max; + + // Exclude the first or the last result depending on the sort order, + // if there are more than one single transfer. + account_filter.timestamp_min += @intFromBool(!account_filter.flags.reversed); + account_filter.timestamp_max -|= @intFromBool(account_filter.flags.reversed); + } else { + switch (self.prng.enum_uniform(enum { none, debits, credits, all })) { + .none => {}, // Testing invalid flags. + .debits => account_filter.flags.debits = true, + .credits => account_filter.flags.credits = true, + .all => { + account_filter.flags.debits = true; + account_filter.flags.credits = true; + }, + } + + account_filter.limit = switch (self.prng.enum_uniform(enum { + zero, + one, + random, + batch_max, + })) { + .zero => 0, + .one => 1, + .random => self.prng.int_inclusive(u32, batch_result_max), + .batch_max => batch_result_max, + }; + } + + return 1; + } + + fn build_query_filter( + self: *const Workload, + client_index: usize, + comptime action: Action, + body: []tb.QueryFilter, + ) usize { + _ = client_index; + comptime assert(action == .query_accounts or + action == .query_transfers or + action == .deprecated_query_accounts_unbatched or + action == .deprecated_query_transfers_unbatched); + assert(body.len == 1); + const query_filter = &body[0]; + + const operation = comptime std.enums.nameCast(Operation, action); + const batch_result_max = operation.result_max(self.options.batch_size_limit); + const limit: u32 = switch (self.prng.enum_uniform(enum { + zero, + one, + random, + batch_max, + })) { + .zero => 0, + .one => 1, + .random => self.prng.int_inclusive(u32, batch_result_max), + .batch_max => batch_result_max, + }; + + if (self.prng.chance(self.options.query_filter_not_found_probability)) { + query_filter.* = .{ + .user_data_128 = 0, + .user_data_64 = 0, + .user_data_32 = 0, + .code = 0, + .ledger = 999, // Non-existent ledger + .limit = limit, + .flags = .{ + .reversed = false, + }, + .timestamp_min = 0, + .timestamp_max = 0, + }; + } else { + const query_intersection_index = self.prng.index(self.auditor.query_intersections); + const query_intersection = + self.auditor.query_intersections[query_intersection_index]; + + query_filter.* = .{ + .user_data_128 = 0, + .user_data_64 = query_intersection.user_data_64, + .user_data_32 = query_intersection.user_data_32, + .code = query_intersection.code, + .ledger = 0, + .limit = limit, + .flags = .{ + .reversed = self.prng.boolean(), + }, + .timestamp_min = 0, + .timestamp_max = 0, + }; + + // Maybe filter by timestamp: + const state = switch (action) { + .query_accounts, + .deprecated_query_accounts_unbatched, + => &query_intersection.accounts, + .query_transfers, + .deprecated_query_transfers_unbatched, + => &query_intersection.transfers, + else => unreachable, + }; + + if (state.count > 1 and state.count <= batch_result_max and + self.prng.chance(self.options.query_filter_timestamp_range_probability)) + { + // Excluding the first or last object: + if (query_filter.flags.reversed) { + query_filter.timestamp_min = state.timestamp_min; + query_filter.timestamp_max = state.timestamp_max - 1; + } else { + query_filter.timestamp_min = state.timestamp_min + 1; + query_filter.timestamp_max = state.timestamp_max; + } + // Later we can assert that results.len == count - 1: + query_filter.limit = state.count; + } + } + + return 1; + } + + fn build_get_change_events_filter( + self: *Workload, + client_index: usize, + body: []tb.ChangeEventsFilter, + ) usize { + _ = client_index; + assert(body.len == 1); + const filter = &body[0]; + + const snapshot = self.auditor.changes_tracker.acquire_snapshot() orelse { + // We can only track a limited set of events, + // so we issue a query with an invalid filter when the results can't be asserted. + filter.* = switch (self.prng.enum_uniform(enum { + zeroed, + invalid_timestamps, + })) { + .zeroed => .{ + .limit = 0, + .timestamp_min = 0, + .timestamp_max = 0, + }, + .invalid_timestamps => filter: { + const timestamp: u64 = self.prng.range_inclusive( + u64, + TimestampRange.timestamp_min, + TimestampRange.timestamp_max, + ); + break :filter .{ + .limit = self.prng.int(u32), + .timestamp_min = timestamp + 1, + .timestamp_max = timestamp, + }; + }, + }; + return 1; + }; + assert(snapshot.count_total() > 0); + + const limit: u32 = switch (self.prng.enum_uniform(enum { + exact, + batch_max, + })) { + .exact => snapshot.count_total(), + .batch_max => Operation.get_change_events.result_max( + self.options.batch_size_limit, + ), + }; + filter.* = .{ + .limit = limit, + .timestamp_min = snapshot.timestamp_min, + .timestamp_max = snapshot.timestamp_max, + }; + return 1; + } + + /// The transfer built is guaranteed to match the TransferPlan's outcome. + /// The transfer built is _not_ guaranteed to match the TransferPlan's method. + /// + /// Returns `null` if the transfer plan cannot be fulfilled (because there aren't enough + /// accounts created). + fn build_transfer( + self: *Workload, + transfer_id: u128, + transfer_plan: TransferPlan, + transfer: *tb.Transfer, + ) ?accounting_auditor.CreateTransferStatusSet { + // If the specified method is unavailable, swap it. + // Changing the method may narrow the TransferOutcome (unknown→success, unknown→failure) + // but never broaden it (success→unknown, success→failure). + const method = method: { + const default = transfer_plan.method; + if (default == .pending and + self.auditor.pending_expiries.count() + self.transfers_pending_in_flight == + self.auditor.options.transfers_pending_max) + { + break :method .single_phase; + } + + if (default == .post_pending or default == .void_pending) { + if (self.auditor.pending_transfers.count() == 0) { + break :method .single_phase; + } + } + break :method default; + }; + + const index_valid = @intFromBool(transfer_plan.valid); + const index_limit = @intFromBool(transfer_plan.limit); + const index_method = @intFromEnum(method); + const transfer_template = &transfer_templates[index_valid][index_limit][index_method]; + + const limit_debits = transfer_plan.limit and self.prng.boolean(); + const limit_credits = transfer_plan.limit and (self.prng.boolean() or !limit_debits); + assert(transfer_plan.limit == (limit_debits or limit_credits)); + + const debit_account = self.auditor.pick_account(.{ + .created = true, + .debits_must_not_exceed_credits = limit_debits, + .credits_must_not_exceed_debits = null, + }) orelse return null; + assert(!limit_debits or debit_account.flags.debits_must_not_exceed_credits); + + const credit_account = self.auditor.pick_account(.{ + .created = true, + .debits_must_not_exceed_credits = null, + .credits_must_not_exceed_debits = limit_credits, + .exclude = debit_account.id, + }) orelse return null; + assert(!limit_credits or credit_account.flags.credits_must_not_exceed_debits); + + const query_intersection_index = self.prng.index( + self.auditor.query_intersections, + ); + const query_intersection = self.auditor.query_intersections[query_intersection_index]; + + transfer.* = .{ + .id = transfer_id, + .debit_account_id = debit_account.id, + .credit_account_id = credit_account.id, + // "user_data_128" will be set to a checksum of the Transfer. + .user_data_128 = 0, + .user_data_64 = query_intersection.user_data_64, + .user_data_32 = query_intersection.user_data_32, + .code = query_intersection.code, + .pending_id = 0, + .timeout = 0, + .ledger = transfer_template.ledger, + .flags = .{}, + .timestamp = 0, + .amount = self.prng.int_inclusive(u128, std.math.maxInt(u8)), + }; + + switch (method) { + .single_phase => {}, + .pending => { + transfer.flags = .{ .pending = true }; + // Bound the timeout to ensure we never hit `overflows_timeout`. + transfer.timeout = 1 + @as(u32, @min( + std.math.maxInt(u32) / 2, + fuzz.random_int_exponential( + self.prng, + u32, + self.options.pending_timeout_mean, + ), + )); + }, + .post_pending, .void_pending => { + // Don't depend on `HashMap.keyIterator()` being deterministic. + // Pick a random "target" key, then post/void the id it is nearest to. + const target = self.prng.int(u128); + var previous: ?u128 = null; + var iterator = self.auditor.pending_transfers.keyIterator(); + while (iterator.next()) |id| { + if (previous == null or + @max(target, id.*) - @min(target, id.*) < + @max(target, previous.?) - @min(target, previous.?)) + { + previous = id.*; + } + } + + // If there were no pending ids, the method would have been changed. + const pending_id = previous.?; + const pending_transfer = self.auditor.pending_transfers.getPtr(previous.?).?; + const dr = pending_transfer.debit_account_index; + const cr = pending_transfer.credit_account_index; + const pending_query_intersection = self.auditor + .query_intersections[pending_transfer.query_intersection_index]; + // Don't use the default '0' parameters because the StateMachine overwrites 0s + // with the pending transfer's values, invalidating the post/void transfer + // checksum. + transfer.debit_account_id = self.auditor.account_index_to_id(dr); + transfer.credit_account_id = self.auditor.account_index_to_id(cr); + transfer.user_data_64 = pending_query_intersection.user_data_64; + transfer.user_data_32 = pending_query_intersection.user_data_32; + transfer.code = pending_query_intersection.code; + if (method == .post_pending) { + transfer.amount = + self.prng.range_inclusive(u128, 0, pending_transfer.amount); + } else { + transfer.amount = pending_transfer.amount; + } + transfer.pending_id = pending_id; + transfer.flags = .{ + .post_pending_transfer = method == .post_pending, + .void_pending_transfer = method == .void_pending, + }; + }, + } + assert(transfer_template.result.count() > 0); + return transfer_template.result; + } + + fn batch( + self: *const Workload, + comptime T: type, + comptime action: Action, + body: []u8, + event_count_remain: u32, + ) []T { + const batch_min = switch (action) { + .create_accounts, + .lookup_accounts, + .deprecated_create_accounts_sparse, + .deprecated_create_accounts_unbatched, + .deprecated_lookup_accounts_unbatched, + => self.options.accounts_batch_size_min, + .create_transfers, + .lookup_transfers, + .deprecated_create_transfers_sparse, + .deprecated_create_transfers_unbatched, + .deprecated_lookup_transfers_unbatched, + => self.options.transfers_batch_size_min, + .get_account_transfers, + .get_account_balances, + .query_accounts, + .query_transfers, + .deprecated_get_account_transfers_unbatched, + .deprecated_get_account_balances_unbatched, + .deprecated_query_accounts_unbatched, + .deprecated_query_transfers_unbatched, + .get_change_events, + => 1, + }; + const batch_span = switch (action) { + .create_accounts, + .lookup_accounts, + .deprecated_create_accounts_sparse, + .deprecated_create_accounts_unbatched, + .deprecated_lookup_accounts_unbatched, + => self.options.accounts_batch_size_span, + .create_transfers, + .lookup_transfers, + .deprecated_create_transfers_sparse, + .deprecated_create_transfers_unbatched, + .deprecated_lookup_transfers_unbatched, + => self.options.transfers_batch_size_span, + .get_account_transfers, + .get_account_balances, + .query_accounts, + .query_transfers, + .deprecated_get_account_transfers_unbatched, + .deprecated_get_account_balances_unbatched, + .deprecated_query_accounts_unbatched, + .deprecated_query_transfers_unbatched, + .get_change_events, + => 0, + }; + + const slice = stdx.bytes_as_slice(.inexact, T, body); + const batch_size = @min( + batch_min + self.prng.int_inclusive(usize, batch_span), + event_count_remain, + ); + + return slice[0..batch_size]; + } + + fn transfer_id_to_index(self: *const Workload, id: u128) usize { + // -1 because id=0 is not valid, so index=0→id=1. + return @as(usize, @intCast(self.options.transfer_id_permutation.decode(id))) - 1; + } + + fn transfer_index_to_id(self: *const Workload, index: usize) u128 { + // +1 so that index=0 is encoded as a valid id. + return self.options.transfer_id_permutation.encode(index + 1); + } + + /// To support `lookup_transfers`, the `TransferPlan` is deterministic based on: + /// * `Workload.transfer_plan_seed`, and + /// * the transfer `index`. + fn transfer_index_to_plan(self: *const Workload, index: usize) TransferPlan { + var prng = stdx.PRNG.from_seed(self.transfer_plan_seed ^ @as(u64, index)); + const method: TransferPlan.Method = blk: { + if (prng.chance(self.options.create_transfer_pending_probability)) { + break :blk .pending; + } + if (prng.chance(self.options.create_transfer_post_probability)) { + break :blk .post_pending; + } + if (prng.chance(self.options.create_transfer_void_probability)) { + break :blk .void_pending; + } + break :blk .single_phase; + }; + return .{ + .valid = !prng.chance(self.options.create_transfer_invalid_probability), + .limit = prng.chance(self.options.create_transfer_limit_probability), + .method = method, + }; + } + + fn on_create_transfers_sparse( + self: *Workload, + client_index: usize, + timestamp: u64, + transfers: []const tb.Transfer, + results_sparse: []const tb.CreateTransferErrorResult, + ) void { + self.auditor.on_create_transfers_sparse( + client_index, + timestamp, + transfers, + results_sparse, + ); + if (transfers.len == 0) return; + + const transfer_index_min = self.transfer_id_to_index(transfers[0].id); + const transfer_index_max = self.transfer_id_to_index(transfers[transfers.len - 1].id); + assert(transfer_index_min <= transfer_index_max); + + self.transfers_delivered_recently.add(.{ + .min = transfer_index_min, + .max = transfer_index_max, + }) catch unreachable; + + while (self.transfers_delivered_recently.peek()) |delivered| { + if (self.transfers_delivered_past == delivered.min) { + self.transfers_delivered_past = delivered.max + 1; + _ = self.transfers_delivered_recently.remove(); + } else { + assert(self.transfers_delivered_past < delivered.min); + break; + } + } + + var iterator: ResultsSparseIteratorType(tb.CreateTransferErrorResult) = .init( + results_sparse, + ); + for (transfers, 0..) |*transfer, i| { + const result: tb.CreateTransferStatus = iterator.take(i) orelse .created; + if (transfer.flags.pending and result != .exists) { + self.transfers_pending_in_flight -= 1; + } + + // Add some successfully completed transfers to be retried in the next request. + if (result == .created and !transfer.flags.linked and + self.transfers_retry_exists.items.len < + self.options.transfers_retry_exists_max and + self.prng.chance(self.options.create_transfer_retry_probability)) + { + var transfer_exists = transfer.*; + assert(transfer_exists.timestamp == 0); + assert(transfer_exists.user_data_128 != 0); + + transfer_exists.user_data_128 = 0; // This will be replaced by the checksum. + self.transfers_retry_exists.appendAssumeCapacity(transfer_exists); + } + + // Enqueue the `id`s of transient errors to be retried in the next request. + if (result != .created and result.transient() and + self.transfers_retry_failed.count() < + self.options.transfers_retry_failed_max) + { + self.transfers_retry_failed.putAssumeCapacityNoClobber( + transfer.id, + {}, + ); + } + } + } + + fn on_create_transfers( + self: *Workload, + client_index: usize, + timestamp: u64, + transfers: []const tb.Transfer, + results: []const tb.CreateTransferResult, + ) void { + self.auditor.on_create_transfers( + client_index, + timestamp, + transfers, + results, + ); + if (transfers.len == 0) return; + + const transfer_index_min = self.transfer_id_to_index(transfers[0].id); + const transfer_index_max = self.transfer_id_to_index(transfers[transfers.len - 1].id); + assert(transfer_index_min <= transfer_index_max); + + self.transfers_delivered_recently.add(.{ + .min = transfer_index_min, + .max = transfer_index_max, + }) catch unreachable; + + while (self.transfers_delivered_recently.peek()) |delivered| { + if (self.transfers_delivered_past == delivered.min) { + self.transfers_delivered_past = delivered.max + 1; + _ = self.transfers_delivered_recently.remove(); + } else { + assert(self.transfers_delivered_past < delivered.min); + break; + } + } + + for (transfers, results) |*transfer, *result| { + assert(result.reserved == 0); + if (transfer.flags.pending and result.status != .exists) { + self.transfers_pending_in_flight -= 1; + } + + // Add some successfully completed transfers to be retried in the next request. + if (result.status == .created and !transfer.flags.linked and + self.transfers_retry_exists.items.len < + self.options.transfers_retry_exists_max and + self.prng.chance(self.options.create_transfer_retry_probability)) + { + var transfer_exists = transfer.*; + assert(transfer_exists.timestamp == 0); + assert(transfer_exists.user_data_128 != 0); + + transfer_exists.user_data_128 = 0; // This will be replaced by the checksum. + self.transfers_retry_exists.appendAssumeCapacity(transfer_exists); + } + + // Enqueue the `id`s of transient errors to be retried in the next request. + if (result.status != .created and result.status.transient() and + self.transfers_retry_failed.count() < + self.options.transfers_retry_failed_max) + { + self.transfers_retry_failed.putAssumeCapacityNoClobber( + transfer.id, + {}, + ); + } + } + } + + fn on_lookup_transfers( + self: *Workload, + client_index: usize, + timestamp: u64, + ids: []const u128, + results: []const tb.Transfer, + ) void { + self.auditor.on_lookup_transfers(client_index, timestamp, ids, results); + + var transfers = accounting_auditor.IteratorForLookupType(tb.Transfer).init(results); + for (ids) |transfer_id| { + const transfer_index = self.transfer_id_to_index(transfer_id); + const transfer_outcome = self.transfer_index_to_plan(transfer_index).outcome(); + const result = transfers.take(transfer_id); + + if (result) |transfer| validate_transfer_checksum(transfer); + + if (transfer_index >= self.transfers_sent) { + // This transfer hasn't been created yet. + assert(result == null); + continue; + } + + switch (transfer_outcome) { + .success => { + if (transfer_index < self.transfers_delivered_past) { + // The transfer was delivered; it must exist. + assert(result != null); + } else { + var it = self.transfers_delivered_recently.iterator(); + while (it.next()) |delivered| { + if (transfer_index >= delivered.min and + transfer_index <= delivered.max) + { + // The transfer was delivered recently; it must exist. + assert(result != null); + break; + } + } else { + // The `create_transfers` has not committed (it may be in-flight). + assert(result == null); + } + } + }, + // An invalid transfer is never persisted. + .failure => assert(result == null), + // Due to races and timeouts, these transfer types may not succeed. + .unknown => {}, + } + } + } + + fn on_get_account_transfers( + self: *Workload, + comptime operation: Operation, + timestamp: u64, + body: []const tb.AccountFilter, + results: []const tb.Transfer, + ) void { + _ = timestamp; + comptime assert(operation == .get_account_transfers or + operation == .deprecated_get_account_transfers_unbatched); + assert(body.len == 1); + + const batch_result_max = operation.result_max(self.options.batch_size_limit); + const account_filter = &body[0]; + assert(results.len <= account_filter.limit); + assert(results.len <= batch_result_max); + + const account_state = self.auditor.get_account_state( + account_filter.account_id, + ) orelse { + // Invalid account id. + assert(results.len == 0); + return; + }; + + const filter_valid = account_state.created and + (account_filter.flags.credits or account_filter.flags.debits) and + account_filter.limit > 0 and + account_filter.timestamp_min <= account_filter.timestamp_max; + if (!filter_valid) { + // Invalid filter. + assert(results.len == 0); + return; + } + + self.validate_account_filter_result_count( + operation, + account_state, + account_filter, + results.len, + ); + + var timestamp_previous: u64 = if (account_filter.flags.reversed) + account_state.transfer_timestamp_max +| 1 + else + account_state.transfer_timestamp_min -| 1; + + for (results) |*transfer| { + if (account_filter.flags.reversed) { + assert(transfer.timestamp < timestamp_previous); + } else { + assert(transfer.timestamp > timestamp_previous); + } + timestamp_previous = transfer.timestamp; + + assert(account_filter.timestamp_min == 0 or + transfer.timestamp >= account_filter.timestamp_min); + assert(account_filter.timestamp_max == 0 or + transfer.timestamp <= account_filter.timestamp_max); + + validate_transfer_checksum(transfer); + + const transfer_index = self.transfer_id_to_index(transfer.id); + assert(transfer_index < self.transfers_sent); + + const transfer_plan = self.transfer_index_to_plan(transfer_index); + assert(transfer_plan.valid); + assert(transfer_plan.outcome() != .failure); + if (transfer.flags.pending) assert(transfer_plan.method == .pending); + if (transfer.flags.post_pending_transfer) { + assert(transfer_plan.method == .post_pending); + } + if (transfer.flags.void_pending_transfer) { + assert(transfer_plan.method == .void_pending); + } + if (transfer_plan.method == .single_phase) assert(!transfer.flags.pending and + !transfer.flags.post_pending_transfer and + !transfer.flags.void_pending_transfer); + + assert(transfer.debit_account_id == account_filter.account_id or + transfer.credit_account_id == account_filter.account_id); + assert(account_filter.flags.credits or account_filter.flags.debits); + assert(account_filter.flags.credits or + transfer.debit_account_id == account_filter.account_id); + assert(account_filter.flags.debits or + transfer.credit_account_id == account_filter.account_id); + + if (transfer_plan.limit) { + // The plan does not guarantee the "limit" flag for posting + // or voiding pending transfers. + const post_or_void_pending_transfer = transfer.flags.post_pending_transfer or + transfer.flags.void_pending_transfer; + assert(post_or_void_pending_transfer == (transfer.pending_id != 0)); + + const dr_account = self.auditor.get_account(transfer.debit_account_id).?; + const cr_account = self.auditor.get_account(transfer.credit_account_id).?; + assert( + post_or_void_pending_transfer or + dr_account.flags.debits_must_not_exceed_credits or + cr_account.flags.credits_must_not_exceed_debits, + ); + } + } + } + + fn on_get_account_balances( + self: *Workload, + comptime operation: Operation, + timestamp: u64, + body: []const tb.AccountFilter, + results: []const tb.AccountBalance, + ) void { + _ = timestamp; + comptime assert(operation == .get_account_balances or + operation == .deprecated_get_account_balances_unbatched); + assert(body.len == 1); + + const batch_result_max = operation.result_max(self.options.batch_size_limit); + const account_filter = &body[0]; + assert(results.len <= account_filter.limit); + assert(results.len <= batch_result_max); + + const account_state = self.auditor.get_account_state( + account_filter.account_id, + ) orelse { + // Invalid account id. + assert(results.len == 0); + return; + }; + + const filter_valid = account_state.created and + self.auditor.get_account(account_filter.account_id).?.flags.history and + (account_filter.flags.credits or account_filter.flags.debits) and + account_filter.limit > 0 and + account_filter.timestamp_min <= account_filter.timestamp_max; + if (!filter_valid) { + // Invalid filter. + assert(results.len == 0); + return; + } + + self.validate_account_filter_result_count( + operation, + account_state, + account_filter, + results.len, + ); + + var timestamp_last: u64 = if (account_filter.flags.reversed) + account_state.transfer_timestamp_max +| 1 + else + account_state.transfer_timestamp_min -| 1; + + for (results) |*balance| { + assert(if (account_filter.flags.reversed) + balance.timestamp < timestamp_last + else + balance.timestamp > timestamp_last); + timestamp_last = balance.timestamp; + + assert(account_filter.timestamp_min == 0 or + balance.timestamp >= account_filter.timestamp_min); + assert(account_filter.timestamp_max == 0 or + balance.timestamp <= account_filter.timestamp_max); + } + } + + fn validate_account_filter_result_count( + self: *const Workload, + comptime operation: Operation, + account_state: *const Auditor.AccountState, + account_filter: *const tb.AccountFilter, + result_count: usize, + ) void { + comptime assert(operation == .get_account_transfers or + operation == .get_account_balances or + operation == .deprecated_get_account_transfers_unbatched or + operation == .deprecated_get_account_balances_unbatched); + maybe(account_filter.limit == 0); + + const batch_result_max = operation.result_max(self.options.batch_size_limit); + const transfer_count = account_state.transfers_count(account_filter.flags); + if (account_filter.timestamp_min == 0 and account_filter.timestamp_max == 0) { + assert(account_filter.limit <= batch_result_max); + assert(result_count == + @min(account_filter.limit, batch_result_max, transfer_count)); + } else { + // If timestamp range is set, then the limit is exactly the number of transfer + // at the time the filter was generated, but new transfers could have been + // inserted since then. + assert(account_filter.limit <= transfer_count); + assert(account_filter.timestamp_max >= account_filter.timestamp_min); + if (account_filter.flags.reversed) { + // This filter is only set if there is at least one transfer, so the first + // transfer timestamp never changes. + assert(account_filter.timestamp_min == account_state.transfer_timestamp_min); + // The filter `timestamp_max` was decremented to skip one result. + assert(account_filter.timestamp_max < account_state.transfer_timestamp_max); + } else { + // The filter `timestamp_min` was incremented to skip one result. + assert(account_filter.timestamp_min > account_state.transfer_timestamp_min); + // New transfers can update `transfer_timestamp_max`. + assert(account_filter.timestamp_max <= account_state.transfer_timestamp_max); + } + + // Either `transfer_count` is greater than the batch size (so removing a result + // doesn't make a difference) or there is exactly one less result that was + // excluded by the timestamp filter. + assert((result_count == batch_result_max and transfer_count > batch_result_max) or + result_count == account_filter.limit - 1); + } + } + + fn on_query( + self: *Workload, + comptime operation: Operation, + timestamp: u64, + body: []const tb.QueryFilter, + results: []const operation.ResultType(), + ) void { + _ = timestamp; + comptime assert(operation == .query_accounts or + operation == .query_transfers or + operation == .deprecated_query_accounts_unbatched or + operation == .deprecated_query_transfers_unbatched); + assert(body.len == 1); + + const batch_result_max: u32 = operation.result_max(self.options.batch_size_limit); + const filter = &body[0]; + + if (filter.ledger != 0) { + // No results expected. + assert(results.len == 0); + return; + } + + assert(filter.user_data_64 != 0); + assert(filter.user_data_32 != 0); + assert(filter.code != 0); + assert(filter.user_data_128 == 0); + assert(filter.ledger == 0); + maybe(filter.limit == 0); + maybe(filter.timestamp_min == 0); + maybe(filter.timestamp_max == 0); + + const query_intersection_index = filter.code - 1; + const query_intersection = self.auditor.query_intersections[query_intersection_index]; + const state = switch (operation) { + .query_accounts, + .deprecated_query_accounts_unbatched, + => &query_intersection.accounts, + .query_transfers, + .deprecated_query_transfers_unbatched, + => &query_intersection.transfers, + else => unreachable, + }; + + assert(results.len <= filter.limit); + assert(results.len <= batch_result_max); + + if (filter.timestamp_min > 0 or filter.timestamp_max > 0) { + assert(filter.limit <= state.count); + assert(filter.timestamp_min > 0); + assert(filter.timestamp_max > 0); + assert(filter.timestamp_min <= filter.timestamp_max); + + // Filtering by timestamp always exclude one single result. + assert(results.len == filter.limit - 1); + } else { + assert(results.len == @min( + filter.limit, + batch_result_max, + state.count, + )); + } + + var timestamp_previous: u64 = if (filter.flags.reversed) + std.math.maxInt(u64) + else + 0; + + for (results) |*result| { + if (filter.flags.reversed) { + assert(result.timestamp < timestamp_previous); + } else { + assert(result.timestamp > timestamp_previous); + } + timestamp_previous = result.timestamp; + + if (filter.timestamp_min > 0) { + assert(result.timestamp >= filter.timestamp_min); + } + if (filter.timestamp_max > 0) { + assert(result.timestamp <= filter.timestamp_max); + } + + assert(result.user_data_64 == filter.user_data_64); + assert(result.user_data_32 == filter.user_data_32); + assert(result.code == filter.code); + + if (operation == .query_transfers or + operation == .deprecated_query_transfers_unbatched) + { + validate_transfer_checksum(result); + } + } + } + + fn on_get_change_events( + self: *Workload, + timestamp: u64, + body: []const tb.ChangeEventsFilter, + results: []const tb.ChangeEvent, + ) void { + assert(body.len == 1); + self.auditor.on_get_change_events(timestamp, body[0], results); + + for (results) |*result| { + assert(stdx.zeroed(&result.reserved)); + switch (result.type) { + .single_phase => { + assert(result.timestamp == result.transfer_timestamp); + assert(!result.transfer_flags.pending); + assert(!result.transfer_flags.post_pending_transfer); + assert(!result.transfer_flags.void_pending_transfer); + assert(result.transfer_pending_id == 0); + assert(result.transfer_amount <= result.debit_account_debits_posted); + assert(result.transfer_amount <= result.credit_account_credits_posted); + }, + .two_phase_pending => { + assert(result.timestamp == result.transfer_timestamp); + assert(result.transfer_flags.pending); + assert(!result.transfer_flags.post_pending_transfer); + assert(!result.transfer_flags.void_pending_transfer); + assert(result.transfer_pending_id == 0); + assert(result.transfer_amount <= result.debit_account_debits_pending); + assert(result.transfer_amount <= result.credit_account_credits_pending); + }, + .two_phase_posted => { + assert(result.timestamp == result.transfer_timestamp); + assert(result.transfer_flags.post_pending_transfer); + assert(!result.transfer_flags.pending); + assert(!result.transfer_flags.void_pending_transfer); + assert(result.transfer_pending_id != 0); + assert(result.transfer_amount <= result.debit_account_debits_posted); + assert(result.transfer_amount <= result.credit_account_credits_posted); + }, + .two_phase_voided => { + assert(result.timestamp == result.transfer_timestamp); + assert(result.transfer_flags.void_pending_transfer); + assert(!result.transfer_flags.pending); + assert(!result.transfer_flags.post_pending_transfer); + assert(result.transfer_pending_id != 0); + }, + .two_phase_expired => { + assert(result.transfer_timeout > 0); + const timeout_ns: u64 = + @as(u64, result.transfer_timeout) * std.time.ns_per_s; + assert(result.timestamp >= result.transfer_timestamp + timeout_ns); + assert(result.transfer_flags.pending); + assert(!result.transfer_flags.post_pending_transfer); + assert(!result.transfer_flags.void_pending_transfer); + assert(result.transfer_pending_id == 0); + }, + } + assert(result.transfer_flags.closing_debit == result.debit_account_flags.closed); + assert(result.transfer_flags.closing_credit == result.credit_account_flags.closed); + validate_get_event_checksum(result); + } + } + + /// Verify the transfer's integrity. + fn validate_transfer_checksum(transfer: *const tb.Transfer) void { + const checksum_actual = transfer.user_data_128; + var check = transfer.*; + check.user_data_128 = 0; + check.timestamp = 0; + const checksum_expect = vsr.checksum(std.mem.asBytes(&check)); + assert(checksum_expect == checksum_actual); + } + + fn validate_get_event_checksum(event: *const tb.ChangeEvent) void { + const transfer: tb.Transfer = .{ + .id = event.transfer_id, + .debit_account_id = event.debit_account_id, + .credit_account_id = event.credit_account_id, + .amount = event.transfer_amount, + .pending_id = event.transfer_pending_id, + .user_data_128 = event.transfer_user_data_128, + .user_data_64 = event.transfer_user_data_64, + .user_data_32 = event.transfer_user_data_32, + .timeout = event.transfer_timeout, + .ledger = event.ledger, + .code = event.transfer_code, + .flags = event.transfer_flags, + .timestamp = event.timestamp, + }; + validate_transfer_checksum(&transfer); + } + }; +} + +fn OptionsType( + comptime AccountingStateMachine: type, + comptime Action: type, + comptime Lookup: type, +) type { + return struct { + batch_size_limit: u32, + multi_batch_per_request_limit: u32, + + auditor_options: Auditor.Options, + transfer_id_permutation: IdPermutation, + + operations: stdx.PRNG.EnumWeightsType(Action), + + create_account_invalid_probability: Ratio, + create_transfer_invalid_probability: Ratio, + create_transfer_limit_probability: Ratio, + create_transfer_pending_probability: Ratio, + create_transfer_post_probability: Ratio, + create_transfer_void_probability: Ratio, + create_transfer_retry_probability: Ratio, + lookup_account_invalid_probability: Ratio, + + account_filter_invalid_account_probability: Ratio, + account_filter_timestamp_range_probability: Ratio, + + query_filter_not_found_probability: Ratio, + query_filter_timestamp_range_probability: Ratio, + lookup_transfer: stdx.PRNG.EnumWeightsType(Lookup), + + // Size of timespan for querying, measured in transfers + lookup_transfer_span_mean: usize, + + account_limit_probability: Ratio, + account_history_probability: Ratio, + + /// This probability is only checked for consecutive guaranteed-successful transfers. + linked_valid_probability: Ratio, + /// This probability is only checked for consecutive invalid transfers. + linked_invalid_probability: Ratio, + + pending_timeout_mean: u32, + + accounts_batch_size_min: usize, + accounts_batch_size_span: usize, // inclusive + transfers_batch_size_min: usize, + transfers_batch_size_span: usize, // inclusive + + /// Maximum number of failed transfer IDs to keep in the retry list. + transfers_retry_failed_max: usize, + + /// Maximum number of successfully completed transfers to keep in the retry list. + transfers_retry_exists_max: usize, + + const Options = @This(); + const Operation = AccountingStateMachine.Operation; + + pub fn generate(prng: *stdx.PRNG, options: struct { + batch_size_limit: u32, + multi_batch_per_request_limit: u32, + client_count: usize, + in_flight_max: usize, + }) Options { + assert( + options.batch_size_limit <= constants.message_body_size_max, + ); + + const batch_create_accounts_limit = @min( + Operation.create_accounts.event_max(options.batch_size_limit), + Operation.deprecated_create_accounts_sparse.event_max(options.batch_size_limit), + Operation.deprecated_create_accounts_unbatched.event_max(options.batch_size_limit), + ); + assert(batch_create_accounts_limit > 0); + assert(batch_create_accounts_limit <= + AccountingStateMachine.batch_max.create_accounts); + + const batch_create_transfers_limit = @min( + Operation.create_transfers.event_max(options.batch_size_limit), + Operation.deprecated_create_transfers_sparse.event_max(options.batch_size_limit), + Operation.deprecated_create_transfers_unbatched.event_max( + options.batch_size_limit, + ), + ); + assert(batch_create_transfers_limit > 0); + assert(batch_create_transfers_limit <= + AccountingStateMachine.batch_max.create_transfers); + return .{ + .batch_size_limit = options.batch_size_limit, + .multi_batch_per_request_limit = options.multi_batch_per_request_limit, + .auditor_options = .{ + .accounts_max = prng.range_inclusive(usize, 2, 128), + .account_id_permutation = IdPermutation.generate(prng), + .client_count = options.client_count, + .transfers_pending_max = 256, + .changes_events_max = Operation + .get_change_events.event_max(options.batch_size_limit), + .in_flight_max = options.in_flight_max, + .pulse_expiries_max = @max( + Operation.create_transfers.event_max( + options.batch_size_limit, + ), + Operation.deprecated_create_transfers_sparse.event_max( + options.batch_size_limit, + ), + Operation.deprecated_create_transfers_unbatched.event_max( + options.batch_size_limit, + ), + ), + }, + .transfer_id_permutation = IdPermutation.generate(prng), + .operations = .{ + .create_accounts = prng.range_inclusive(u64, 1, 10), + .create_transfers = prng.range_inclusive(u64, 1, 100), + .lookup_accounts = prng.range_inclusive(u64, 1, 20), + .lookup_transfers = prng.range_inclusive(u64, 1, 20), + .get_account_transfers = prng.range_inclusive(u64, 1, 20), + .get_account_balances = prng.range_inclusive(u64, 1, 20), + .query_accounts = prng.range_inclusive(u64, 1, 20), + .query_transfers = prng.range_inclusive(u64, 1, 20), + .get_change_events = prng.range_inclusive(u64, 1, 20), + + .deprecated_create_accounts_sparse = prng.range_inclusive(u64, 1, 10), + .deprecated_create_transfers_sparse = prng.range_inclusive(u64, 1, 100), + + .deprecated_create_accounts_unbatched = prng.range_inclusive(u64, 1, 10), + .deprecated_create_transfers_unbatched = prng.range_inclusive(u64, 1, 100), + .deprecated_lookup_accounts_unbatched = prng.range_inclusive(u64, 1, 20), + .deprecated_lookup_transfers_unbatched = prng.range_inclusive(u64, 1, 20), + .deprecated_get_account_transfers_unbatched = prng.range_inclusive(u64, 1, 20), + .deprecated_get_account_balances_unbatched = prng.range_inclusive(u64, 1, 20), + .deprecated_query_accounts_unbatched = prng.range_inclusive(u64, 1, 20), + .deprecated_query_transfers_unbatched = prng.range_inclusive(u64, 1, 20), + }, + .create_account_invalid_probability = ratio(1, 100), + .create_transfer_invalid_probability = ratio(1, 100), + .create_transfer_limit_probability = ratio(prng.int_inclusive(u8, 100), 100), + .create_transfer_pending_probability = ratio(prng.range_inclusive(u8, 1, 100), 100), + .create_transfer_post_probability = ratio(prng.range_inclusive(u8, 1, 50), 100), + .create_transfer_void_probability = ratio(prng.range_inclusive(u8, 1, 50), 100), + .create_transfer_retry_probability = ratio(prng.range_inclusive(u8, 1, 10), 100), + .lookup_account_invalid_probability = ratio(1, 100), + + .account_filter_invalid_account_probability = ratio( + prng.range_inclusive(u8, 1, 20), + 100, + ), + .account_filter_timestamp_range_probability = ratio( + prng.range_inclusive(u8, 1, 80), + 100, + ), + + .query_filter_not_found_probability = ratio(prng.range_inclusive(u8, 1, 20), 100), + .query_filter_timestamp_range_probability = ratio( + prng.range_inclusive(u8, 1, 80), + 100, + ), + + .lookup_transfer = .{ + .delivered = prng.range_inclusive(u64, 1, 10), + .sending = prng.range_inclusive(u64, 1, 10), + }, + .lookup_transfer_span_mean = prng.range_inclusive(usize, 10, 1000), + .account_limit_probability = ratio(prng.int_inclusive(u8, 80), 100), + .account_history_probability = ratio(prng.int_inclusive(u8, 80), 100), + .linked_valid_probability = ratio(prng.int_inclusive(u8, 100), 100), + // 100% chance: this only applies to consecutive invalid transfers, which are rare. + .linked_invalid_probability = ratio(100, 100), + // One second. + .pending_timeout_mean = 1, + .accounts_batch_size_min = 0, + .accounts_batch_size_span = prng.range_inclusive( + usize, + 1, + batch_create_accounts_limit, + ), + .transfers_batch_size_min = 0, + .transfers_batch_size_span = prng.range_inclusive( + usize, + 1, + batch_create_transfers_limit, + ), + .transfers_retry_failed_max = 128, + .transfers_retry_exists_max = 128, + }; + } + }; +} diff --git a/ocam/src/state_machine_fuzz.zig b/ocam/src/state_machine_fuzz.zig new file mode 100644 index 00000000..b468b50d --- /dev/null +++ b/ocam/src/state_machine_fuzz.zig @@ -0,0 +1,291 @@ +//! Very simple state machine fuzzer. It looks for poison pill style ops that are otherwise valid +//! which cause a crash, then be replayed after said crash, resulting in a crash loop. +const std = @import("std"); +const assert = std.debug.assert; + +const vsr = @import("vsr.zig"); +const constants = vsr.constants; +const stdx = @import("stdx"); + +const tb = @import("tigerbeetle.zig"); +const TestContext = @import("state_machine_tests.zig").TestContext; +const fuzz = @import("./testing/fuzz.zig"); + +/// Generate a random number, biased towards all bit 'edges' of T. That is, given a u64, it's very +/// likely to not only get 0 or maxInt(u64), but also values around maxInt(u63), maxInt(u62), ..., +/// maxInt(u1). +pub fn int_edge_biased(prng: *stdx.PRNG, T: anytype) T { + const bits = @typeInfo(T).int.bits; + comptime assert(@typeInfo(T).int.signedness == .unsigned); + + // With bits * 2, there's a ~50% chance of generating a uniform integer within the full range, + // and a ~50% chance of generating an integer biased towards an edge. + const bias_to = prng.range_inclusive(T, 0, bits * 2); + + if (bias_to > bits) { + return prng.int(T); + } else { + const bias_center: T = if (bias_to == bits) + std.math.maxInt(T) + else + std.math.pow(T, 2, bias_to); + const bias_min = if (bias_to == 0) 0 else bias_center - @min(bias_center, 8); + const bias_max = if (bias_to == bits) bias_center else bias_center + 8; + + return prng.range_inclusive(T, bias_min, bias_max); + } +} + +pub fn main(allocator: std.mem.Allocator, args: fuzz.FuzzArgs) !void { + var context: TestContext = undefined; + try context.init(allocator); + defer context.deinit(allocator); + + const request_buffer = try allocator.alignedAlloc( + u8, + constants.cache_line_size, + vsr.constants.message_body_size_max, + ); + defer allocator.free(request_buffer); + + const reply_buffer = try allocator.alignedAlloc( + u8, + constants.cache_line_size, + vsr.constants.message_body_size_max, + ); + defer allocator.free(reply_buffer); + + var prng = stdx.PRNG.from_seed(args.seed); + + var op: u64 = 1; + + for (0..args.events_max orelse 50_000) |_| { + const operation = prng.enum_uniform(TestContext.StateMachine.Operation); + const size: usize = size: { + if (!operation.is_multi_batch()) { + break :size build_batch(&prng, operation, request_buffer); + } + assert(operation.is_multi_batch()); + + var body_encoder = vsr.multi_batch.MultiBatchEncoder.init(request_buffer, .{ + .element_size = operation.event_size(), + }); + + const batch_count = prng.enum_uniform(enum { one, random, max }); + while (body_encoder.writable()) |writable| { + const bytes_written: u32 = build_batch(&prng, operation, writable); + body_encoder.add(bytes_written); + switch (batch_count) { + .one => { + if (body_encoder.batch_count == 1) break; + }, + .random => if (prng.chance(.{ .numerator = 30, .denominator = 100 })) { + break; + }, + .max => {}, + } + } + + break :size body_encoder.finish(); + }; + + if (context.state_machine.input_valid(operation, request_buffer[0..size])) { + context.prepare(operation, request_buffer[0..size]); + const reply_size = context.execute( + op, + operation, + request_buffer[0..size], + @ptrCast(reply_buffer), + ); + stdx.maybe(reply_size == 0); + if (operation.is_multi_batch()) { + assert(reply_size > 0); + _ = vsr.multi_batch.MultiBatchDecoder.init(reply_buffer[0..reply_size], .{ + .element_size = operation.result_size(), + }) catch |err| switch (err) { + error.MultiBatchInvalid => unreachable, + }; + } + } + op += 1; + } +} + +fn build_batch( + prng: *stdx.PRNG, + operation: TestContext.StateMachine.Operation, + buffer: []u8, +) u32 { + return switch (operation) { + // No payload, so not very interesting yet. + .pulse => 0, + + // No payload, `create_*` require compaction to be hooked up. + .create_accounts, + .create_transfers, + => 0, + .deprecated_create_accounts_sparse, + .deprecated_create_transfers_sparse, + => 0, + .deprecated_create_accounts_unbatched, + .deprecated_create_transfers_unbatched, + => 0, + + .lookup_accounts, .lookup_transfers => build_lookup(prng, buffer), + .get_account_transfers, .get_account_balances => build_account_filter(prng, buffer), + .query_accounts, .query_transfers => build_query_filter(prng, buffer), + .get_change_events => build_get_change_events_filter(prng, buffer), + + .deprecated_lookup_accounts_unbatched, + .deprecated_lookup_transfers_unbatched, + => build_lookup(prng, buffer), + .deprecated_get_account_transfers_unbatched, + .deprecated_get_account_balances_unbatched, + => build_account_filter(prng, buffer), + .deprecated_query_accounts_unbatched, + .deprecated_query_transfers_unbatched, + => build_query_filter(prng, buffer), + }; +} + +fn build_lookup(prng: *stdx.PRNG, buffer: []u8) u32 { + const ids: []u128 = stdx.bytes_as_slice(.inexact, u128, buffer); + const size: u32 = prng.int_inclusive(u32, @intCast(ids.len)); + for (ids[0..size]) |*id| { + id.* = int_edge_biased(prng, u128); + } + return size * @sizeOf(u128); +} + +fn build_account_filter(prng: *stdx.PRNG, buffer: []u8) u32 { + const filter: *tb.AccountFilter = filter: { + const slice = stdx.bytes_as_slice( + .inexact, + tb.AccountFilter, + buffer, + ); + if (slice.len == 0) return 0; + break :filter &slice[0]; + }; + var reserved: [58]u8 = @splat(0); + if (prng.chance(.{ .numerator = 1, .denominator = 1000 })) { + prng.fill(&reserved); + } + + filter.* = .{ + .account_id = int_edge_biased(prng, u128), + .user_data_128 = int_edge_biased(prng, u128), + .user_data_64 = int_edge_biased(prng, u64), + .user_data_32 = int_edge_biased(prng, u32), + .code = int_edge_biased(prng, u16), + .timestamp_min = int_edge_biased(prng, u64), + .timestamp_max = int_edge_biased(prng, u64), + .limit = int_edge_biased(prng, u32), + .reserved = reserved, + .flags = .{ + .reversed = prng.boolean(), + .debits = prng.boolean(), + .credits = prng.boolean(), + .padding = if (prng.chance(.{ .numerator = 1, .denominator = 1000 })) + int_edge_biased(prng, u29) + else + 0, + }, + }; + + return @sizeOf(tb.AccountFilter); +} + +fn build_query_filter(prng: *stdx.PRNG, buffer: []u8) u32 { + const filter: *tb.QueryFilter = filter: { + const slice = stdx.bytes_as_slice( + .inexact, + tb.QueryFilter, + buffer, + ); + if (slice.len == 0) return 0; + break :filter &slice[0]; + }; + var reserved: [6]u8 = @splat(0); + if (prng.chance(.{ .numerator = 1, .denominator = 1000 })) { + prng.fill(&reserved); + } + + filter.* = .{ + .user_data_128 = int_edge_biased(prng, u128), + .user_data_64 = int_edge_biased(prng, u64), + .user_data_32 = int_edge_biased(prng, u32), + .ledger = int_edge_biased(prng, u32), + .code = int_edge_biased(prng, u16), + .timestamp_min = int_edge_biased(prng, u64), + .timestamp_max = int_edge_biased(prng, u64), + .limit = int_edge_biased(prng, u32), + .reserved = reserved, + .flags = .{ + .reversed = prng.boolean(), + .padding = if (prng.chance(.{ .numerator = 1, .denominator = 1000 })) + int_edge_biased(prng, u31) + else + 0, + }, + }; + + return @sizeOf(tb.QueryFilter); +} + +fn build_get_change_events_filter(prng: *stdx.PRNG, buffer: []u8) u32 { + const filter: *tb.ChangeEventsFilter = filter: { + const slice = stdx.bytes_as_slice( + .inexact, + tb.ChangeEventsFilter, + buffer, + ); + if (slice.len == 0) return 0; + break :filter &slice[0]; + }; + var reserved: [44]u8 = @splat(0); + if (prng.chance(.{ .numerator = 1, .denominator = 1000 })) { + prng.fill(&reserved); + } + + filter.* = .{ + .timestamp_min = int_edge_biased(prng, u64), + .timestamp_max = int_edge_biased(prng, u64), + .limit = int_edge_biased(prng, u32), + .reserved = reserved, + }; + + return @sizeOf(tb.ChangeEventsFilter); +} + +test "int_edge_biased" { + const seed = 42; + + var prng = stdx.PRNG.from_seed(seed); + var found_max_int: [129]bool = std.mem.zeroes([129]bool); + + // Currently takes ~20 000 random values to hit all maxInts (eg, 0, maxInt(u1), maxInt(u2), etc, + // for a u128 with a seed of 42. Even if the seed changes, we expect this to find them all + // within a relatively short space of time. + for (0..20_000) |_| { + const int = int_edge_biased(&prng, u128); + + if (int == 0) { + found_max_int[0] = true; + continue; + } + + inline for (1..129) |bits| { + const IntType = @Type(.{ .int = .{ + .signedness = .unsigned, + .bits = bits, + } }); + const max = std.math.maxInt(IntType); + if (int == max) { + found_max_int[bits] = true; + } + } + } + + assert(std.mem.allEqual(bool, &found_max_int, true)); +} diff --git a/ocam/src/state_machine_tests.zig b/ocam/src/state_machine_tests.zig new file mode 100644 index 00000000..0d099581 --- /dev/null +++ b/ocam/src/state_machine_tests.zig @@ -0,0 +1,3219 @@ +const std = @import("std"); +const assert = std.debug.assert; +const math = std.math; +const mem = std.mem; + +const stdx = @import("stdx"); +const maybe = stdx.maybe; + +const tb = @import("tigerbeetle.zig"); +const vsr = @import("vsr.zig"); +const constants = vsr.constants; + +const MultiBatchEncoder = vsr.multi_batch.MultiBatchEncoder; +const MultiBatchDecoder = vsr.multi_batch.MultiBatchDecoder; + +const TimestampRange = @import("lsm/timestamp_range.zig").TimestampRange; + +const Account = tb.Account; +const AccountBalance = tb.AccountBalance; +const Transfer = tb.Transfer; +const CreateAccountResult = tb.CreateAccountResult; +const CreateTransferResult = tb.CreateTransferResult; + +const CreateAccountStatus = tb.CreateAccountStatus; +const CreateTransferStatus = tb.CreateTransferStatus; + +const AccountFilter = tb.AccountFilter; +const QueryFilter = tb.QueryFilter; +const ChangeEventsFilter = tb.ChangeEventsFilter; +const ChangeEvent = tb.ChangeEvent; +const ChangeEventType = tb.ChangeEventType; + +const StateMachineType = @import("state_machine.zig").StateMachineType; + +const testing = std.testing; + +pub const TestContext = struct { + const TimeSim = @import("testing/time.zig").TimeSim; + const Storage = @import("testing/storage.zig").Storage; + const Tracer = Storage.Tracer; + const data_file_size_min = @import("vsr/superblock.zig").data_file_size_min; + const SuperBlock = @import("vsr/superblock.zig").SuperBlockType(Storage); + const Grid = @import("vsr/grid.zig").GridType(Storage); + const fixtures = @import("testing/fixtures.zig"); + + pub const StateMachine = StateMachineType(Storage); + + pub const Operation = enum { + create_accounts, + create_transfers, + lookup_accounts, + lookup_transfers, + get_account_transfers, + get_account_balances, + query_accounts, + query_transfers, + get_change_events, + + const VersionMap = std.EnumArray(Operation, StateMachine.Operation); + /// Variations of operations supported by the state machine, + /// including deprecated ones used by old clients. + const versions: []const VersionMap = &.{ + .init(.{ + .create_accounts = .create_accounts, + .create_transfers = .create_transfers, + .lookup_accounts = .lookup_accounts, + .lookup_transfers = .lookup_transfers, + .get_account_transfers = .get_account_transfers, + .get_account_balances = .get_account_balances, + .query_accounts = .query_accounts, + .query_transfers = .query_transfers, + .get_change_events = .get_change_events, + }), + .init(.{ + .create_accounts = .deprecated_create_accounts_sparse, + .create_transfers = .deprecated_create_transfers_sparse, + .lookup_accounts = .lookup_accounts, + .lookup_transfers = .lookup_transfers, + .get_account_transfers = .get_account_transfers, + .get_account_balances = .get_account_balances, + .query_accounts = .query_accounts, + .query_transfers = .query_transfers, + .get_change_events = .get_change_events, + }), + .init(.{ + .create_accounts = .deprecated_create_accounts_unbatched, + .create_transfers = .deprecated_create_transfers_unbatched, + .lookup_accounts = .deprecated_lookup_accounts_unbatched, + .lookup_transfers = .deprecated_lookup_transfers_unbatched, + .get_account_transfers = .deprecated_get_account_transfers_unbatched, + .get_account_balances = .deprecated_get_account_balances_unbatched, + .query_accounts = .deprecated_query_accounts_unbatched, + .query_transfers = .deprecated_query_transfers_unbatched, + .get_change_events = .get_change_events, + }), + }; + }; + + storage: Storage, + time_sim: TimeSim, + trace: Tracer, + superblock: SuperBlock, + grid: Grid, + state_machine: StateMachine, + op: u64, + busy: bool, + + pub fn init(ctx: *TestContext, allocator: mem.Allocator) !void { + ctx.storage = try fixtures.init_storage(allocator, .{ .size = 4096 }); + errdefer ctx.storage.deinit(allocator); + + ctx.time_sim = fixtures.init_time(.{}); + + ctx.trace = try fixtures.init_tracer(allocator, ctx.time_sim.time(), .{}); + errdefer ctx.trace.deinit(allocator); + + ctx.superblock = try fixtures.init_superblock(allocator, &ctx.storage, .{ + .storage_size_limit = data_file_size_min, + }); + errdefer ctx.superblock.deinit(allocator); + + // Pretend that the superblock is open so that the Forest can initialize. + ctx.superblock.opened = true; + ctx.superblock.working.vsr_state.checkpoint.header.op = 0; + + ctx.grid = try fixtures.init_grid(allocator, &ctx.trace, &ctx.superblock, .{}); + errdefer ctx.grid.deinit(allocator); + + const batch_size_limit = 30 * @max(@sizeOf(Account), @sizeOf(Transfer)); + assert(batch_size_limit <= constants.message_body_size_max); + try ctx.state_machine.init( + allocator, + ctx.time_sim.time(), + &ctx.grid, + .{ + .batch_size_limit = batch_size_limit, + .lsm_forest_compaction_block_count = StateMachine.Forest.Options + .compaction_block_count_min, + .lsm_forest_node_count = 1, + .cache_entries_accounts = 0, + .cache_entries_transfers = 0, + .cache_entries_transfers_pending = 0, + .log_trace = true, + .aof_recovery = false, + }, + ); + errdefer ctx.state_machine.deinit(allocator); + // Usually, `pulse_next_timestamp` starts in an unknown state, signaling that the state + // machine needs a `pulse` to scan for pending transfers and correctly determine when to + // process the next expiry. However, this initial `pulse` unnecessarily bumps time, making + // unit tests that depend on the `timestamp` harder to reason about. + // + // Since this is a newly created state machine, we can bypass the initial check, ensuring + // that there will be no `timestamp` bumps between operations unless actual pending + // transfers get expired. + ctx.state_machine.expire_pending_transfers + .pulse_next_timestamp = TimestampRange.timestamp_max; + + ctx.op = 1; + ctx.busy = false; + } + + pub fn deinit(ctx: *TestContext, allocator: mem.Allocator) void { + ctx.state_machine.deinit(allocator); + ctx.grid.deinit(allocator); + ctx.superblock.deinit(allocator); + ctx.trace.deinit(allocator); + ctx.storage.deinit(allocator); + ctx.* = undefined; + } + + fn callback(state_machine: *StateMachine) void { + const ctx: *TestContext = @fieldParentPtr("state_machine", state_machine); + assert(ctx.busy); + ctx.busy = false; + } + + fn submit( + context: *TestContext, + operation: TestContext.StateMachine.Operation, + input_buffer: []align(constants.cache_line_size) u8, + input_size: u32, + output_buffer: *align(constants.cache_line_size) [constants.message_body_size_max]u8, + ) []const u8 { + const message_body: []align(constants.cache_line_size) const u8 = message_body: { + if (!operation.is_multi_batch()) { + break :message_body input_buffer[0..input_size]; + } + assert(operation.is_multi_batch()); + const event_size = operation.event_size(); + var body_encoder = MultiBatchEncoder.init(input_buffer, .{ + .element_size = event_size, + }); + body_encoder.add(input_size); + const bytes_written = body_encoder.finish(); + assert(bytes_written > 0); + break :message_body input_buffer[0..bytes_written]; + }; + context.prepare(operation, message_body); + + const pulse_needed = context.state_machine.pulse_needed( + context.state_machine.prepare_timestamp, + ); + maybe(pulse_needed); + // Pulse is executed in a best-effort manner + // after committing the current pipelined operation. + defer if (pulse_needed) context.pulse(); + + const reply_actual_size = context.execute( + context.op, + operation, + message_body, + output_buffer, + ); + + if (!operation.is_multi_batch()) { + return output_buffer[0..reply_actual_size]; + } + assert(operation.is_multi_batch()); + + const result_size = operation.result_size(); + var reply_decoder = MultiBatchDecoder.init( + output_buffer[0..reply_actual_size], + .{ .element_size = result_size }, + ) catch unreachable; + assert(reply_decoder.batch_count() == 1); + return reply_decoder.peek(); + } + + pub fn prepare( + context: *TestContext, + operation: TestContext.StateMachine.Operation, + message_body_used: []align(constants.cache_line_size) const u8, + ) void { + context.state_machine.commit_timestamp = context.state_machine.prepare_timestamp; + context.state_machine.prepare_timestamp += 1; + context.state_machine.prepare( + operation, + message_body_used, + ); + } + + fn pulse(context: *TestContext) void { + if (context.state_machine.pulse_needed(context.state_machine.prepare_timestamp)) { + const operation = vsr.Operation.pulse.cast(TestContext.StateMachine.Operation); + context.prepare(operation, &.{}); + const pulse_size = context.execute( + context.op, + operation, + &.{}, + undefined, // Output is never used for pulse. + ); + assert(pulse_size == 0); + context.op += 1; + } + } + + pub fn execute( + context: *TestContext, + op: u64, + operation: TestContext.StateMachine.Operation, + message_body_used: []align(constants.cache_line_size) const u8, + output_buffer: *align(constants.cache_line_size) [constants.message_body_size_max]u8, + ) usize { + const timestamp = context.state_machine.prepare_timestamp; + context.busy = true; + context.state_machine.prefetch_timestamp = timestamp; + context.state_machine.prefetch( + TestContext.callback, + op, + op, + operation, + message_body_used, + ); + while (context.busy) context.storage.run(); + + return context.state_machine.commit( + 1, + op, + timestamp, + operation, + message_body_used, + output_buffer, + ); + } + + fn get_account_from_cache(context: *TestContext, id: u128) ?Account { + return switch (context.state_machine.forest.grooves.accounts.get(id)) { + .found_object => |object| object, + .not_found => null, + }; + } +}; + +const TestAction = union(enum) { + // Set the account's balance. + setup: struct { + account: u128, + debits_pending: u128, + debits_posted: u128, + credits_pending: u128, + credits_posted: u128, + }, + + tick: struct { + value: i64, + unit: enum { nanoseconds, seconds }, + }, + + commit: TestContext.Operation, + account: TestCreateAccount, + transfer: TestCreateTransfer, + + lookup_account: struct { + id: u128, + data: ?struct { + debits_pending: u128, + debits_posted: u128, + credits_pending: u128, + credits_posted: u128, + flag_closed: ?enum { CLSD } = null, + } = null, + }, + lookup_transfer: struct { + id: u128, + data: union(enum) { + exists: bool, + amount: u128, + timestamp: u64, + }, + }, + + get_account_balances: TestGetAccountBalances, + get_account_balances_result: struct { + transfer_id: u128, + debits_pending: u128, + debits_posted: u128, + credits_pending: u128, + credits_posted: u128, + }, + + get_account_transfers: TestGetAccountTransfers, + get_account_transfers_result: u128, + + query_accounts: TestQueryAccounts, + query_accounts_result: struct { + id: u128, + data: ?struct { + debits_pending: u128, + debits_posted: u128, + credits_pending: u128, + credits_posted: u128, + flag_closed: ?enum { CLSD } = null, + } = null, + }, + + query_transfers: TestQueryTransfers, + query_transfers_result: u128, + + get_change_events: TestGetChangeEventsFilter, + get_change_events_result: TestGetChangeEventsResult, +}; + +const TestCreateAccount = struct { + id: u128, + debits_pending: u128 = 0, + debits_posted: u128 = 0, + credits_pending: u128 = 0, + credits_posted: u128 = 0, + user_data_128: u128 = 0, + user_data_64: u64 = 0, + user_data_32: u32 = 0, + reserved: u1 = 0, + ledger: u32, + code: u16, + flags_linked: ?enum { LNK } = null, + flags_debits_must_not_exceed_credits: ?enum { @"D .two_phase_pending, + .POS => .two_phase_posted, + .VOI => .two_phase_voided, + .EXP => .two_phase_expired, + }; + if (event.type != expected) return false; + } else { + if (event.type != .single_phase) return false; + } + if (event.transfer_amount != self.amount) return false; + if (self.transfer_pending_id) |transfer_pending_id| { + switch (event.type) { + .two_phase_pending, .single_phase => return false, + .two_phase_posted, .two_phase_voided => { + if (event.transfer_pending_id != transfer_pending_id) return false; + }, + .two_phase_expired => { + const transfer = transfers.get(transfer_pending_id).?; + if (transfer.timeout == 0) return false; + if (event.timestamp < + transfer.timestamp + transfer.timeout_ns()) return false; + if (!match_transfer(event, &transfer)) return false; + }, + } + } + + const dr_account = accounts.get(self.dr_account.account_id).?; + if (dr_account.ledger != event.ledger) return false; + if (self.dr_account.account_id != event.debit_account_id) return false; + if (dr_account.timestamp != event.debit_account_timestamp) return false; + if (self.dr_account.debits_pending != event.debit_account_debits_pending) return false; + if (self.dr_account.debits_posted != event.debit_account_debits_posted) return false; + if (self.dr_account.credits_pending != event.debit_account_credits_pending) return false; + if (self.dr_account.credits_posted != event.debit_account_credits_posted) return false; + if ((self.dr_account.closed == .CLSD) != event.debit_account_flags.closed) return false; + + const cr_account = accounts.get(self.cr_account.account_id).?; + if (cr_account.ledger != event.ledger) return false; + if (self.cr_account.account_id != event.credit_account_id) return false; + if (cr_account.timestamp != event.credit_account_timestamp) return false; + if (self.cr_account.debits_pending != event.credit_account_debits_pending) return false; + if (self.cr_account.debits_posted != event.credit_account_debits_posted) return false; + if (self.cr_account.credits_pending != event.credit_account_credits_pending) return false; + if (self.cr_account.credits_posted != event.credit_account_credits_posted) return false; + if ((self.cr_account.closed == .CLSD) != event.credit_account_flags.closed) return false; + + return true; + } + + fn match_transfer(event: *const ChangeEvent, transfer: *const tb.Transfer) bool { + if (event.transfer_timestamp != transfer.timestamp) return false; + if (event.transfer_id != transfer.id) return false; + if (event.transfer_amount != transfer.amount and + // The in-memory model keeps the `AMOUNT_MAX`. + transfer.amount != std.math.maxInt(u128)) return false; + if (event.transfer_pending_id != transfer.pending_id) return false; + if (event.transfer_user_data_128 != transfer.user_data_128) return false; + if (event.transfer_user_data_64 != transfer.user_data_64) return false; + if (event.transfer_user_data_32 != transfer.user_data_32) return false; + if (event.transfer_code != transfer.code) return false; + if (event.ledger != transfer.ledger) return false; + if (event.ledger != transfer.ledger) return false; + if (@as(u16, @bitCast(transfer.flags)) != + @as(u16, @bitCast(event.transfer_flags))) return false; + return true; + } +}; + +// Operations that share the same input. +const TestGetAccountBalances = TestAccountFilter; +const TestGetAccountTransfers = TestAccountFilter; +const TestQueryAccounts = TestQueryFilter; +const TestQueryTransfers = TestQueryFilter; + +fn check(test_table: []const u8) !void { + const parse_table = @import("testing/table.zig").parse; + const test_actions = parse_table(TestAction, test_table); + + // Runs the same test for each variation of supported operations, + // simulating different client versions. + for (TestContext.Operation.versions) |*version_map| { + try check_version( + test_actions.const_slice(), + version_map, + ); + } +} + +fn check_version( + test_actions: []const TestAction, + version_map: *const TestContext.Operation.VersionMap, +) !void { + const allocator = std.testing.allocator; + + var context: TestContext = undefined; + try context.init(allocator); + defer context.deinit(allocator); + + var accounts = std.AutoHashMap(u128, Account).init(allocator); + defer accounts.deinit(); + + var transfers = std.AutoHashMap(u128, Transfer).init(allocator); + defer transfers.deinit(); + + // The result code `.exists` always returns the timestamp of the original event. + // Even if the existing event was created within a linked chain and rolled back. + // For example, the linked chain: + // events: { id=1, flags=linked; id=1 } + // results: { result=linked_event_failed, timestamp=100; result=exists, timestamp=100 } + // ^^^ ^^^ + var linked_events_failed: std.AutoHashMap(u128, u64) = .init(allocator); + defer linked_events_failed.deinit(); + + var request: std.ArrayListAligned(u8, constants.cache_line_size) = .init(allocator); + defer request.deinit(); + + try request.ensureTotalCapacity(constants.message_body_size_max); + + var reply: std.ArrayListAligned(u8, constants.cache_line_size) = .init(allocator); + defer reply.deinit(); + + var operation: ?TestContext.Operation = null; + for (test_actions) |test_action| { + switch (test_action) { + .setup => |b| { + assert(operation == null); + + const account = context.get_account_from_cache(b.account).?; + var account_new = account; + + account_new.debits_pending = b.debits_pending; + account_new.debits_posted = b.debits_posted; + account_new.credits_pending = b.credits_pending; + account_new.credits_posted = b.credits_posted; + assert(!account_new.debits_exceed_credits(0)); + assert(!account_new.credits_exceed_debits(0)); + + if (!stdx.equal_bytes(Account, &account_new, &account)) { + context.state_machine.forest.grooves.accounts.update(.{ + .old = &account, + .new = &account_new, + }); + } + }, + .tick => |ticks| { + assert(ticks.value != 0); + + const interval_ns: u64 = @abs(ticks.value) * + @as(u64, switch (ticks.unit) { + .nanoseconds => 1, + .seconds => std.time.ns_per_s, + }); + + // The `parse` logic already computes `maxInt - value` when a unsigned int is + // represented as a negative number. However, we need to use a signed int and + // perform our own calculation to account for the unit. + context.state_machine.prepare_timestamp += if (ticks.value > 0) + interval_ns + else + TimestampRange.timestamp_max - interval_ns; + + // Pulse is executed when the cluster is idle. + context.pulse(); + }, + .account => |a| { + assert(operation == null or operation.? == .create_accounts); + operation = .create_accounts; + + var event = a.event(); + try request.appendSlice(std.mem.asBytes(&event)); + + const timestamp_commit = context.state_machine.prepare_timestamp + 1 + + @divExact(request.items.len, @sizeOf(Account)); + if (event.timestamp == 0) event.timestamp = timestamp_commit; + if (a.status == .created) { + try accounts.put(a.id, event); + } + + switch (version_map.get(.create_accounts)) { + .create_accounts => { + const result = CreateAccountResult{ + .timestamp = timestamp_expected: { + if (a.status == .created or a.status == .linked_event_failed) { + break :timestamp_expected event.timestamp; + } + if (a.status == .exists) { + break :timestamp_expected if (accounts.get(a.id)) |exists| + exists.timestamp + else + linked_events_failed.get(a.id).?; + } + break :timestamp_expected timestamp_commit; + }, + .status = a.status, + }; + try reply.appendSlice(std.mem.asBytes(&result)); + + if (event.flags.linked) { + if (a.status == .linked_event_failed) { + try linked_events_failed.putNoClobber(event.id, event.timestamp); + } + } else { + linked_events_failed.clearRetainingCapacity(); + } + }, + .deprecated_create_accounts_sparse, + .deprecated_create_accounts_unbatched, + => if (a.status != .created) { + const result = tb.CreateAccountErrorResult{ + .index = @intCast(@divExact(request.items.len, @sizeOf(Account)) - 1), + .result = a.status, + }; + try reply.appendSlice(std.mem.asBytes(&result)); + }, + else => unreachable, + } + }, + .transfer => |t| { + assert(operation == null or operation.? == .create_transfers); + operation = .create_transfers; + + var event = t.event(); + try request.appendSlice(std.mem.asBytes(&event)); + + const timestamp_commit = context.state_machine.prepare_timestamp + 1 + + @divExact(request.items.len, @sizeOf(Transfer)); + if (t.timestamp == 0) event.timestamp = timestamp_commit; + if (t.status == .created) { + if (event.pending_id != 0) { + // Fill in default values. + const t_pending = transfers.get(event.pending_id).?; + inline for (.{ + "debit_account_id", + "credit_account_id", + "ledger", + "code", + "user_data_128", + "user_data_64", + "user_data_32", + }) |field| { + if (@field(event, field) == 0) { + @field(event, field) = @field(t_pending, field); + } + } + + if (event.flags.void_pending_transfer) { + if (event.amount == 0) event.amount = t_pending.amount; + } + } + try transfers.put(t.id, event); + } + + switch (version_map.get(.create_transfers)) { + .create_transfers => { + const result: CreateTransferResult = .{ + .timestamp = timestamp_expected: { + if (t.status == .created or t.status == .linked_event_failed) { + break :timestamp_expected event.timestamp; + } + if (t.status == .exists) { + break :timestamp_expected if (transfers.get(t.id)) |exists| + exists.timestamp + else + linked_events_failed.get(t.id).?; + } + break :timestamp_expected timestamp_commit; + }, + .status = t.status, + }; + try reply.appendSlice(std.mem.asBytes(&result)); + + if (event.flags.linked) { + if (t.status == .linked_event_failed) { + try linked_events_failed.putNoClobber(event.id, event.timestamp); + } + } else { + linked_events_failed.clearRetainingCapacity(); + } + }, + .deprecated_create_transfers_sparse, + .deprecated_create_transfers_unbatched, + => if (t.status != .created) { + const result: tb.CreateTransferErrorResult = .{ + .index = @intCast(@divExact(request.items.len, @sizeOf(Transfer)) - 1), + .result = t.status, + }; + try reply.appendSlice(std.mem.asBytes(&result)); + }, + else => unreachable, + } + }, + .lookup_account => |a| { + assert(operation == null or operation.? == .lookup_accounts); + operation = .lookup_accounts; + + try request.appendSlice(std.mem.asBytes(&a.id)); + if (a.data) |data| { + var account = accounts.get(a.id).?; + account.debits_pending = data.debits_pending; + account.debits_posted = data.debits_posted; + account.credits_pending = data.credits_pending; + account.credits_posted = data.credits_posted; + account.flags.closed = data.flag_closed != null; + try reply.appendSlice(std.mem.asBytes(&account)); + } + }, + .lookup_transfer => |t| { + assert(operation == null or operation.? == .lookup_transfers); + operation = .lookup_transfers; + + try request.appendSlice(std.mem.asBytes(&t.id)); + switch (t.data) { + .exists => |exists| { + if (exists) { + var transfer = transfers.get(t.id).?; + try reply.appendSlice(std.mem.asBytes(&transfer)); + } + }, + .amount => |amount| { + var transfer = transfers.get(t.id).?; + transfer.amount = amount; + try reply.appendSlice(std.mem.asBytes(&transfer)); + }, + .timestamp => |timestamp| { + var transfer = transfers.get(t.id).?; + transfer.timestamp = timestamp; + try reply.appendSlice(std.mem.asBytes(&transfer)); + }, + } + }, + .get_account_balances => |f| { + assert(operation == null or operation.? == .get_account_balances); + operation = .get_account_balances; + + const timestamp_min = + if (f.timestamp_min_transfer_id) |id| transfers.get(id).?.timestamp else 0; + const timestamp_max = + if (f.timestamp_max_transfer_id) |id| transfers.get(id).?.timestamp else 0; + + const event = AccountFilter{ + .account_id = f.account_id, + .user_data_128 = f.user_data_128 orelse 0, + .user_data_64 = f.user_data_64 orelse 0, + .user_data_32 = f.user_data_32 orelse 0, + .code = f.code orelse 0, + .timestamp_min = timestamp_min, + .timestamp_max = timestamp_max, + .limit = f.limit, + .flags = .{ + .debits = f.flags_debits != null, + .credits = f.flags_credits != null, + .reversed = f.flags_reversed != null, + }, + }; + try request.appendSlice(std.mem.asBytes(&event)); + }, + .get_account_balances_result => |r| { + assert(operation.? == .get_account_balances); + + const balance = AccountBalance{ + .debits_pending = r.debits_pending, + .debits_posted = r.debits_posted, + .credits_pending = r.credits_pending, + .credits_posted = r.credits_posted, + .timestamp = transfers.get(r.transfer_id).?.timestamp, + }; + try reply.appendSlice(std.mem.asBytes(&balance)); + }, + .get_account_transfers => |f| { + assert(operation == null or operation.? == .get_account_transfers); + operation = .get_account_transfers; + + const timestamp_min = + if (f.timestamp_min_transfer_id) |id| transfers.get(id).?.timestamp else 0; + const timestamp_max = + if (f.timestamp_max_transfer_id) |id| transfers.get(id).?.timestamp else 0; + + const event = AccountFilter{ + .account_id = f.account_id, + .user_data_128 = f.user_data_128 orelse 0, + .user_data_64 = f.user_data_64 orelse 0, + .user_data_32 = f.user_data_32 orelse 0, + .code = f.code orelse 0, + .timestamp_min = timestamp_min, + .timestamp_max = timestamp_max, + .limit = f.limit, + .flags = .{ + .debits = f.flags_debits != null, + .credits = f.flags_credits != null, + .reversed = f.flags_reversed != null, + }, + }; + try request.appendSlice(std.mem.asBytes(&event)); + }, + .get_account_transfers_result => |id| { + assert(operation.? == .get_account_transfers); + try reply.appendSlice(std.mem.asBytes(&transfers.get(id).?)); + }, + .query_accounts => |f| { + assert(operation == null or operation.? == .query_accounts); + operation = .query_accounts; + + const timestamp_min = if (f.timestamp_min_transfer_id) |id| + accounts.get(id).?.timestamp + else + 0; + const timestamp_max = if (f.timestamp_max_transfer_id) |id| + accounts.get(id).?.timestamp + else + 0; + + const event = QueryFilter{ + .user_data_128 = f.user_data_128, + .user_data_64 = f.user_data_64, + .user_data_32 = f.user_data_32, + .ledger = f.ledger, + .code = f.code, + .timestamp_min = timestamp_min, + .timestamp_max = timestamp_max, + .limit = f.limit, + .flags = .{ + .reversed = f.flags_reversed != null, + }, + }; + try request.appendSlice(std.mem.asBytes(&event)); + }, + .query_accounts_result => |a| { + assert(operation.? == .query_accounts); + var account = accounts.get(a.id).?; + if (a.data) |data| { + account.debits_pending = data.debits_pending; + account.debits_posted = data.debits_posted; + account.credits_pending = data.credits_pending; + account.credits_posted = data.credits_posted; + account.flags.closed = data.flag_closed != null; + } + try reply.appendSlice(std.mem.asBytes(&account)); + }, + .query_transfers => |f| { + assert(operation == null or operation.? == .query_transfers); + operation = .query_transfers; + + const timestamp_min = if (f.timestamp_min_transfer_id) |id| + transfers.get(id).?.timestamp + else + 0; + const timestamp_max = if (f.timestamp_max_transfer_id) |id| + transfers.get(id).?.timestamp + else + 0; + + const event = QueryFilter{ + .user_data_128 = f.user_data_128, + .user_data_64 = f.user_data_64, + .user_data_32 = f.user_data_32, + .ledger = f.ledger, + .code = f.code, + .timestamp_min = timestamp_min, + .timestamp_max = timestamp_max, + .limit = f.limit, + .flags = .{ + .reversed = f.flags_reversed != null, + }, + }; + try request.appendSlice(std.mem.asBytes(&event)); + }, + .query_transfers_result => |id| { + assert(operation.? == .query_transfers); + try reply.appendSlice(std.mem.asBytes(&transfers.get(id).?)); + }, + .get_change_events => |f| { + assert(operation == null or operation.? == .get_change_events); + operation = .get_change_events; + const timestamp_min = if (f.timestamp_min_transfer_id) |id| + transfers.get(id).?.timestamp + else + 0; + const timestamp_max = if (f.timestamp_max_transfer_id) |id| + transfers.get(id).?.timestamp + else + 0; + + const event = ChangeEventsFilter{ + .timestamp_min = timestamp_min, + .timestamp_max = timestamp_max, + .limit = f.limit, + }; + try request.appendSlice(std.mem.asBytes(&event)); + }, + .get_change_events_result => |*t| { + assert(operation.? == .get_change_events); + try reply.appendSlice(std.mem.asBytes(t)); + }, + .commit => |commit_operation| { + assert(operation == null or operation.? == commit_operation); + assert(!context.busy); + + const reply_actual_buffer = try allocator.alignedAlloc( + u8, + constants.cache_line_size, + constants.message_body_size_max, + ); + defer allocator.free(reply_actual_buffer); + + const payload_size: u32 = @intCast(request.items.len); + request.expandToCapacity(); + + const operation_actual = version_map.get(commit_operation); + const reply_actual = context.submit( + operation_actual, + request.items, + payload_size, + reply_actual_buffer[0..constants.message_body_size_max], + ); + + switch (operation_actual) { + inline else => |operation_actual_comptime| { + const Result = operation_actual_comptime.ResultType(); + try testing.expectEqualSlices( + Result, + stdx.bytes_as_slice(.exact, Result, reply.items), + stdx.bytes_as_slice(.exact, Result, reply_actual), + ); + }, + .get_change_events => { + const results_actual = stdx.bytes_as_slice( + .exact, + ChangeEvent, + reply_actual, + ); + const results_expected = stdx.bytes_as_slice( + .exact, + TestGetChangeEventsResult, + reply.items, + ); + try testing.expectEqual(results_expected.len, results_actual.len); + for (results_actual, results_expected) |*actual, *expected| { + try testing.expect(expected.match(&accounts, &transfers, actual)); + } + }, + .pulse => unreachable, + } + + request.clearRetainingCapacity(); + reply.clearRetainingCapacity(); + operation = null; + }, + } + } + + assert(operation == null); + assert(request.items.len == 0); + assert(reply.items.len == 0); +} + +test "create_accounts" { + try check( + \\ account A1 0 0 0 0 U2 U2 U2 _ L3 C4 _ _ _ _ _ _ _ _ created + \\ account A0 1 1 1 1 _ _ _ 1 L0 C0 _ D e.amount + \\ transfer T101 A0 A0 14 T2 U0 U0 U0 _ L0 C0 _ _ POS _ _ _ _ _ _ _ _ exists_with_different_amount + \\ transfer T101 A0 A0 12 T2 U0 U0 U0 _ L0 C0 _ _ POS _ _ _ _ _ _ _ _ exists_with_different_amount // t.amount < e.amount + \\ + \\ transfer T105 A0 A0 8 T5 U0 U0 U0 _ L0 C0 _ _ POS _ _ _ _ _ _ _ _ exceeds_pending_transfer_amount // t.amount > p.amount + \\ transfer T105 A0 A0 -0 T5 U0 U0 U0 _ L0 C0 _ _ POS _ _ _ _ _ _ _ _ created + \\ transfer T105 A0 A0 7 T5 U0 U0 U0 _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ exists + \\ transfer T105 A0 A0 7 T5 U0 U0 U0 _ L0 C0 _ _ POS _ _ _ _ _ _ _ _ exists // ledger/code = 0 + \\ transfer T105 A0 A0 -0 T5 U0 U0 U0 _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ exists // amount = max + \\ transfer T105 A0 A0 8 T5 U0 U0 U0 _ L0 C0 _ _ POS _ _ _ _ _ _ _ _ exists_with_different_amount // t.amount > p.amount + \\ transfer T105 A0 A0 6 T5 U0 U0 U0 _ L0 C0 _ _ POS _ _ _ _ _ _ _ _ exists_with_different_amount // t.amount < e.amount + \\ transfer T105 A0 A0 0 T5 U0 U0 U0 _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ exists_with_different_amount + \\ + \\ transfer T106 A0 A0 -1 T6 U0 U0 U0 _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ exceeds_pending_transfer_amount + \\ transfer T106 A0 A0 -0 T6 U0 U0 U0 _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ created + \\ transfer T106 A0 A0 -0 T6 U0 U0 U0 _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ exists + \\ transfer T106 A0 A0 1 T6 U0 U0 U0 _ L0 C0 _ _ POS _ _ _ _ _ _ _ _ exists + \\ transfer T106 A0 A0 2 T6 U0 U0 U0 _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ exists_with_different_amount // t.amount > p.amount + \\ transfer T106 A0 A0 0 T6 U0 U0 U0 _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ exists_with_different_amount // t.amount < p.amount + \\ + \\ transfer T107 A0 A0 0 T7 U0 U0 U0 _ L0 C0 _ _ POS _ _ _ _ _ _ _ _ created + \\ transfer T107 A0 A0 0 T7 U0 U0 U0 _ L0 C0 _ _ POS _ _ _ _ _ _ _ _ exists + \\ transfer T107 A0 A0 1 T7 U0 U0 U0 _ L0 C0 _ _ POS _ _ _ _ _ _ _ _ exists_with_different_amount // t.amount > e.amount + \\ commit create_transfers + + // Check balances after resolving. + \\ lookup_account A1 0 36 0 0 _ + \\ lookup_account A2 0 0 0 36 _ + \\ commit lookup_accounts + + // The posted transfer amounts are set to the actual amount posted (which may be less than + // the "client" set as the amount). + \\ lookup_transfer T101 amount 13 + \\ lookup_transfer T105 amount 7 + \\ lookup_transfer T106 amount 1 + \\ lookup_transfer T107 amount 0 + \\ commit lookup_transfers + ); +} + +test "create/lookup 2-phase transfers (amount=maxInt)" { + try check( + \\ account A1 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ account A2 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ commit create_accounts + + // Posting maxInt(u128) is a pun – it is interpreted as "send full pending amount", which in + // this case is exactly maxInt(u127). + \\ transfer T1 A1 A2 -0 _ _ _ _ _ L1 C1 _ PEN _ _ _ _ _ _ _ _ _ created + \\ transfer T2 A1 A2 -0 T1 _ _ _ _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ created + \\ transfer T2 A1 A2 -0 T1 _ _ _ _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ exists + \\ commit create_transfers + + // Check balances after resolving. + \\ lookup_account A1 0 -0 0 0 _ + \\ lookup_account A2 0 0 0 -0 _ + \\ commit lookup_accounts + \\ + \\ lookup_transfer T1 amount -0 + \\ lookup_transfer T2 amount -0 + \\ commit lookup_transfers + ); +} + +test "create/lookup expired transfers" { + try check( + \\ account A1 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ account A2 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ commit create_accounts + + // First phase. + \\ transfer T1 A1 A2 10 _ _ _ _ 0 L1 C1 _ PEN _ _ _ _ _ _ _ _ _ created // Timeout zero will never expire. + \\ transfer T2 A1 A2 11 _ _ _ _ 1 L1 C1 _ PEN _ _ _ _ _ _ _ _ _ created + \\ transfer T3 A1 A2 12 _ _ _ _ 2 L1 C1 _ PEN _ _ _ _ _ _ _ _ _ created + \\ transfer T4 A1 A2 13 _ _ _ _ 3 L1 C1 _ PEN _ _ _ _ _ _ _ _ _ created + \\ commit create_transfers + + // Check balances before expiration. + \\ lookup_account A1 46 0 0 0 _ + \\ lookup_account A2 0 0 46 0 _ + \\ commit lookup_accounts + + // Check balances after 1s. + \\ tick 1 seconds + \\ lookup_account A1 35 0 0 0 _ + \\ lookup_account A2 0 0 35 0 _ + \\ commit lookup_accounts + + // Check balances after 1s. + \\ tick 1 seconds + \\ lookup_account A1 23 0 0 0 _ + \\ lookup_account A2 0 0 23 0 _ + \\ commit lookup_accounts + + // Check balances after 1s. + \\ tick 1 seconds + \\ lookup_account A1 10 0 0 0 _ + \\ lookup_account A2 0 0 10 0 _ + \\ commit lookup_accounts + + // Second phase. + \\ transfer T101 A1 A2 10 T1 U1 U1 U1 _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ created + \\ transfer T102 A1 A2 11 T2 U1 U1 U1 _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ pending_transfer_expired + \\ transfer T103 A1 A2 12 T3 U1 U1 U1 _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ pending_transfer_expired + \\ transfer T104 A1 A2 13 T4 U1 U1 U1 _ L1 C1 _ _ POS _ _ _ _ _ _ _ _ pending_transfer_expired + \\ commit create_transfers + + // Check final balances. + \\ lookup_account A1 0 10 0 0 _ + \\ lookup_account A2 0 0 0 10 _ + \\ commit lookup_accounts + + // Check transfers. + \\ lookup_transfer T101 exists true + \\ lookup_transfer T102 exists false + \\ lookup_transfer T103 exists false + \\ lookup_transfer T104 exists false + \\ commit lookup_transfers + ); +} + +test "create_transfers: empty" { + try check( + \\ commit create_transfers + ); +} + +test "create_transfers/lookup_transfers: failed transfer does not exist" { + try check( + \\ account A1 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ account A2 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ commit create_accounts + \\ + \\ transfer T1 A1 A2 15 _ _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ _ _ _ created + \\ transfer T2 A1 A2 15 _ _ _ _ _ L0 C1 _ _ _ _ _ _ _ _ _ _ _ ledger_must_not_be_zero + \\ commit create_transfers + \\ + \\ lookup_account A1 0 15 0 0 _ + \\ lookup_account A2 0 0 0 15 _ + \\ commit lookup_accounts + \\ + \\ lookup_transfer T1 exists true + \\ lookup_transfer T2 exists false + \\ commit lookup_transfers + ); +} + +test "create_transfers: failed linked-chains are undone" { + try check( + \\ account A1 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ account A2 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ commit create_accounts + \\ + \\ transfer T1 A1 A2 15 _ _ _ _ _ L1 C1 LNK _ _ _ _ _ _ _ _ _ _ linked_event_failed + \\ transfer T2 A1 A2 15 _ _ _ _ _ L0 C1 _ _ _ _ _ _ _ _ _ _ _ ledger_must_not_be_zero + \\ commit create_transfers + \\ + \\ transfer T3 A1 A2 15 _ _ _ _ 1 L1 C1 LNK PEN _ _ _ _ _ _ _ _ _ linked_event_failed + \\ transfer T4 A1 A2 15 _ _ _ _ _ L0 C1 _ _ _ _ _ _ _ _ _ _ _ ledger_must_not_be_zero + \\ commit create_transfers + \\ + \\ lookup_account A1 0 0 0 0 _ + \\ lookup_account A2 0 0 0 0 _ + \\ commit lookup_accounts + \\ + \\ lookup_transfer T1 exists false + \\ lookup_transfer T2 exists false + \\ lookup_transfer T3 exists false + \\ lookup_transfer T4 exists false + \\ commit lookup_transfers + ); +} + +test "create_transfers: failed linked-chains are undone within a commit" { + try check( + \\ account A1 0 0 0 0 _ _ _ _ L1 C1 _ D0. + \\ get_account_transfers A1 _ _ _ _ T3 _ 10 DR CR _ + \\ get_account_transfers_result T3 + \\ get_account_transfers_result T4 + \\ commit get_account_transfers + \\ + // Debits + credits, timestamp_max>0. + \\ get_account_transfers A1 _ _ _ _ _ T2 10 DR CR _ + \\ get_account_transfers_result T1 + \\ get_account_transfers_result T2 + \\ commit get_account_transfers + \\ + // Debits + credits, 0 < timestamp_min ≤ timestamp_max. + \\ get_account_transfers A1 _ _ _ _ T2 T3 10 DR CR _ + \\ get_account_transfers_result T2 + \\ get_account_transfers_result T3 + \\ commit get_account_transfers + \\ + // Debits + credits, reverse-chronological. + \\ get_account_transfers A1 _ _ _ _ _ _ 10 DR CR REV + \\ get_account_transfers_result T4 + \\ get_account_transfers_result T3 + \\ get_account_transfers_result T2 + \\ get_account_transfers_result T1 + \\ commit get_account_transfers + \\ + // Debits only. + \\ get_account_transfers A1 _ _ _ _ _ _ 10 DR _ _ + \\ get_account_transfers_result T1 + \\ get_account_transfers_result T3 + \\ commit get_account_transfers + \\ + // Credits only. + \\ get_account_transfers A1 _ _ _ _ _ _ 10 _ CR _ + \\ get_account_transfers_result T2 + \\ get_account_transfers_result T4 + \\ commit get_account_transfers + \\ + // Debits + credits + user_data_128, chronological. + \\ get_account_transfers A1 U1001 _ _ _ _ _ 10 DR CR _ + \\ get_account_transfers_result T2 + \\ get_account_transfers_result T4 + \\ commit get_account_transfers + \\ + // Debits + credits + user_data_64, chronological. + \\ get_account_transfers A1 _ U10 _ _ _ _ 10 DR CR _ + \\ get_account_transfers_result T1 + \\ get_account_transfers_result T2 + \\ commit get_account_transfers + \\ + // Debits + credits + user_data_32, chronological. + \\ get_account_transfers A1 _ _ U1 _ _ _ 10 DR CR _ + \\ get_account_transfers_result T1 + \\ get_account_transfers_result T4 + \\ commit get_account_transfers + \\ + // Debits + credits + code, chronological. + \\ get_account_transfers A1 _ _ _ C1 _ _ 10 DR CR _ + \\ get_account_transfers_result T1 + \\ get_account_transfers_result T3 + \\ commit get_account_transfers + \\ + // Debits + credits + all filters, 0 < timestamp_min ≤ timestamp_max, chronological. + \\ get_account_transfers A1 U1000 U10 U1 C1 T1 T3 10 DR CR _ + \\ get_account_transfers_result T1 + \\ commit get_account_transfers + \\ + // Debits only + all filters, 0 < timestamp_min ≤ timestamp_max, chronological. + \\ get_account_transfers A1 U1000 U10 U1 C1 T1 T3 10 DR _ _ + \\ get_account_transfers_result T1 + \\ commit get_account_transfers + \\ + // Credits only + all filters, 0 < timestamp_min ≤ timestamp_max, chronological. + \\ get_account_transfers A2 U1000 U10 U1 C1 T1 T3 10 _ CR _ + \\ get_account_transfers_result T1 + \\ commit get_account_transfers + \\ + // Not found. + \\ get_account_transfers A1 U1000 U20 U2 C2 _ _ 10 DR CR _ + \\ commit get_account_transfers + ); +} + +test "get_account_transfers: two-phase" { + try check( + \\ account A1 0 0 0 0 _ _ _ _ L1 C1 _ _ _ HIST _ _ _ _ created + \\ account A2 0 0 0 0 _ _ _ _ L1 C1 _ _ _ HIST _ _ _ _ created + \\ commit create_accounts + \\ + \\ transfer T1 A1 A2 2 _ _ _ _ 0 L1 C1 _ PEN _ _ _ _ _ _ _ _ _ created + \\ transfer T2 A1 A2 1 T1 _ _ _ 0 L1 C1 _ _ POS _ _ _ _ _ _ _ _ created + \\ commit create_transfers + \\ + \\ get_account_transfers A1 _ _ _ _ _ _ 10 DR CR _ + \\ get_account_transfers_result T1 + \\ get_account_transfers_result T2 + \\ commit get_account_transfers + ); +} + +test "get_account_transfers: invalid filter" { + try check( + \\ account A1 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ account A2 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ commit create_accounts + \\ + \\ transfer T1 A1 A2 2 _ _ _ _ 0 L1 C1 _ PEN _ _ _ _ _ _ _ _ _ created + \\ transfer T2 A1 A2 1 T1 _ _ _ 0 L1 C1 _ _ POS _ _ _ _ _ _ _ _ created + \\ commit create_transfers + \\ + // Invalid account. + \\ get_account_transfers A3 _ _ _ _ _ _ 10 DR CR _ + \\ commit get_account_transfers // Empty result. + \\ + // Invalid filter flags. + \\ get_account_transfers A1 _ _ _ _ _ _ 10 _ _ _ + \\ commit get_account_transfers // Empty result. + \\ + // Invalid timestamp_min > timestamp_max. + \\ get_account_transfers A1 _ _ _ _ T2 T1 10 DR CR _ + \\ commit get_account_transfers // Empty result. + \\ + // Invalid limit. + \\ get_account_transfers A1 _ _ _ _ _ _ 0 DR CR _ + \\ commit get_account_transfers // Empty result. + \\ + // Success. + \\ get_account_transfers A1 _ _ _ C1 _ _ 10 DR CR _ + \\ get_account_transfers_result T1 + \\ get_account_transfers_result T2 + \\ commit get_account_transfers + ); +} + +test "get_account_balances: single-phase" { + try check( + \\ account A1 0 0 0 0 _ _ _ _ L1 C1 _ _ _ HIST _ _ _ _ created + \\ account A2 0 0 0 0 _ _ _ _ L1 C1 _ _ _ HIST _ _ _ _ created + \\ commit create_accounts + \\ + \\ transfer T1 A1 A2 10 _ U1000 U10 U1 _ L1 C1 _ _ _ _ _ _ _ _ _ _ _ created + \\ transfer T2 A2 A1 11 _ U1001 U10 U2 _ L1 C2 _ _ _ _ _ _ _ _ _ _ _ created + \\ transfer T3 A1 A2 12 _ U1000 U20 U2 _ L1 C1 _ _ _ _ _ _ _ _ _ _ _ created + \\ transfer T4 A2 A1 13 _ U1001 U20 U1 _ L1 C2 _ _ _ _ _ _ _ _ _ _ _ created + \\ commit create_transfers + \\ + // Debits + credits, chronological. + \\ get_account_balances A1 _ _ _ _ _ _ 10 DR CR _ + \\ get_account_balances_result T1 0 10 0 0 + \\ get_account_balances_result T2 0 10 0 11 + \\ get_account_balances_result T3 0 22 0 11 + \\ get_account_balances_result T4 0 22 0 24 + \\ commit get_account_balances + \\ + // Debits + credits, limit=2. + \\ get_account_balances A1 _ _ _ _ _ _ 2 DR CR _ + \\ get_account_balances_result T1 0 10 0 0 + \\ get_account_balances_result T2 0 10 0 11 + \\ commit get_account_balances + \\ + // Debits + credits, timestamp_min>0. + \\ get_account_balances A1 _ _ _ _ T3 _ 10 DR CR _ + \\ get_account_balances_result T3 0 22 0 11 + \\ get_account_balances_result T4 0 22 0 24 + \\ commit get_account_balances + \\ + // Debits + credits, timestamp_max>0. + \\ get_account_balances A1 _ _ _ _ _ T2 10 DR CR _ + \\ get_account_balances_result T1 0 10 0 0 + \\ get_account_balances_result T2 0 10 0 11 + \\ commit get_account_balances + \\ + // Debits + credits, 0 < timestamp_min ≤ timestamp_max. + \\ get_account_balances A1 _ _ _ _ T2 T3 10 DR CR _ + \\ get_account_balances_result T2 0 10 0 11 + \\ get_account_balances_result T3 0 22 0 11 + \\ commit get_account_balances + \\ + // Debits + credits, reverse-chronological. + \\ get_account_balances A1 _ _ _ _ _ _ 10 DR CR REV + \\ get_account_balances_result T4 0 22 0 24 + \\ get_account_balances_result T3 0 22 0 11 + \\ get_account_balances_result T2 0 10 0 11 + \\ get_account_balances_result T1 0 10 0 0 + \\ commit get_account_balances + \\ + // Debits only. + \\ get_account_balances A1 _ _ _ _ _ _ 10 DR _ _ + \\ get_account_balances_result T1 0 10 0 0 + \\ get_account_balances_result T3 0 22 0 11 + \\ commit get_account_balances + \\ + // Credits only. + \\ get_account_balances A1 _ _ _ _ _ _ 10 _ CR _ + \\ get_account_balances_result T2 0 10 0 11 + \\ get_account_balances_result T4 0 22 0 24 + \\ commit get_account_balances + \\ + // Debits + credits + user_data_128, chronological. + \\ get_account_balances A1 U1001 _ _ _ _ _ 10 DR CR _ + \\ get_account_balances_result T2 0 10 0 11 + \\ get_account_balances_result T4 0 22 0 24 + \\ commit get_account_balances + \\ + // Debits + credits + user_data_64, chronological. + \\ get_account_balances A1 _ U10 _ _ _ _ 10 DR CR _ + \\ get_account_balances_result T1 0 10 0 0 + \\ get_account_balances_result T2 0 10 0 11 + \\ commit get_account_balances + \\ + // Debits + credits + user_data_32, chronological. + \\ get_account_balances A1 _ _ U1 _ _ _ 10 DR CR _ + \\ get_account_balances_result T1 0 10 0 0 + \\ get_account_balances_result T4 0 22 0 24 + \\ commit get_account_balances + \\ + // Debits + credits + code, chronological. + \\ get_account_balances A1 _ _ _ C1 _ _ 10 DR CR _ + \\ get_account_balances_result T1 0 10 0 0 + \\ get_account_balances_result T3 0 22 0 11 + \\ commit get_account_balances + \\ + // Debits + credits + all filters, 0 < timestamp_min ≤ timestamp_max, chronological. + \\ get_account_balances A1 U1000 U10 U1 C1 T1 T3 10 DR CR _ + \\ get_account_balances_result T1 0 10 0 0 + \\ commit get_account_balances + \\ + // Debits only + all filters, 0 < timestamp_min ≤ timestamp_max, chronological. + \\ get_account_balances A1 U1000 U10 U1 C1 T1 T3 10 DR _ _ + \\ get_account_balances_result T1 0 10 0 0 + \\ commit get_account_balances + \\ + // Credits only + all filters, 0 < timestamp_min ≤ timestamp_max, chronological. + \\ get_account_balances A2 U1000 U10 U1 C1 T1 T3 10 _ CR _ + \\ get_account_balances_result T1 0 0 0 10 + \\ commit get_account_balances + \\ + // Not found. + \\ get_account_balances A1 U1000 U20 U2 C2 _ _ 10 DR CR _ + \\ commit get_account_balances + ); +} + +test "get_account_balances: two-phase" { + try check( + \\ account A1 0 0 0 0 _ _ _ _ L1 C1 _ _ _ HIST _ _ _ _ created + \\ account A2 0 0 0 0 _ _ _ _ L1 C1 _ _ _ HIST _ _ _ _ created + \\ commit create_accounts + \\ + \\ transfer T1 A1 A2 1 _ _ _ _ 0 L1 C1 _ PEN _ _ _ _ _ _ _ _ _ created + \\ transfer T2 A1 A2 1 T1 _ _ _ 0 L1 C1 _ _ POS _ _ _ _ _ _ _ _ created + \\ commit create_transfers + \\ + \\ get_account_balances A1 _ _ _ _ _ _ 10 DR CR _ + \\ get_account_balances_result T1 1 0 0 0 + \\ get_account_balances_result T2 0 1 0 0 + \\ commit get_account_balances + ); +} + +test "get_account_balances: invalid filter" { + try check( + \\ account A1 0 0 0 0 _ _ _ _ L1 C1 _ _ _ HIST _ _ _ _ created + \\ account A2 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ commit create_accounts + \\ + \\ transfer T1 A1 A2 2 _ _ _ _ 0 L1 C1 _ _ _ _ _ _ _ _ _ _ _ created + \\ transfer T2 A1 A2 1 _ _ _ _ 0 L1 C1 _ _ _ _ _ _ _ _ _ _ _ created + \\ commit create_transfers + \\ + // Invalid account. + \\ get_account_balances A3 _ _ _ _ _ _ 10 DR CR _ + \\ commit get_account_balances // Empty result. + \\ + // Account without flags.history. + \\ get_account_balances A2 _ _ _ _ _ _ 10 DR CR _ + \\ commit get_account_balances // Empty result. + \\ + // Invalid filter flags. + \\ get_account_balances A1 _ _ _ _ _ _ 10 _ _ _ + \\ commit get_account_balances // Empty result. + \\ + // Invalid timestamp_min > timestamp_max. + \\ get_account_balances A1 _ _ _ _ T2 T1 10 DR CR _ + \\ commit get_account_balances // Empty result. + \\ + // Invalid limit. + \\ get_account_balances A1 _ _ _ _ _ _ 0 DR CR _ + \\ commit get_account_balances // Empty result. + \\ + // Success. + \\ get_account_balances A1 _ _ _ C1 _ _ 10 DR CR _ + \\ get_account_balances_result T1 0 2 0 0 + \\ get_account_balances_result T2 0 3 0 0 + \\ commit get_account_balances + ); +} + +test "query_accounts" { + try check( + \\ account A1 0 0 0 0 U1000 U10 U1 _ L1 C1 _ _ _ _ _ _ _ _ created + \\ account A2 0 0 0 0 U1000 U11 U2 _ L2 C2 _ _ _ _ _ _ _ _ created + \\ account A3 0 0 0 0 U1000 U10 U3 _ L3 C3 _ _ _ _ _ _ _ _ created + \\ account A4 0 0 0 0 U1000 U11 U4 _ L4 C4 _ _ _ _ _ _ _ _ created + \\ account A5 0 0 0 0 U2000 U10 U1 _ L3 C5 _ _ _ _ _ _ _ _ created + \\ account A6 0 0 0 0 U2000 U11 U2 _ L2 C6 _ _ _ _ _ _ _ _ created + \\ account A7 0 0 0 0 U2000 U10 U3 _ L1 C7 _ _ _ _ _ _ _ _ created + \\ account A8 0 0 0 0 U1000 U10 U1 _ L1 C1 _ _ _ _ _ _ _ _ created + \\ commit create_accounts + + // WHERE user_data_128=1000: + \\ query_accounts U1000 U0 U0 L0 C0 _ _ L-0 _ + \\ query_accounts_result A1 _ + \\ query_accounts_result A2 _ + \\ query_accounts_result A3 _ + \\ query_accounts_result A4 _ + \\ query_accounts_result A8 _ + \\ commit query_accounts + + // WHERE user_data_128=1000 ORDER BY DESC: + \\ query_accounts U1000 U0 U0 L0 C0 _ _ L-0 REV + \\ query_accounts_result A8 _ + \\ query_accounts_result A4 _ + \\ query_accounts_result A3 _ + \\ query_accounts_result A2 _ + \\ query_accounts_result A1 _ + \\ commit query_accounts + + // WHERE user_data_64=10 AND user_data_32=3 + \\ query_accounts U0 U10 U3 L0 C0 _ _ L-0 _ + \\ query_accounts_result A3 _ + \\ query_accounts_result A7 _ + \\ commit query_accounts + + // WHERE user_data_64=10 AND user_data_32=3 ORDER BY DESC: + \\ query_accounts U0 U10 U3 L0 C0 _ _ L-0 REV + \\ query_accounts_result A7 _ + \\ query_accounts_result A3 _ + \\ commit query_accounts + + // WHERE user_data_64=11 AND user_data_32=2 AND code=2: + \\ query_accounts U0 U11 U2 L2 C0 _ _ L-0 _ + \\ query_accounts_result A2 _ + \\ query_accounts_result A6 _ + \\ commit query_accounts + + // WHERE user_data_64=11 AND user_data_32=2 AND code=2 ORDER BY DESC: + \\ query_accounts U0 U11 U2 L2 C0 _ _ L-0 REV + \\ query_accounts_result A6 _ + \\ query_accounts_result A2 _ + \\ commit query_accounts + + // WHERE user_data_128=1000 AND user_data_64=10 + // AND user_data_32=1 AND ledger=1 AND code=1: + \\ query_accounts U1000 U10 U1 L1 C1 _ _ L-0 _ + \\ query_accounts_result A1 _ + \\ query_accounts_result A8 _ + \\ commit query_accounts + + // WHERE user_data_128=1000 AND user_data_64=10 + // AND user_data_32=1 AND ledger=1 AND code=1 ORDER BY DESC: + \\ query_accounts U1000 U10 U1 L1 C1 _ _ L-0 REV + \\ query_accounts_result A8 _ + \\ query_accounts_result A1 _ + \\ commit query_accounts + + // WHERE user_data_128=1000 AND timestamp >= A3.timestamp: + \\ query_accounts U1000 U0 U0 L0 C0 A3 _ L-0 _ + \\ query_accounts_result A3 _ + \\ query_accounts_result A4 _ + \\ query_accounts_result A8 _ + \\ commit query_accounts + + // WHERE user_data_128=1000 AND timestamp <= A3.timestamp: + \\ query_accounts U1000 U0 U0 L0 C0 _ A3 L-0 _ + \\ query_accounts_result A1 _ + \\ query_accounts_result A2 _ + \\ query_accounts_result A3 _ + \\ commit query_accounts + + // WHERE user_data_128=1000 AND timestamp BETWEEN A2.timestamp AND A4.timestamp: + \\ query_accounts U1000 U0 U0 L0 C0 A2 A4 L-0 _ + \\ query_accounts_result A2 _ + \\ query_accounts_result A3 _ + \\ query_accounts_result A4 _ + \\ commit query_accounts + + // SELECT * : + \\ query_accounts U0 U0 U0 L0 C0 _ _ L-0 _ + \\ query_accounts_result A1 _ + \\ query_accounts_result A2 _ + \\ query_accounts_result A3 _ + \\ query_accounts_result A4 _ + \\ query_accounts_result A5 _ + \\ query_accounts_result A6 _ + \\ query_accounts_result A7 _ + \\ query_accounts_result A8 _ + \\ commit query_accounts + + // SELECT * ORDER BY DESC: + \\ query_accounts U0 U0 U0 L0 C0 _ _ L-0 REV + \\ query_accounts_result A8 _ + \\ query_accounts_result A7 _ + \\ query_accounts_result A6 _ + \\ query_accounts_result A5 _ + \\ query_accounts_result A4 _ + \\ query_accounts_result A3 _ + \\ query_accounts_result A2 _ + \\ query_accounts_result A1 _ + \\ commit query_accounts + + // SELECT * WHERE timestamp >= A2.timestamp LIMIT 3: + \\ query_accounts U0 U0 U0 L0 C0 A2 _ L3 _ + \\ query_accounts_result A2 _ + \\ query_accounts_result A3 _ + \\ query_accounts_result A4 _ + \\ commit query_accounts + + // SELECT * LIMIT 1: + \\ query_accounts U0 U0 U0 L0 C0 _ _ L1 _ + \\ query_accounts_result A1 _ + \\ commit query_accounts + + // SELECT * ORDER BY DESC LIMIT 1: + \\ query_accounts U0 U0 U0 L0 C0 _ _ L1 REV + \\ query_accounts_result A8 _ + \\ commit query_accounts + + // NOT FOUND: + + // SELECT * LIMIT 0: + \\ query_accounts U0 U0 U0 L0 C0 _ _ L0 _ + \\ commit query_accounts + + // WHERE user_data_128=3000 + \\ query_accounts U3000 U0 U0 L0 C0 _ _ L-0 _ + \\ commit query_accounts + + // WHERE user_data_128=1000 AND code=5 + \\ query_accounts U1000 U0 U0 L0 C5 _ _ L-0 _ + \\ commit query_accounts + + // WHERE user_data_128=1000 AND user_data_64=10 + // AND user_data_32=1 AND ledger=1 AND code=2: + \\ query_accounts U1000 U10 U1 L1 C2 _ _ L-0 _ + \\ commit query_accounts + + // WHERE user_data_128=1000 AND timestamp BETWEEN A5.timestamp AND A7.timestamp: + \\ query_accounts U1000 U0 U0 L0 C0 A5 A7 L-0 _ + \\ commit query_accounts + ); +} + +test "query_transfers" { + try check( + \\ account A1 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ account A2 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ account A3 0 0 0 0 _ _ _ _ L2 C1 _ _ _ _ _ _ _ _ created + \\ account A4 0 0 0 0 _ _ _ _ L2 C1 _ _ _ _ _ _ _ _ created + \\ commit create_accounts + + // Creating transfers: + \\ transfer T1 A1 A2 0 _ U1000 U10 U1 _ L1 C1 _ _ _ _ _ _ _ _ _ _ _ created + \\ transfer T2 A3 A4 11 _ U1000 U11 U2 _ L2 C2 _ _ _ _ _ _ _ _ _ _ _ created + \\ transfer T3 A2 A1 12 _ U1000 U10 U3 _ L1 C3 _ _ _ _ _ _ _ _ _ _ _ created + \\ transfer T4 A4 A3 13 _ U1000 U11 U4 _ L2 C4 _ _ _ _ _ _ _ _ _ _ _ created + \\ transfer T5 A2 A1 14 _ U2000 U10 U1 _ L1 C5 _ _ _ _ _ _ _ _ _ _ _ created + \\ transfer T6 A4 A3 15 _ U2000 U11 U2 _ L2 C6 _ _ _ _ _ _ _ _ _ _ _ created + \\ transfer T7 A1 A2 16 _ U2000 U10 U3 _ L1 C7 _ _ _ _ _ _ _ _ _ _ _ created + \\ transfer T8 A2 A1 17 _ U1000 U10 U1 _ L1 C1 _ _ _ _ _ _ _ _ _ _ _ created + \\ commit create_transfers + + // WHERE user_data_128=1000: + \\ query_transfers U1000 U0 U0 L0 C0 _ _ L-0 _ + \\ query_transfers_result T1 + \\ query_transfers_result T2 + \\ query_transfers_result T3 + \\ query_transfers_result T4 + \\ query_transfers_result T8 + \\ commit query_transfers + + // WHERE user_data_128=1000 ORDER BY DESC: + \\ query_transfers U1000 U0 U0 L0 C0 _ _ L-0 REV + \\ query_transfers_result T8 + \\ query_transfers_result T4 + \\ query_transfers_result T3 + \\ query_transfers_result T2 + \\ query_transfers_result T1 + \\ commit query_transfers + + // WHERE user_data_64=10 AND user_data_32=3 + \\ query_transfers U0 U10 U3 L0 C0 _ _ L-0 _ + \\ query_transfers_result T3 + \\ query_transfers_result T7 + \\ commit query_transfers + + // WHERE user_data_64=10 AND user_data_32=3 ORDER BY DESC: + \\ query_transfers U0 U10 U3 L0 C0 _ _ L-0 REV + \\ query_transfers_result T7 + \\ query_transfers_result T3 + \\ commit query_transfers + + // WHERE user_data_64=11 AND user_data_32=2 AND code=2: + \\ query_transfers U0 U11 U2 L2 C0 _ _ L-0 _ + \\ query_transfers_result T2 + \\ query_transfers_result T6 + \\ commit query_transfers + + // WHERE user_data_64=11 AND user_data_32=2 AND code=2 ORDER BY DESC: + \\ query_transfers U0 U11 U2 L2 C0 _ _ L-0 REV + \\ query_transfers_result T6 + \\ query_transfers_result T2 + \\ commit query_transfers + + // WHERE user_data_128=1000 AND user_data_64=10 + // AND user_data_32=1 AND ledger=1 AND code=1: + \\ query_transfers U1000 U10 U1 L1 C1 _ _ L-0 _ + \\ query_transfers_result T1 + \\ query_transfers_result T8 + \\ commit query_transfers + + // WHERE user_data_128=1000 AND user_data_64=10 + // AND user_data_32=1 AND ledger=1 AND code=1 ORDER BY DESC: + \\ query_transfers U1000 U10 U1 L1 C1 _ _ L-0 REV + \\ query_transfers_result T8 + \\ query_transfers_result T1 + \\ commit query_transfers + + // WHERE user_data_128=1000 AND timestamp >= T3.timestamp: + \\ query_transfers U1000 U0 U0 L0 C0 A3 _ L-0 _ + \\ query_transfers_result T3 + \\ query_transfers_result T4 + \\ query_transfers_result T8 + \\ commit query_transfers + + // WHERE user_data_128=1000 AND timestamp <= T3.timestamp: + \\ query_transfers U1000 U0 U0 L0 C0 _ A3 L-0 _ + \\ query_transfers_result T1 + \\ query_transfers_result T2 + \\ query_transfers_result T3 + \\ commit query_transfers + + // WHERE user_data_128=1000 AND timestamp BETWEEN T2.timestamp AND T4.timestamp: + \\ query_transfers U1000 U0 U0 L0 C0 A2 A4 L-0 _ + \\ query_transfers_result T2 + \\ query_transfers_result T3 + \\ query_transfers_result T4 + \\ commit query_transfers + + // SELECT * : + \\ query_transfers U0 U0 U0 L0 C0 _ _ L-0 _ + \\ query_transfers_result T1 + \\ query_transfers_result T2 + \\ query_transfers_result T3 + \\ query_transfers_result T4 + \\ query_transfers_result T5 + \\ query_transfers_result T6 + \\ query_transfers_result T7 + \\ query_transfers_result T8 + \\ commit query_transfers + + // SELECT * ORDER BY DESC: + \\ query_transfers U0 U0 U0 L0 C0 _ _ L-0 REV + \\ query_transfers_result T8 + \\ query_transfers_result T7 + \\ query_transfers_result T6 + \\ query_transfers_result T5 + \\ query_transfers_result T4 + \\ query_transfers_result T3 + \\ query_transfers_result T2 + \\ query_transfers_result T1 + \\ commit query_transfers + + // SELECT * WHERE timestamp >= A2.timestamp LIMIT 3: + \\ query_transfers U0 U0 U0 L0 C0 A2 _ L3 _ + \\ query_transfers_result T2 + \\ query_transfers_result T3 + \\ query_transfers_result T4 + \\ commit query_transfers + + // SELECT * LIMIT 1: + \\ query_transfers U0 U0 U0 L0 C0 _ _ L1 _ + \\ query_transfers_result T1 + \\ commit query_transfers + + // SELECT * ORDER BY DESC LIMIT 1: + \\ query_transfers U0 U0 U0 L0 C0 _ _ L1 REV + \\ query_transfers_result T8 + \\ commit query_transfers + + // NOT FOUND: + + // SELECT * LIMIT 0: + \\ query_transfers U0 U0 U0 L0 C0 _ _ L0 _ + \\ commit query_transfers + + // WHERE user_data_128=3000 + \\ query_transfers U3000 U0 U0 L0 C0 _ _ L-0 _ + \\ commit query_transfers + + // WHERE user_data_128=1000 AND code=5 + \\ query_transfers U1000 U0 U0 L0 C5 _ _ L-0 _ + \\ commit query_transfers + + // WHERE user_data_128=1000 AND user_data_64=10 + // AND user_data_32=1 AND ledger=1 AND code=2: + \\ query_transfers U1000 U10 U1 L1 C2 _ _ L-0 _ + \\ commit query_transfers + + // WHERE user_data_128=1000 AND timestamp BETWEEN T5.timestamp AND T7.timestamp: + \\ query_transfers U1000 U0 U0 L0 C0 A5 A7 L-0 _ + \\ commit query_transfers + ); +} + +test "get_change_events" { + try check( + \\ account A1 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ account A2 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ account A3 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ account A4 0 0 0 0 _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ created + \\ commit create_accounts + + // First phase. + \\ transfer T1 A1 A2 10 _ _ _ _ _ L1 C1 _ _ _ _ _ _ _ _ _ _ _ created // Not pending. + \\ transfer T2 A1 A2 11 _ _ _ _ 0 L1 C1 _ PEN _ _ _ _ _ _ _ _ _ created // Timeout zero will never expire. + \\ transfer T3 A1 A2 12 _ _ _ _ 1 L1 C1 _ PEN _ _ _ _ _ _ _ _ _ created // Will expire. + \\ transfer T4 A1 A2 13 _ _ _ _ 2 L1 C1 _ PEN _ _ _ _ _ _ _ _ _ created // Will be posted. + \\ transfer T5 A1 A2 14 _ _ _ _ 2 L1 C1 _ PEN _ _ _ _ _ _ _ _ _ created // Will be voided. + // Closes the debit and credit accounts. + \\ transfer T6 A3 A1 0 _ _ _ _ 0 L1 C1 _ PEN _ _ _ _ _ CDR _ _ _ created + \\ transfer T7 A1 A4 0 _ _ _ _ 0 L1 C1 _ PEN _ _ _ _ _ _ CCR _ _ created + \\ commit create_transfers + + // Bump the state machine time in +1s for testing the timeout expiration. + \\ tick 1 seconds + + // Second phase. + \\ transfer T14 A0 A0 -0 T4 _ _ _ _ L0 C0 _ _ POS _ _ _ _ _ _ _ _ created // Posts T4. + \\ transfer T15 A0 A0 0 T5 _ _ _ _ L0 C0 _ _ _ VOI _ _ _ _ _ _ _ created // Voids T5. + // Reopens the debit and credit accounts. + \\ transfer T16 A0 A0 0 T6 _ _ _ _ L0 C0 _ _ _ VOI _ _ _ _ _ _ _ created + \\ transfer T17 A0 A0 0 T7 _ _ _ _ L0 C0 _ _ _ VOI _ _ _ _ _ _ _ created + \\ commit create_transfers + + // Check the events. + \\ get_change_events _ T6 5 + \\ get_change_events_result _ T1 10 _ D1 0 10 0 0 _ C2 0 0 0 10 _ + \\ get_change_events_result PEN T2 11 _ D1 11 10 0 0 _ C2 0 0 11 10 _ + \\ get_change_events_result PEN T3 12 _ D1 23 10 0 0 _ C2 0 0 23 10 _ + \\ get_change_events_result PEN T4 13 _ D1 36 10 0 0 _ C2 0 0 36 10 _ + \\ get_change_events_result PEN T5 14 _ D1 50 10 0 0 _ C2 0 0 50 10 _ + \\ commit get_change_events + \\ + \\ get_change_events T6 _ -0 + \\ get_change_events_result PEN T6 0 _ D3 0 0 0 0 CLSD A1 50 10 0 0 _ + \\ get_change_events_result PEN T7 0 _ D1 50 10 0 0 _ C4 0 0 0 0 CLSD + \\ get_change_events_result EXP _ 12 T3 D1 38 10 0 0 _ C2 0 0 38 10 _ + \\ get_change_events_result POS T14 13 T4 D1 25 23 0 0 _ C2 0 0 25 23 _ + \\ get_change_events_result VOI T15 14 T5 D1 11 23 0 0 _ C2 0 0 11 23 _ + \\ get_change_events_result VOI T16 0 T6 D3 0 0 0 0 _ C1 11 23 0 0 _ + \\ get_change_events_result VOI T17 0 T7 D1 11 23 0 0 _ C4 0 0 0 0 _ + \\ commit get_change_events + ); +} + +// Sanity test to check the maximum batch size. +// For a comprehensive test of all operations, see the `input_valid` test. +test "StateMachine: batch_elements_max" { + const Operation = vsr.tigerbeetle.Operation; + + const events_max: u32 = @divExact( + constants.message_body_size_max, + @max(@sizeOf(Account), @sizeOf(Transfer)), + ); + + // No multi-batch encode. + try testing.expectEqual(events_max, Operation.deprecated_create_accounts_unbatched.event_max( + constants.message_body_size_max, + )); + try testing.expectEqual(events_max, Operation.deprecated_lookup_accounts_unbatched.event_max( + constants.message_body_size_max, + )); + try testing.expectEqual(events_max, Operation.deprecated_create_transfers_unbatched.event_max( + constants.message_body_size_max, + )); + try testing.expectEqual(events_max, Operation.deprecated_lookup_transfers_unbatched.event_max( + constants.message_body_size_max, + )); + + // Multi-batch encoded (the size corresponding to one element is occupied by the trailer). + try testing.expectEqual(events_max - 1, Operation.create_accounts.event_max( + constants.message_body_size_max, + )); + try testing.expectEqual(events_max - 1, Operation.create_transfers.event_max( + constants.message_body_size_max, + )); + try testing.expectEqual(events_max - 1, Operation.lookup_accounts.event_max( + constants.message_body_size_max, + )); + try testing.expectEqual(events_max - 1, Operation.lookup_transfers.event_max( + constants.message_body_size_max, + )); +} + +// Tests the input validation logic for both multi-batch encoded messages and +// the former single-batch format. +test "StateMachine: input_valid" { + const allocator = std.testing.allocator; + const input = try allocator.alignedAlloc( + u8, + constants.cache_line_size, + 2 * constants.message_body_size_max, + ); + defer allocator.free(input); + + const build_input = struct { + fn build_input(buffer: []align(constants.cache_line_size) u8, options: struct { + operation: TestContext.StateMachine.Operation, + event_count: u32, + }) []align(constants.cache_line_size) const u8 { + const event_size = options.operation.event_size(); + const payload_size: u32 = options.event_count * event_size; + if (options.operation.is_multi_batch()) { + var body_encoder = vsr.multi_batch.MultiBatchEncoder.init(buffer, .{ + .element_size = event_size, + }); + assert(payload_size <= body_encoder.writable().?.len); + body_encoder.add(payload_size); + const bytes_written = body_encoder.finish(); + assert(bytes_written > 0); + return buffer[0..bytes_written]; + } + + return buffer[0..payload_size]; + } + }.build_input; + + var context: TestContext = undefined; + try context.init(std.testing.allocator); + defer context.deinit(std.testing.allocator); + + const operations = std.enums.values(TestContext.StateMachine.Operation); + for (operations) |operation| { + if (operation == .pulse) continue; + const event_size = operation.event_size(); + maybe(event_size == 0); + + const event_min: u32, const event_max: u32 = limits: { + if (event_size == 0) { + break :limits .{ 0, 0 }; + } + if (!operation.is_batchable()) { + break :limits .{ 1, 1 }; + } + break :limits .{ + 0, + operation.event_max(context.state_machine.batch_size_limit), + }; + }; + assert(event_min <= event_max); + + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(input, .{ + .event_count = 0, + .operation = operation, + }), + ) == (event_min == 0)); + if (event_size == 0) { + assert(event_min == 0); + assert(event_max == 0); + continue; + } + + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(input, .{ + .event_count = 1, + .operation = operation, + }), + )); + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(input, .{ + .event_count = event_max, + .operation = operation, + }), + )); + const too_much_data = build_input(input, .{ + .event_count = event_max + 1, + .operation = operation, + }); + if (too_much_data.len < constants.message_body_size_max) { + try std.testing.expect(!context.state_machine.input_valid( + operation, + too_much_data, + )); + } else { + // Don't test input larger than the message body limit, since input_valid() + // would panic on an assert. + } + } +} + +// Tests multi-batched query filters. +// Multi-batch filters are valid as long as the sum of `filter.limit` stays within the maximum +// number of results that can fit in the reply message. +test "StateMachine: query multi-batch input_valid" { + const allocator = std.testing.allocator; + const input = try allocator.alignedAlloc( + u8, + constants.cache_line_size, + 2 * constants.message_body_size_max, + ); + defer allocator.free(input); + + var context: TestContext = undefined; + try context.init(std.testing.allocator); + defer context.deinit(std.testing.allocator); + + const build_input = struct { + fn build_input( + operation: TestContext.StateMachine.Operation, + limits: []const u32, + buffer: []align(constants.cache_line_size) u8, + ) []align(constants.cache_line_size) const u8 { + switch (operation) { + .get_account_transfers, + .get_account_balances, + => { + var body_encoder = vsr.multi_batch.MultiBatchEncoder.init(buffer, .{ + .element_size = @sizeOf(AccountFilter), + }); + if (limits.len == 0) body_encoder.add(0) else for (limits) |limit| { + const batch: []u8 = body_encoder.writable().?; + const filter: *AccountFilter = @alignCast(std.mem.bytesAsValue( + AccountFilter, + batch[0..@sizeOf(AccountFilter)], + )); + filter.* = .{ + .account_id = 0, + .user_data_128 = 0, + .user_data_64 = 0, + .user_data_32 = 0, + .code = 0, + .timestamp_min = 0, + .timestamp_max = 0, + .limit = limit, + .flags = .{ + .debits = false, + .credits = false, + .reversed = false, + }, + }; + body_encoder.add(@sizeOf(AccountFilter)); + } + return buffer[0..body_encoder.finish()]; + }, + .query_accounts, + .query_transfers, + => { + var body_encoder = vsr.multi_batch.MultiBatchEncoder.init(buffer, .{ + .element_size = @sizeOf(QueryFilter), + }); + if (limits.len == 0) body_encoder.add(0) else for (limits) |limit| { + const batch: []u8 = body_encoder.writable().?; + const filter: *QueryFilter = @alignCast(std.mem.bytesAsValue( + QueryFilter, + batch[0..@sizeOf(QueryFilter)], + )); + filter.* = .{ + .user_data_128 = 0, + .user_data_64 = 0, + .user_data_32 = 0, + .code = 0, + .ledger = 0, + .timestamp_min = 0, + .timestamp_max = 0, + .limit = limit, + .flags = .{ + .reversed = false, + }, + }; + body_encoder.add(@sizeOf(QueryFilter)); + } + return buffer[0..body_encoder.finish()]; + }, + else => unreachable, + } + } + }.build_input; + + const operations = &[_]TestContext.StateMachine.Operation{ + .get_account_transfers, + .get_account_balances, + .query_accounts, + .query_transfers, + }; + + for (operations) |operation| { + const batch_max = operation.result_max(context.state_machine.batch_size_limit); + + // Valid inputs: + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(operation, &.{0}, input), + )); + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(operation, &.{ 0, 0 }, input), + )); + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(operation, &.{1}, input), + )); + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(operation, &.{ 1, 1, 1 }, input), + )); + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(operation, &.{batch_max}, input), + )); + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(operation, &.{ 0, batch_max }, input), + )); + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(operation, &.{ 0, 1, batch_max - 1 }, input), + )); + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(operation, &.{ 1, 1, batch_max - 2 }, input), + )); + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(operation, &.{ + @divFloor(batch_max, 2), + stdx.div_ceil(batch_max, 2), + }, input), + )); + try std.testing.expect(context.state_machine.input_valid( + operation, + build_input(operation, &.{std.math.maxInt(u32)}, input), + )); + + // Invalid inputs: + try std.testing.expect(!context.state_machine.input_valid( + operation, + build_input(operation, &.{}, input), + )); + try std.testing.expect(!context.state_machine.input_valid( + operation, + build_input(operation, &.{ 1, batch_max }, input), + )); + try std.testing.expect(!context.state_machine.input_valid( + operation, + build_input(operation, &.{ 1, std.math.maxInt(u32) }, input), + )); + try std.testing.expect(!context.state_machine.input_valid( + operation, + build_input(operation, &.{ batch_max, batch_max }, input), + )); + try std.testing.expect(!context.state_machine.input_valid( + operation, + build_input(operation, &.{ + @divFloor(batch_max, 2), + stdx.div_ceil(batch_max, 2), + 1, + }, input), + )); + } +} diff --git a/ocam/src/static_allocator.zig b/ocam/src/static_allocator.zig new file mode 100644 index 00000000..90d3b21d --- /dev/null +++ b/ocam/src/static_allocator.zig @@ -0,0 +1,82 @@ +//! An allocator wrapper which can be disabled at runtime. +//! We use this for allocating at startup and then +//! disable it to prevent accidental dynamic allocation at runtime. + +const std = @import("std"); +const assert = std.debug.assert; +const mem = std.mem; +const Alignment = mem.Alignment; + +const StaticAllocator = @This(); +parent_allocator: mem.Allocator, +state: State, + +const State = enum { + /// Allow `alloc` and `resize`. + /// (To make errdefer cleanup easier to write we also allow calling `free`, + /// in which case we switch state to `.deinit` and no longer allow `alloc` or `resize`.) + init, + /// Don't allow any calls. + static, + /// Allow `free` but not `alloc` and `resize`. + deinit, +}; + +pub fn init(parent_allocator: mem.Allocator) StaticAllocator { + return .{ + .parent_allocator = parent_allocator, + .state = .init, + }; +} + +pub fn deinit(self: *StaticAllocator) void { + self.* = undefined; +} + +pub fn transition_from_init_to_static(self: *StaticAllocator) void { + assert(self.state == .init); + self.state = .static; +} + +pub fn transition_from_static_to_deinit(self: *StaticAllocator) void { + assert(self.state == .static); + self.state = .deinit; +} + +pub fn allocator(self: *StaticAllocator) mem.Allocator { + return .{ + .ptr = self, + .vtable = &.{ + .alloc = alloc, + .resize = resize, + .remap = remap, + .free = free, + }, + }; +} + +fn alloc(ctx: *anyopaque, len: usize, ptr_align: Alignment, ret_addr: usize) ?[*]u8 { + const self: *StaticAllocator = @ptrCast(@alignCast(ctx)); + assert(self.state == .init); + return self.parent_allocator.rawAlloc(len, ptr_align, ret_addr); +} + +fn resize(ctx: *anyopaque, buf: []u8, buf_align: Alignment, new_len: usize, ret_addr: usize) bool { + const self: *StaticAllocator = @ptrCast(@alignCast(ctx)); + assert(self.state == .init); + return self.parent_allocator.rawResize(buf, buf_align, new_len, ret_addr); +} + +fn remap(ctx: *anyopaque, buf: []u8, buf_align: Alignment, new_len: usize, ret_addr: usize) ?[*]u8 { + const self: *StaticAllocator = @ptrCast(@alignCast(ctx)); + assert(self.state == .init); + return self.parent_allocator.rawRemap(buf, buf_align, new_len, ret_addr); +} + +fn free(ctx: *anyopaque, buf: []u8, buf_align: Alignment, ret_addr: usize) void { + const self: *StaticAllocator = @ptrCast(@alignCast(ctx)); + assert(self.state == .init or self.state == .deinit); + // Once you start freeing, you don't stop. + self.state = .deinit; + return self.parent_allocator.rawFree(buf, buf_align, ret_addr); +} diff --git a/ocam/src/stdx/bit_set.zig b/ocam/src/stdx/bit_set.zig new file mode 100644 index 00000000..d3a9d1c4 --- /dev/null +++ b/ocam/src/stdx/bit_set.zig @@ -0,0 +1,157 @@ +const stdx = @import("stdx.zig"); +const std = @import("std"); +const assert = std.debug.assert; + +/// Use a dynamic bitset for larger sizes. +pub fn BitSetType(comptime with_capacity: u9) type { + assert(with_capacity <= 256); + + return struct { + // While mathematically 0 and 1 are symmetric, we intentionally bias the API to use zeros + // default, as zero-initialization reduces binary size. + bits: Word = 0, + + pub const Word = for (.{ u8, u16, u32, u64, u128, u256 }) |w| { + if (@bitSizeOf(w) >= with_capacity) break w; + } else unreachable; + + const BitSet = @This(); + + pub fn is_set(bit_set: BitSet, index: usize) bool { + assert(index < bit_set.capacity()); + return bit_set.bits & bit(index) != 0; + } + + pub fn count(bit_set: BitSet) usize { + return @popCount(bit_set.bits); + } + + pub inline fn capacity(_: BitSet) usize { + return with_capacity; + } + + pub fn full(bit_set: BitSet) bool { + return bit_set.count() == bit_set.capacity(); + } + + pub fn empty(bit_set: BitSet) bool { + return bit_set.bits == 0; + } + + pub fn first_set(bit_set: BitSet) ?usize { + if (bit_set.bits == 0) return null; + return @ctz(bit_set.bits); + } + + pub fn first_unset(bit_set: BitSet) ?usize { + const result = @ctz(~bit_set.bits); + return if (result < bit_set.capacity()) result else null; + } + + pub fn set(bit_set: *BitSet, index: usize) void { + assert(index < bit_set.capacity()); + bit_set.bits |= bit(index); + } + + pub fn unset(bit_set: *BitSet, index: usize) void { + assert(index < bit_set.capacity()); + bit_set.bits &= ~bit(index); + } + + pub fn set_value(bit_set: *BitSet, index: usize, value: bool) void { + if (value) { + bit_set.set(index); + } else { + bit_set.unset(index); + } + } + + fn bit(index: usize) Word { + assert(index < with_capacity); + return @as(Word, 1) << @intCast(index); + } + + pub fn iterate(bit_set: BitSet) Iterator { + return .{ .bits_remain = bit_set.bits }; + } + + pub const Iterator = struct { + bits_remain: Word, + + pub fn next(it: *@This()) ?usize { + const result = @ctz(it.bits_remain); + if (result >= with_capacity) return null; + it.bits_remain &= it.bits_remain - 1; + return result; + } + }; + }; +} + +test BitSetType { + var prng = stdx.PRNG.from_seed_testing(); + inline for (.{ 0, 1, 8, 32, 65, 255, 256 }) |N| { + const BitSet = BitSetType(N); + + var set: BitSet = .{}; + var model = try std.DynamicBitSetUnmanaged.initEmpty(std.testing.allocator, N); + defer model.deinit(std.testing.allocator); + + for (0..1000) |_| { + switch (prng.enum_uniform(std.meta.DeclEnum(BitSet))) { + .Word => { + const bit_size = + comptime if (N == 0) 8 else @max(8, try std.math.ceilPowerOfTwo(u16, N)); + assert(BitSet.Word == std.meta.Int(.unsigned, bit_size)); + }, + .Iterator => {}, + .is_set => { + if (N > 0) { + const bit = prng.int_inclusive(usize, N - 1); + assert(set.is_set(bit) == model.isSet(bit)); + } + }, + .count => assert(set.count() == model.count()), + .capacity => assert(set.capacity() == N), + .full => assert(set.full() == (model.count() == N)), + .empty => assert(set.empty() == (model.count() == 0)), + .first_set => assert(set.first_set() == model.findFirstSet()), + .first_unset => { + var it = model.iterator(.{ .kind = .unset }); + assert(set.first_unset() == it.next()); + }, + .set => { + if (N > 0) { + const bit = prng.int_inclusive(usize, N - 1); + set.set(bit); + model.set(bit); + } + }, + .unset => { + if (N > 0) { + const bit = prng.int_inclusive(usize, N - 1); + set.unset(bit); + model.unset(bit); + } + }, + .set_value => { + if (N > 0) { + const bit = prng.int_inclusive(usize, N - 1); + const value = prng.boolean(); + set.set_value(bit, value); + model.setValue(bit, value); + } + }, + .iterate => { + var it_set = set.iterate(); + var it_model = model.iterator(.{}); + while (it_model.next()) |next| { + assert(next == it_set.next()); + } + assert(it_set.next() == null); + assert(it_set.next() == null); + }, + } + } + } +} diff --git a/ocam/src/stdx/bounded_array.zig b/ocam/src/stdx/bounded_array.zig new file mode 100644 index 00000000..f5d6d3a3 --- /dev/null +++ b/ocam/src/stdx/bounded_array.zig @@ -0,0 +1,292 @@ +const std = @import("std"); +const stdx = @import("stdx.zig"); +const assert = std.debug.assert; + +/// A version of standard `BoundedArray` with TigerBeetle-idiomatic APIs. +pub fn BoundedArrayType(comptime T: type, comptime buffer_capacity: usize) type { + return struct { + buffer: [buffer_capacity]T = undefined, + // Its not clear whether the best type of count is u32, usize, or u`log(buffer_capacity)`. + // Use an ugly internal name here and expose `pub fn count` as an API. + count_u32: u32 = 0, + + const BoundedArray = @This(); + + pub inline fn from_slice(items: []const T) error{Overflow}!BoundedArray { + if (items.len <= buffer_capacity) { + var result: BoundedArray = .{}; + result.push_slice(items); + return result; + } else { + return error.Overflow; + } + } + + pub inline fn count(array: *const BoundedArray) usize { + return array.count_u32; + } + + /// Returns count of elements in this BoundedArray in the specified integer types, + /// checking at compile time that it indeed can represent the length. + pub inline fn count_as(array: *const BoundedArray, comptime Int: type) Int { + comptime assert(buffer_capacity <= std.math.maxInt(Int)); + return @intCast(array.count_u32); + } + + pub inline fn full(array: BoundedArray) bool { + return array.count_u32 == buffer_capacity; + } + + pub inline fn empty(array: BoundedArray) bool { + return array.count_u32 == 0; + } + + pub inline fn get(array: *const BoundedArray, index: usize) T { + assert(index < array.count_u32); + return array.buffer[index]; + } + + pub inline fn slice(array: *BoundedArray) []T { + return array.buffer[0..array.count_u32]; + } + + pub inline fn const_slice(array: *const BoundedArray) []const T { + return array.buffer[0..array.count_u32]; + } + + pub inline fn unused_capacity_slice(array: *BoundedArray) []T { + return array.buffer[array.count_u32..]; + } + + pub fn insert_at(array: *BoundedArray, index: usize, item: T) void { + assert(!array.full()); + assert(index <= array.count_u32); + stdx.copy_right( + .exact, + T, + array.buffer[index + 1 .. array.count_u32 + 1], + array.buffer[index..array.count_u32], + ); + array.buffer[index] = item; + array.count_u32 += 1; + } + + pub fn push(array: *BoundedArray, item: T) void { + assert(!array.full()); + array.buffer[array.count_u32] = item; + array.count_u32 += 1; + } + + pub fn push_slice(array: *BoundedArray, items: []const T) void { + assert(array.count_u32 + items.len <= array.capacity()); + stdx.copy_disjoint(.inexact, T, array.buffer[array.count_u32..], items); + array.count_u32 += @intCast(items.len); + } + + pub inline fn swap_remove(array: *BoundedArray, index: usize) T { + assert(array.count_u32 > 0); + assert(index < array.count_u32); + const result = array.buffer[index]; + array.count_u32 -= 1; + array.buffer[index] = array.buffer[array.count_u32]; + return result; + } + + pub inline fn ordered_remove(array: *BoundedArray, index: usize) T { + assert(array.count_u32 > 0); + assert(index < array.count_u32); + const result = array.buffer[index]; + stdx.copy_left( + .exact, + T, + array.buffer[index .. array.count_u32 - 1], + array.buffer[index + 1 .. array.count_u32], + ); + array.count_u32 -= 1; + return result; + } + + pub fn resize(array: *BoundedArray, count_new: usize) error{Overflow}!void { + if (count_new <= buffer_capacity) { + array.count_u32 = @intCast(count_new); + } else { + return error.Overflow; + } + } + + pub inline fn truncate(array: *BoundedArray, count_new: usize) void { + assert(count_new <= array.count_u32); + array.count_u32 = @intCast(count_new); // can't overflow due to check above. + } + + pub inline fn clear(array: *BoundedArray) void { + array.count_u32 = 0; + } + + pub inline fn pop(array: *BoundedArray) ?T { + if (array.count_u32 == 0) return null; + array.count_u32 -= 1; + return array.buffer[array.count_u32]; + } + + pub inline fn capacity(_: *BoundedArray) usize { + return buffer_capacity; + } + }; +} + +test BoundedArrayType { + const capacity = 8; + const Array = BoundedArrayType(u8, capacity); + const Model = std.ArrayListUnmanaged(u8); + const swarm_count = 10; + const action_count = 1_000; + + const gpa = std.testing.allocator; + + var array: Array = .{}; + var model: Model = try .initCapacity(gpa, capacity); + defer model.deinit(gpa); + + var prng = stdx.PRNG.from_seed_testing(); + + for (0..swarm_count) |_| { + const swarm_weights = prng.enum_weights(std.meta.DeclEnum(Array)); + for (0..action_count) |_| { + const action = prng.enum_weighted(std.meta.DeclEnum(Array), swarm_weights); + switch (action) { + .count => assert(array.count() == model.items.len), + .count_as => assert(array.count_as(u8) == model.items.len), + .full => assert(array.full() == (model.unusedCapacitySlice().len == 0)), + .empty => assert(array.empty() == (model.items.len == 0)), + .get => { + if (model.items.len > 0) { + const index = prng.index(model.items); + assert(array.get(index) == model.items[index]); + } + }, + .slice => assert(std.mem.eql(u8, array.slice(), model.items)), + .const_slice => assert(std.mem.eql(u8, array.const_slice(), model.items)), + .unused_capacity_slice => { + assert(array.unused_capacity_slice().len == model.unusedCapacitySlice().len); + }, + .insert_at => { + if (model.items.len < model.capacity) { + const index = prng.int_inclusive(usize, model.items.len); + const value = prng.int(u8); + + array.insert_at(index, value); + model.insertAssumeCapacity(index, value); + } + }, + .push => { + if (model.items.len < model.capacity) { + const value = prng.int(u8); + + array.push(value); + model.appendAssumeCapacity(value); + } + }, + .push_slice => { + var buffer: [capacity]u8 = undefined; + const count = prng.int_inclusive(usize, model.capacity - model.items.len); + for (0..count) |index| buffer[index] = prng.int(u8); + const slice = buffer[0..count]; + + array.push_slice(slice); + model.appendSliceAssumeCapacity(slice); + }, + .swap_remove => { + if (model.items.len > 0) { + const index = prng.index(model.items); + + const a = array.swap_remove(index); + const b = model.swapRemove(index); + assert(a == b); + } + }, + .ordered_remove => { + if (model.items.len > 0) { + const index = prng.index(model.items); + + const a = array.ordered_remove(index); + const b = model.orderedRemove(index); + assert(a == b); + } + }, + .resize => { + const count_old = model.items.len; + const count_new = prng.int_inclusive(usize, capacity); + + model.resize(gpa, count_new) catch unreachable; + array.resize(count_new) catch unreachable; + if (count_old <= count_new) { + for (count_old..count_new) |index| { + const value = prng.int(u8); + model.items[index] = value; + array.buffer[index] = value; + } + } + }, + .truncate => { + const count_new = prng.int_inclusive(usize, model.items.len); + array.truncate(count_new); + model.resize(gpa, count_new) catch unreachable; + }, + .clear => { + array.clear(); + model.clearRetainingCapacity(); + }, + .pop => { + const b = model.pop(); + const a = array.pop(); + assert((a == null and b == null) or (a.? == b.?)); + }, + .capacity => assert(array.capacity() == model.capacity), + .from_slice => { + var buffer: [capacity]u8 = undefined; + const count = prng.int_inclusive(usize, model.capacity - model.items.len); + for (0..count) |index| buffer[index] = prng.int(u8); + const slice = buffer[0..count]; + + array = Array.from_slice(slice) catch unreachable; + model.clearRetainingCapacity(); + model.appendSliceAssumeCapacity(slice); + }, + } + } + } +} + +test "BoundedArray.insert_at" { + const items_max = 32; + const BoundedArrayU64 = BoundedArrayType(u64, items_max); + + // Test lists of every size (less than the capacity). + for (0..items_max) |len| { + var list_base = BoundedArrayU64{}; + for (0..len) |i| { + list_base.push(i); + } + + // Test an insert at every possible position (including an append). + for (0..list_base.count() + 1) |i| { + var list = list_base; + + list.insert_at(i, 12345); + + // Verify the result: + + try std.testing.expectEqual(list.count(), list_base.count() + 1); + try std.testing.expectEqual(list.get(i), 12345); + + for (0..i) |j| { + try std.testing.expectEqual(list.get(j), j); + } + + for (i + 1..list.count()) |j| { + try std.testing.expectEqual(list.get(j), j - 1); + } + } + } +} diff --git a/ocam/src/stdx/debug.zig b/ocam/src/stdx/debug.zig new file mode 100644 index 00000000..5b68dd10 --- /dev/null +++ b/ocam/src/stdx/debug.zig @@ -0,0 +1,65 @@ +const std = @import("std"); + +/// Utility function for ad-hoc profiling. +/// +/// A thin wrapper around `std.time.Timer` which handles the boilerplate of +/// printing to stderr and formatting times in some (unspecified) readable way. +pub fn timeit() TimeIt { + return TimeIt{ .inner = std.time.Timer.start() catch unreachable }; +} + +const TimeIt = struct { + inner: std.time.Timer, + + /// Prints elapsed time to stderr and resets the internal timer. + pub fn print(self: *TimeIt, comptime label: []const u8) void { + const label_alignment = comptime " " ** (1 + (12 -| label.len)); + + const elapsed_ns = self.inner.lap(); + std.debug.print( + label ++ ":" ++ label_alignment ++ "{}\n", + .{std.fmt.fmtDuration(elapsed_ns)}, + ); + } + + pub fn print_if_longer_than_ms( + self: *TimeIt, + threshold_ms: u64, + comptime label: []const u8, + ) void { + self.if_longer_than(label, threshold_ms, false); + } + + pub fn backtrace_if_longer_than_ms( + self: *TimeIt, + threshold_ms: u64, + comptime label: []const u8, + ) void { + self.if_longer_than(label, threshold_ms, true); + } + + fn if_longer_than( + self: *TimeIt, + comptime label: []const u8, + threshold_ms: u64, + backtrace: bool, + ) void { + const elapsed_ns = self.inner.lap(); + if (elapsed_ns > threshold_ms * std.time.ns_per_ms) { + std.debug.print(label ++ ": {}\n", .{std.fmt.fmtDuration(elapsed_ns)}); + if (backtrace) std.debug.dumpCurrentStackTrace(null); + } + } +}; + +/// Utility for print-if debugging, a-la Rust's dbg! macro. +/// +/// dbg prints the value with the prefix, while also returning the value, which makes it convenient +/// to drop it in the middle of a complex expression. +pub fn dbg(prefix: []const u8, value: anytype) @TypeOf(value) { + std.debug.print("{s} = {any}\n", .{ + prefix, + std.json.fmt(value, .{ .whitespace = .indent_2 }), + }); + return value; +} diff --git a/ocam/src/stdx/flags.zig b/ocam/src/stdx/flags.zig new file mode 100644 index 00000000..db765a11 --- /dev/null +++ b/ocam/src/stdx/flags.zig @@ -0,0 +1,1477 @@ +//! The purpose of `flags` is to define standard behavior for parsing CLI arguments and provide +//! a specific parsing library, implementing this behavior. +//! +//! These are TigerBeetle CLI guidelines: +//! +//! - The main principle is robustness --- make operator errors harder to make. +//! - For production usage, avoid defaults. +//! - Thoroughly validate options. +//! - In particular, check that no options are repeated. +//! - Use only long options (`--addresses`). +//! - Exception: `-h/--help` is allowed. +//! - Use `--key=value` syntax for an option with an argument. +//! Don't use `--key value`, as that can be ambiguous (e.g., `--key --verbose`). +//! - Use subcommand syntax when appropriate. +//! - Use positional arguments when appropriate. +//! +//! Design choices for this particular `flags` library: +//! +//! - Be a 80% solution. Parsing arguments is a surprisingly vast topic: auto-generated help, +//! bash completions, typo correction. Rather than providing a definitive solution, `flags` +//! is just one possible option. It is ok to re-implement arg parsing in a different way, as long +//! as the CLI guidelines are observed. +//! +//! - No auto-generated help. Zig doesn't expose doc comments through `@typeInfo`, so its hard to +//! implement auto-help nicely. Additionally, fully hand-crafted `--help` message can be of +//! higher quality. +//! +//! - Fatal errors. It might be "cleaner" to use `try` to propagate the error to the caller, but +//! during early CLI parsing, it is much simpler to terminate the process directly and save the +//! caller the hassle of propagating errors. The `fatal` function is public, to allow the caller +//! to run additional validation or parsing using the same error reporting mechanism. +//! +//! - Concise DSL. Most cli parsing is done for ad-hoc tools like benchmarking, where the ability to +//! quickly add a new argument is valuable. As this is a 80% solution, production code may use +//! more verbose approach if it gives better UX. +//! +//! - Caller manages ArgsIterator. ArgsIterator owns the backing memory of the args, so we let the +//! caller to manage the lifetime. The caller should be skipping program name. + +const std = @import("std"); +const stdx = @import("stdx.zig"); +const builtin = @import("builtin"); +const assert = std.debug.assert; + +const Allocator = std.mem.Allocator; + +arena: std.heap.ArenaAllocator, + +const Flags = @This(); + +pub fn init(gpa: Allocator) Flags { + return .{ + .arena = std.heap.ArenaAllocator.init(gpa), + }; +} + +pub fn deinit(flags: *Flags, gpa: Allocator) void { + assert(std.meta.eql(flags.arena.child_allocator, gpa)); + flags.arena.deinit(); + flags.* = undefined; +} + +/// Format and print an error message to stderr, then exit with an exit code of 1. +fn fatal(comptime fmt_string: []const u8, args: anytype) noreturn { + const stderr = std.io.getStdErr().writer(); + stderr.print("error: " ++ fmt_string ++ "\n", args) catch {}; + // NB: this status must match vsr.FatalReason.cli, but it would be wrong for flags to depend on + // vsr. The right way would be to parametrize flags by this behavior, and let the caller inject + // the implementation of fatal function, but let's be pragmatic here and just match the behavior + // manually. + std.process.exit(1); +} + +fn oom(_: error{OutOfMemory}) noreturn { + fatal("error: out of memory when parsing cli", .{}); +} + +/// Parse CLI arguments for subcommands specified as Zig `struct` or `union(enum)`: +/// +/// ``` +/// const CLIArgs = union(enum) { +/// start: struct { addresses: []const u8, replica: u32 }, +/// format: struct { +/// verbose: bool = false, +/// @"--": void, +/// path: []const u8, +/// }, +/// +/// pub const help = +/// \\ tigerbeetle start --addresses= --replica= +/// \\ tigerbeetle format [--verbose] +/// } +/// +/// const cli_args = parse_commands(&args, CLIArgs); +/// ``` +/// +/// `@"--"` field is treated specially, it delineates positional arguments. +/// +/// If `pub const help` declaration is present, it is used to implement `-h/--help` argument. +/// +/// Value parsing can be customized on per-type basis via `parse_flag_value` customization point. +pub fn parse(flags: *Flags, comptime CLIArgs: type) CLIArgs { + comptime assert(CLIArgs != void); + + const arena = flags.arena.allocator(); + + var args = std.process.argsWithAllocator(arena) catch |err| oom(err); + if (!args.skip()) fatal("executable name missing", .{}); + + return parse_flags(arena, &args, CLIArgs); +} + +fn parse_commands( + arena: Allocator, + args: *std.process.ArgIterator, + comptime Commands: type, +) Commands { + comptime assert(@typeInfo(Commands) == .@"union"); + comptime assert(std.meta.fields(Commands).len >= 2); + + const first_arg = args.next() orelse fatal( + "subcommand required, expected {s}", + .{comptime fields_to_comma_list(Commands)}, + ); + + // NB: help must be declared as *pub* const to be visible here. + if (@hasDecl(Commands, "help")) { + if (std.mem.eql(u8, first_arg, "-h") or std.mem.eql(u8, first_arg, "--help")) { + std.io.getStdOut().writeAll(Commands.help) catch std.process.exit(1); + std.process.exit(0); + } + } + + inline for (comptime std.meta.fields(Commands)) |field| { + comptime assert(std.mem.indexOfScalar(u8, field.name, '_') == null); + if (std.mem.eql(u8, first_arg, field.name)) { + return @unionInit(Commands, field.name, parse_flags(arena, args, field.type)); + } + } + fatal("unknown subcommand: '{s}'", .{first_arg}); +} + +fn parse_flags(arena: Allocator, args: *std.process.ArgIterator, comptime CLIArgs: type) CLIArgs { + @setEvalBranchQuota(5_000); + + if (CLIArgs == void) { + if (args.next()) |arg| { + fatal("unexpected argument: '{s}'", .{arg}); + } + return {}; + } + + if (@typeInfo(CLIArgs) == .@"union") { + return parse_commands(arena, args, CLIArgs); + } + + assert(@typeInfo(CLIArgs) == .@"struct"); + + const fields = std.meta.fields(CLIArgs); + comptime var fields_named, var fields_positional: []const std.builtin.Type.StructField = + for (fields, 0..) |field, index| { + if (std.mem.eql(u8, field.name, "--")) { + assert(field.type == void); + const positional_count = fields.len - index - 1; + if (positional_count == 0) @panic("expected positional fields"); + + break .{ + fields[0..index].*, + fields[index + 1 ..], + }; + } + } else .{ + fields[0..fields.len].*, + &.{}, + }; + + comptime var field_extended: ?std.builtin.Type.StructField = null; + if (fields_positional.len == 1 and fields_positional[0].type == []const []const u8) { + field_extended = fields_positional[0]; + fields_positional = fields_positional[1..]; + assert(fields_positional.len == 0); + } + + var arg_extended: if (field_extended == null) void else std.ArrayListUnmanaged([]const u8) = + if (field_extended == null) {} else .empty; + + comptime { + assert( + fields.len == fields_named.len + + fields_positional.len + + @intFromBool(field_extended != null) + + if (field_extended != null or fields_positional.len > 0) 1 else 0, // The @"--" + ); + if (field_extended != null) assert(fields_positional.len == 0); + + // When parsing named arguments, we must consider longer arguments first, such that + // `--foo-bar=92` is not confused for a misspelled `--foo=92`. Using `std.sort` for + // comptime-only values does not work, so open-code insertion sort, and comptime assert + // order during the actual parsing. + for (fields_named[0..], 0..) |*field_right, i| { + for (fields_named[0..i]) |*field_left| { + if (field_left.name.len < field_right.name.len) { + std.mem.swap(std.builtin.Type.StructField, field_left, field_right); + } + } + } + + for (fields_named) |field| { + switch (@typeInfo(field.type)) { + .bool => { + // Boolean flags must have a default. + assert(field.defaultValue() != null); + assert(field.defaultValue().? == false); + }, + .optional => |optional| { + // Optional flags must have a default. + assert(field.defaultValue() != null); + assert(field.defaultValue().? == null); + + assert_valid_value_type(optional.child); + }, + else => { + assert_valid_value_type(field.type); + }, + } + } + + var optional_tail: bool = false; + for (fields_positional) |field| { + if (field.defaultValue() == null) { + if (optional_tail) @panic("optional positional arguments must be trailing"); + } else { + optional_tail = true; + } + switch (@typeInfo(field.type)) { + .optional => |optional| { + // optional flags should have a default + assert(field.defaultValue() != null); + assert(field.defaultValue().? == null); + assert_valid_value_type(optional.child); + }, + else => { + assert_valid_value_type(field.type); + }, + } + } + } + + var counts: std.enums.EnumFieldStruct(std.meta.FieldEnum(CLIArgs), u32, 0) = .{}; + var result: CLIArgs = undefined; + var parsed_positional = false; + next_arg: while (args.next()) |arg| { + comptime var field_len_prev = std.math.maxInt(usize); + inline for (fields_named) |field| { + const flag = comptime flag_name(field); + + comptime assert(field_len_prev >= field.name.len); + field_len_prev = field.name.len; + if (std.mem.startsWith(u8, arg, flag)) { + if (parsed_positional) { + fatal("unexpected trailing option: '{s}'", .{arg}); + } + + @field(counts, field.name) += 1; + const flag_value = parse_flag(field.type, flag, arg); + @field(result, field.name) = flag_value; + continue :next_arg; + } + } + + if (fields_positional.len > 0) { + assert(field_extended == null); + counts.@"--" += 1; + switch (counts.@"--" - 1) { + inline 0...fields_positional.len - 1 => |field_index| { + const field = fields_positional[field_index]; + const flag = comptime flag_name_positional(field); + + if (arg.len == 0) fatal("{s}: empty argument", .{flag}); + // Prevent ambiguity between a flag and positional argument value. We could add + // support for bare ` -- ` as a disambiguation mechanism once we have a real + // use-case. + if (arg[0] == '-') fatal("unexpected argument: '{s}'", .{arg}); + parsed_positional = true; + + @field(result, field.name) = + parse_value(field.type, flag, arg); + continue :next_arg; + }, + else => {}, // Fall-through to the unexpected argument error. + } + } else { + if (field_extended != null) { + if (std.mem.eql(u8, arg, "--")) { + break; + } else { + fatal("unexpected argument: '{s}'; expected '-- ...'", .{arg}); + } + } + } + + fatal("unexpected argument: '{s}'", .{arg}); + } + if (field_extended != null) { + while (args.next()) |arg| { + arg_extended.append(arena, arg) catch |err| oom(err); + } + } + + assert(args.next() == null); + + inline for (fields_named) |field| { + const flag = flag_name(field); + switch (@field(counts, field.name)) { + 0 => if (field.defaultValue()) |default| { + @field(result, field.name) = default; + } else { + fatal("{s}: argument is required", .{flag}); + }, + 1 => {}, + else => fatal("{s}: duplicate argument", .{flag}), + } + } + + if (fields_positional.len > 0) { + assert(field_extended == null); + assert(counts.@"--" <= fields_positional.len); + inline for (fields_positional, 0..) |field, field_index| { + if (field_index >= counts.@"--") { + const flag = comptime flag_name_positional(field); + if (field.defaultValue()) |default| { + @field(result, field.name) = default; + } else { + fatal("{s}: argument is required", .{flag}); + } + } + } + } + + if (field_extended) |field| { + assert(fields_positional.len == 0); + @field(result, field.name) = arg_extended.items; + } + + return result; +} + +fn assert_valid_value_type(comptime T: type) void { + comptime { + if (T == []const u8 or T == [:0]const u8 or @typeInfo(T) == .int) return; + if (@hasDecl(T, "parse_flag_value")) return; + + if (@typeInfo(T) == .@"enum") { + const info = @typeInfo(T).@"enum"; + assert(info.is_exhaustive); + assert(info.fields.len >= 2); + return; + } + + @compileError("flags: unsupported type: " ++ @typeName(T)); + } +} + +/// Parse, e.g., `--cluster=123` into `123` integer +fn parse_flag(comptime T: type, flag: []const u8, arg: [:0]const u8) T { + assert(flag[0] == '-' and flag[1] == '-'); + + if (T == bool) { + if (std.mem.eql(u8, arg, flag)) { + // Bool argument may not have a value. + return true; + } + } + + const value = parse_flag_split_value(flag, arg); + assert(value.len > 0); + return parse_value(T, flag, value); +} + +/// Splits the value part from a `--arg=value` syntax. +fn parse_flag_split_value(flag: []const u8, arg: [:0]const u8) [:0]const u8 { + assert(flag[0] == '-' and flag[1] == '-'); + assert(std.mem.startsWith(u8, arg, flag)); + + const value = arg[flag.len..]; + if (value.len == 0) { + fatal("{s}: expected value separator '='", .{flag}); + } + if (value[0] != '=') { + fatal( + "{s}: expected value separator '=', but found '{c}' in '{s}'", + .{ flag, value[0], arg }, + ); + } + if (value.len == 1) fatal("{s}: argument requires a value", .{flag}); + return value[1..]; +} + +fn parse_value(comptime T: type, flag: []const u8, value: [:0]const u8) T { + assert((flag[0] == '-' and flag[1] == '-') or flag[0] == '<'); + assert(value.len > 0); + + const V = switch (@typeInfo(T)) { + .optional => |optional| optional.child, + else => T, + }; + + if (V == []const u8 or V == [:0]const u8) return value; + if (V == bool) return parse_value_bool(flag, value); + if (@typeInfo(V) == .int) return parse_value_int(V, flag, value); + if (@hasDecl(V, "parse_flag_value")) { + // Contracts: + // - Input string is guaranteed to be not empty. + // - Output diagnostic must point to statically-allocated data. + // - Diagnostic must start with a lower case letter. + // - Diagnostic must end with a ':' (it will be concatenated with original input). + // - (static_diagnostic != null) iff error.InvalidFlagValue is returned. + const parse_flag_value: fn ( + string: []const u8, + static_diagnostic: *?[]const u8, + ) error{InvalidFlagValue}!V = V.parse_flag_value; + + var diagnostic: ?[]const u8 = null; + if (parse_flag_value(value, &diagnostic)) |result| { + assert(diagnostic == null); + return result; + } else |err| switch (err) { + error.InvalidFlagValue => { + const message = diagnostic.?; + assert(std.ascii.isLower(message[0])); + assert(message[message.len - 1] == ':'); + fatal("{s}: {s} '{s}'", .{ flag, message, value }); + }, + } + } + if (@typeInfo(V) == .@"enum") return parse_value_enum(V, flag, value); + comptime unreachable; +} + +/// Parse string value into an integer, providing a nice error message for the user. +fn parse_value_int(comptime T: type, flag: []const u8, value: [:0]const u8) T { + assert((flag[0] == '-' and flag[1] == '-') or flag[0] == '<'); + + // Support only unsigned integers, as a conservative choice. + comptime assert(@typeInfo(T).int.signedness == .unsigned); + return stdx.parse_int(T, value, .{ .allow_separators = true }) catch |err| { + switch (err) { + error.Overflow => fatal( + "{s}: value exceeds {d}-bit {s} integer: '{s}'", + .{ flag, @typeInfo(T).int.bits, @tagName(@typeInfo(T).int.signedness), value }, + ), + error.InvalidCharacter => fatal( + "{s}: expected an integer value, but found '{s}' (invalid digit)", + .{ flag, value }, + ), + error.LeadingZero => fatal( + "{s}: leading zero disallowed: '{s}'", + .{ flag, value }, + ), + } + }; +} + +fn parse_value_bool(flag: []const u8, value: [:0]const u8) bool { + return switch (parse_value_enum( + enum { + true, + false, + }, + flag, + value, + )) { + .true => true, + .false => false, + }; +} + +fn parse_value_enum(comptime E: type, flag: []const u8, value: [:0]const u8) E { + assert((flag[0] == '-' and flag[1] == '-') or flag[0] == '<'); + comptime assert(@typeInfo(E).@"enum".is_exhaustive); + + return std.meta.stringToEnum(E, value) orelse fatal( + "{s}: expected one of {s}, but found '{s}'", + .{ flag, comptime fields_to_comma_list(E), value }, + ); +} + +fn fields_to_comma_list(comptime E: type) []const u8 { + comptime { + const field_count = std.meta.fields(E).len; + assert(field_count >= 2); + + var result: []const u8 = ""; + for (std.meta.fields(E), 0..) |field, field_index| { + const separator = switch (field_index) { + 0 => "", + else => ", ", + field_count - 1 => if (field_count == 2) " or " else ", or ", + }; + result = result ++ separator ++ "'" ++ field.name ++ "'"; + } + return result; + } +} + +fn flag_name(comptime field: std.builtin.Type.StructField) []const u8 { + return comptime blk: { + assert(!std.mem.eql(u8, field.name, "-")); + assert(!std.mem.eql(u8, field.name, "--")); + + var result: []const u8 = "--"; + var index = 0; + while (std.mem.indexOfScalar(u8, field.name[index..], '_')) |i| { + result = result ++ field.name[index..][0..i] ++ "-"; + index = index + i + 1; + } + result = result ++ field.name[index..]; + break :blk result; + }; +} + +test flag_name { + const field = @typeInfo(struct { statsd: bool }).@"struct".fields[0]; + try std.testing.expectEqualStrings(flag_name(field), "--statsd"); +} + +fn flag_name_positional(comptime field: std.builtin.Type.StructField) []const u8 { + comptime assert(std.mem.indexOfScalar(u8, field.name, '_') == null); + return "<" ++ field.name ++ ">"; +} + +/// Fuzz parse_flag_value function: +/// +/// - Check that ok cases return a value. +/// - Check that err cases return an error with a properly formatted diagnostics. +/// - Check that the diagnostic contains specified substring +/// - Random tests with the input alphabet seeded from explicit cases. +/// - Random tests with uniform input. +pub fn parse_flag_value_fuzz( + comptime T: type, + parse_flag_value: fn ([]const u8, *?[]const u8) error{InvalidFlagValue}!T, + cases: struct { + ok: []const struct { []const u8, T }, + err: []const struct { []const u8, []const u8 }, + }, +) !void { + comptime assert(T.parse_flag_value == parse_flag_value); + + const string_size_max = 32; + + const gpa = std.testing.allocator; + var prng = stdx.PRNG.from_seed_testing(); + + for (cases.ok) |case| { + const string, const want = case; + assert(string.len > 0); + + var diagnostic: ?[]const u8 = null; + const got = try parse_flag_value(string, &diagnostic); + assert(diagnostic == null); + try std.testing.expectEqual(want, got); + } + + for (cases.err) |case| { + const string, const want_message = case; + assert(string.len > 0); // Empty value are rejected early. + + var diagnostic: ?[]const u8 = null; + if (parse_flag_value(string, &diagnostic)) |value| { + std.debug.print("expected an error, got value: input='{s}', value={}", .{ + string, + value, + }); + return error.TestUnexpectedResult; + } else |err| switch (err) { + error.InvalidFlagValue => { + try parse_flag_value_check_diagnostic(string, diagnostic); + if (stdx.cut(diagnostic.?, want_message) == null) { + std.debug.print( + "expected diagnostic to contain substring='{s}' diagnostic='{s}'", + .{ want_message, diagnostic.? }, + ); + return error.TestUnexpectedResult; + } + }, + } + } + + var corpus: std.ArrayListUnmanaged(u8) = .empty; + defer corpus.deinit(gpa); + + for (cases.ok) |case| try corpus.appendSlice(gpa, case[0]); + for (cases.err) |case| try corpus.appendSlice(gpa, case[0]); + for (0..5) |_| try corpus.append(gpa, prng.int(u8)); + + std.mem.sort(u8, corpus.items, {}, std.sort.asc(u8)); + + const alphabet = stdx.unique(corpus.items); + + var string_buffer: [string_size_max]u8 = @splat(0); + + var iterations: stdx.PRNG.FuzzIterations = .{}; + while (iterations.more()) { + const string_size = prng.range_inclusive(usize, 1, string_size_max); + const string = string_buffer[0..string_size]; + assert(string.len > 0); + if (prng.boolean()) { + for (string) |*c| c.* = alphabet[prng.index(alphabet)]; + } else { + for (string) |*c| c.* = prng.int(u8); + } + + var diagnostic: ?[]const u8 = null; + if (parse_flag_value(string, &diagnostic)) |_| { + assert(diagnostic == null); + } else |err| switch (err) { + error.InvalidFlagValue => try parse_flag_value_check_diagnostic(string, diagnostic), + } + } +} + +fn parse_flag_value_check_diagnostic(string: []const u8, diagnostic: ?[]const u8) !void { + const message = diagnostic orelse { + std.debug.print("expected a diagnostic: string='{s}'", .{string}); + return error.TestUnexpectedResult; + }; + if (!(message.len > 0 and + std.ascii.isLower(message[0]) and + message[message.len - 1] == ':')) + { + std.debug.print("wrong diagnostic format: string='{s}' diagnostic='{s}'", .{ + string, + message, + }); + return error.TestUnexpectedResult; + } +} + +// CLI parsing makes a liberal use of `fatal`, so testing it within the process is impossible. We +// test it out of process by: +// - using Zig compiler to build this very file as an executable in a temporary directory, +// - running the following main with various args and capturing stdout, stderr, and the exit code. +// - asserting that the captured values are correct. +// For production builds, don't include the main function. +// This is `if __name__ == "__main__":` at comptime! +pub const main = + if (@import("root") != @This()) {} else struct { + const CLIArgs = union(enum) { + empty, + prefix: struct { + foo: u8 = 0, + foo_bar: u8 = 0, + opt: bool = false, + option: bool = false, + }, + positional: struct { + flag: bool = false, + + @"--": void, + p1: []const u8, + p2: []const u8, + p3: ?u32 = null, + p4: ?u32 = null, + }, + extended: struct { + flag: bool = false, + @"--": void, + rest: []const []const u8, + }, + required: struct { + foo: u8, + bar: u8, + }, + values: struct { + int: u32 = 0, + size: stdx.ByteSize = .{ .value = 0 }, + boolean: bool = false, + path: []const u8 = "not-set", + optional: ?[]const u8 = null, + choice: enum { marlowe, shakespeare } = .marlowe, + }, + subcommand: union(enum) { + pub const help = + \\subcommand help + \\ + ; + + c1: struct { a: bool = false }, + c2: struct { b: bool = false }, + }, + + pub const help = + \\ flags-test-program [flags] + \\ + ; + }; + + fn main() !void { + var gpa_allocator = std.heap.GeneralPurposeAllocator(.{}){}; + const gpa = gpa_allocator.allocator(); + + var flags = Flags.init(gpa); + defer flags.deinit(gpa); + + const cli_args = flags.parse(CLIArgs); + + const stdout = std.io.getStdOut(); + const out_stream = stdout.writer(); + switch (cli_args) { + .empty => try out_stream.print("empty\n", .{}), + .prefix => |values| { + try out_stream.print("foo: {}\n", .{values.foo}); + try out_stream.print("foo-bar: {}\n", .{values.foo_bar}); + try out_stream.print("opt: {}\n", .{values.opt}); + try out_stream.print("option: {}\n", .{values.option}); + }, + .positional => |values| { + try out_stream.print("p1: {s}\n", .{values.p1}); + try out_stream.print("p2: {s}\n", .{values.p2}); + try out_stream.print("p3: {?}\n", .{values.p3}); + try out_stream.print("p4: {?}\n", .{values.p4}); + try out_stream.print("flag: {}\n", .{values.flag}); + }, + .extended => |values| { + try out_stream.print("flag: {}\n", .{values.flag}); + for (values.rest) |arg| try out_stream.print("arg: {s}\n", .{arg}); + }, + .required => |required| { + try out_stream.print("foo: {}\n", .{required.foo}); + try out_stream.print("bar: {}\n", .{required.bar}); + }, + .values => |values| { + try out_stream.print("int: {}\n", .{values.int}); + try out_stream.print("size: {}\n", .{values.size.bytes()}); + try out_stream.print("boolean: {}\n", .{values.boolean}); + try out_stream.print("path: {s}\n", .{values.path}); + try out_stream.print("optional: {?s}\n", .{values.optional}); + try out_stream.print("choice: {?s}\n", .{@tagName(values.choice)}); + }, + .subcommand => |values| { + switch (values) { + .c1 => |c1| try out_stream.print("c1.a: {}\n", .{c1.a}), + .c2 => |c2| try out_stream.print("c2.b: {}\n", .{c2.b}), + } + }, + } + } + }.main; + +test "flags" { + const Snap = stdx.Snap; + const module_path = "src/stdx"; + const snap = Snap.snap_fn(module_path); + + const T = struct { + const T = @This(); + + gpa: std.mem.Allocator, + tmp_dir: std.testing.TmpDir, + output_buf: std.ArrayList(u8), + flags_exe_buf: *[std.fs.max_path_bytes]u8, + flags_exe: []const u8, + + fn init(gpa: std.mem.Allocator) !T { + // TODO: Avoid std.posix.getenv() as it currently causes a linker error on windows. + // See: https://github.com/ziglang/zig/issues/8456 + const zig_exe = try std.process.getEnvVarOwned(gpa, "ZIG_EXE"); // Set by build.zig + defer gpa.free(zig_exe); + + var tmp_dir = std.testing.tmpDir(.{}); + errdefer tmp_dir.cleanup(); + + const tmp_dir_path = try std.fs.path.join(gpa, &.{ + ".zig-cache", + "tmp", + &tmp_dir.sub_path, + }); + defer gpa.free(tmp_dir_path); + + const output_buf = std.ArrayList(u8).init(gpa); + errdefer output_buf.deinit(); + + const flags_exe_buf = try gpa.create([std.fs.max_path_bytes]u8); + errdefer gpa.destroy(flags_exe_buf); + + { // Compile this file as an executable! + const path_relative = try std.fs.path.join(gpa, &.{ + module_path, + @src().file, + }); + defer gpa.free(path_relative); + + const this_file = try std.fs.cwd().realpath( + path_relative, + flags_exe_buf, + ); + const argv = [_][]const u8{ zig_exe, "build-exe", this_file }; + const exec_result = try std.process.Child.run(.{ + .allocator = gpa, + .argv = &argv, + .cwd = tmp_dir_path, + }); + defer gpa.free(exec_result.stdout); + defer gpa.free(exec_result.stderr); + + if (exec_result.term.Exited != 0) { + std.debug.print("{s}{s}", .{ exec_result.stdout, exec_result.stderr }); + return error.FailedToCompile; + } + } + + const flags_exe = try tmp_dir.dir.realpath( + "flags" ++ comptime builtin.target.exeFileExt(), + flags_exe_buf, + ); + + const sanity_check = try std.fs.openFileAbsolute(flags_exe, .{}); + sanity_check.close(); + + return .{ + .gpa = gpa, + .tmp_dir = tmp_dir, + .output_buf = output_buf, + .flags_exe_buf = flags_exe_buf, + .flags_exe = flags_exe, + }; + } + + fn deinit(t: *T) void { + t.gpa.destroy(t.flags_exe_buf); + t.output_buf.deinit(); + t.tmp_dir.cleanup(); + t.* = undefined; + } + + fn check(t: *T, cli: []const []const u8, want: Snap) !void { + const argv = try t.gpa.alloc([]const u8, cli.len + 1); + defer t.gpa.free(argv); + + argv[0] = t.flags_exe; + for (argv[1..], 0..) |*arg, i| { + arg.* = cli[i]; + } + if (cli.len > 0) { + assert(argv[argv.len - 1].ptr == cli[cli.len - 1].ptr); + } + + const exec_result = try std.process.Child.run(.{ + .allocator = t.gpa, + .argv = argv, + }); + defer t.gpa.free(exec_result.stdout); + defer t.gpa.free(exec_result.stderr); + + t.output_buf.clearRetainingCapacity(); + + if (exec_result.term.Exited != 0) { + try t.output_buf.writer().print("status: {}\n", .{exec_result.term.Exited}); + } + if (exec_result.stdout.len > 0) { + try t.output_buf.writer().print("stdout:\n{s}", .{exec_result.stdout}); + } + if (exec_result.stderr.len > 0) { + try t.output_buf.writer().print("stderr:\n{s}", .{exec_result.stderr}); + } + + try want.diff(t.output_buf.items); + } + }; + + var t = try T.init(std.testing.allocator); + defer t.deinit(); + + // Test-cases are roughly in the source order of the corresponding features. + + try t.check(&.{"empty"}, snap(@src(), + \\stdout: + \\empty + \\ + )); + + try t.check(&.{}, snap(@src(), + \\status: 1 + \\stderr: + \\error: subcommand required, expected 'empty', 'prefix', 'positional', 'extended', 'required', 'values', or 'subcommand' + \\ + )); + + try t.check(&.{"-h"}, snap(@src(), + \\stdout: + \\ flags-test-program [flags] + \\ + )); + + try t.check(&.{"--help"}, snap(@src(), + \\stdout: + \\ flags-test-program [flags] + \\ + )); + + try t.check(&.{""}, snap(@src(), + \\status: 1 + \\stderr: + \\error: unknown subcommand: '' + \\ + )); + + try t.check(&.{"bogus"}, snap(@src(), + \\status: 1 + \\stderr: + \\error: unknown subcommand: 'bogus' + \\ + )); + + try t.check(&.{"--int=92"}, snap(@src(), + \\status: 1 + \\stderr: + \\error: unknown subcommand: '--int=92' + \\ + )); + + try t.check(&.{ "empty", "--help" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: unexpected argument: '--help' + \\ + )); + + try t.check(&.{ "prefix", "--foo=92" }, snap(@src(), + \\stdout: + \\foo: 92 + \\foo-bar: 0 + \\opt: false + \\option: false + \\ + )); + + try t.check(&.{ "prefix", "--foo-bar=92" }, snap(@src(), + \\stdout: + \\foo: 0 + \\foo-bar: 92 + \\opt: false + \\option: false + \\ + )); + + try t.check(&.{ "prefix", "--foo-baz=92" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --foo: expected value separator '=', but found '-' in '--foo-baz=92' + \\ + )); + + try t.check(&.{ "prefix", "--opt" }, snap(@src(), + \\stdout: + \\foo: 0 + \\foo-bar: 0 + \\opt: true + \\option: false + \\ + )); + + try t.check(&.{ "prefix", "--option" }, snap(@src(), + \\stdout: + \\foo: 0 + \\foo-bar: 0 + \\opt: false + \\option: true + \\ + )); + + try t.check(&.{ "prefix", "--optx" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --opt: expected value separator '=', but found 'x' in '--optx' + \\ + )); + + try t.check(&.{ "positional", "x", "y" }, snap(@src(), + \\stdout: + \\p1: x + \\p2: y + \\p3: null + \\p4: null + \\flag: false + \\ + )); + + try t.check(&.{ "positional", "x", "y", "1" }, snap(@src(), + \\stdout: + \\p1: x + \\p2: y + \\p3: 1 + \\p4: null + \\flag: false + \\ + )); + + try t.check(&.{ "positional", "x", "y", "1", "2" }, snap(@src(), + \\stdout: + \\p1: x + \\p2: y + \\p3: 1 + \\p4: 2 + \\flag: false + \\ + )); + + try t.check(&.{"positional"}, snap(@src(), + \\status: 1 + \\stderr: + \\error: : argument is required + \\ + )); + + try t.check(&.{ "positional", "x" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: : argument is required + \\ + )); + + try t.check(&.{ "positional", "x", "y", "z" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: : expected an integer value, but found 'z' (invalid digit) + \\ + )); + + try t.check(&.{ "positional", "x", "y", "1", "2", "3" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: unexpected argument: '3' + \\ + )); + + try t.check(&.{ "positional", "" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: : empty argument + \\ + )); + + try t.check(&.{ "positional", "x", "--flag" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: unexpected trailing option: '--flag' + \\ + )); + + try t.check(&.{ "positional", "x", "--flag", "y" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: unexpected trailing option: '--flag' + \\ + )); + + try t.check(&.{ "positional", "--flag", "x", "y" }, snap(@src(), + \\stdout: + \\p1: x + \\p2: y + \\p3: null + \\p4: null + \\flag: true + \\ + )); + + try t.check(&.{ "positional", "--", "x", "y" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: unexpected argument: '--' + \\ + )); + + try t.check(&.{ "positional", "--flak", "x", "y" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: unexpected argument: '--flak' + \\ + )); + + try t.check(&.{ "required", "--foo=1", "--bar=2" }, snap(@src(), + \\stdout: + \\foo: 1 + \\bar: 2 + \\ + )); + + try t.check(&.{ "required", "--surprise" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: unexpected argument: '--surprise' + \\ + )); + + try t.check(&.{ "required", "--foo=1" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --bar: argument is required + \\ + )); + + try t.check(&.{ "required", "--foo=1", "--bar=2", "--foo=3" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --foo: duplicate argument + \\ + )); + + try t.check(&.{ + "values", + "--int=92", + "--size=1GiB", + "--boolean", + "--path=/home", + "--optional=some", + "--choice=shakespeare", + }, snap(@src(), + \\stdout: + \\int: 92 + \\size: 1073741824 + \\boolean: true + \\path: /home + \\optional: some + \\choice: shakespeare + \\ + )); + + try t.check(&.{"values"}, snap(@src(), + \\stdout: + \\int: 0 + \\size: 0 + \\boolean: false + \\path: not-set + \\optional: null + \\choice: marlowe + \\ + )); + + try t.check(&.{ "values", "--boolean=true" }, snap(@src(), + \\stdout: + \\int: 0 + \\size: 0 + \\boolean: true + \\path: not-set + \\optional: null + \\choice: marlowe + \\ + )); + + try t.check(&.{ "values", "--boolean=false" }, snap(@src(), + \\stdout: + \\int: 0 + \\size: 0 + \\boolean: false + \\path: not-set + \\optional: null + \\choice: marlowe + \\ + )); + + try t.check(&.{ "values", "--boolean=foo" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --boolean: expected one of 'true' or 'false', but found 'foo' + \\ + )); + + try t.check(&.{ "values", "--int" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --int: expected value separator '=' + \\ + )); + + try t.check(&.{ "values", "--int:" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --int: expected value separator '=', but found ':' in '--int:' + \\ + )); + + try t.check(&.{ "values", "--int=" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --int: argument requires a value + \\ + )); + + try t.check(&.{ "values", "--int=-92" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --int: expected an integer value, but found '-92' (invalid digit) + \\ + )); + + try t.check(&.{ "values", "--int=092" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --int: leading zero disallowed: '092' + \\ + )); + + try t.check(&.{ "values", "--int=_92" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --int: expected an integer value, but found '_92' (invalid digit) + \\ + )); + + try t.check(&.{ "values", "--int=92_" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --int: expected an integer value, but found '92_' (invalid digit) + \\ + )); + + try t.check(&.{ "values", "--int=92" }, snap(@src(), + \\stdout: + \\int: 92 + \\size: 0 + \\boolean: false + \\path: not-set + \\optional: null + \\choice: marlowe + \\ + )); + + try t.check(&.{ "values", "--int=900_200" }, snap(@src(), + \\stdout: + \\int: 900200 + \\size: 0 + \\boolean: false + \\path: not-set + \\optional: null + \\choice: marlowe + \\ + )); + + try t.check(&.{ "values", "--int=XCII" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --int: expected an integer value, but found 'XCII' (invalid digit) + \\ + )); + + try t.check(&.{ "values", "--int=44444444444444444444" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --int: value exceeds 32-bit unsigned integer: '44444444444444444444' + \\ + )); + + try t.check(&.{ "values", "--int=-0" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --int: expected an integer value, but found '-0' (invalid digit) + \\ + )); + + try t.check(&.{ "values", "--int=+0" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --int: expected an integer value, but found '+0' (invalid digit) + \\ + )); + + try t.check(&.{ "values", "--size=-0" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --size: expected a size, but found: '-0' + \\ + )); + + try t.check(&.{ "values", "--size=+0" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --size: expected a size, but found: '+0' + \\ + )); + + try t.check(&.{ "values", "--size=_1000KiB" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --size: expected a size, but found: '_1000KiB' + \\ + )); + + try t.check(&.{ "values", "--size=1000_KiB" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --size: expected a size, but found: '1000_KiB' + \\ + )); + + try t.check(&.{ "values", "--size=1_000KiB" }, snap(@src(), + \\stdout: + \\int: 0 + \\size: 1024000 + \\boolean: false + \\path: not-set + \\optional: null + \\choice: marlowe + \\ + )); + + try t.check(&.{ "values", "--size=3MiB" }, snap(@src(), + \\stdout: + \\int: 0 + \\size: 3145728 + \\boolean: false + \\path: not-set + \\optional: null + \\choice: marlowe + \\ + )); + + try t.check(&.{ "values", "--size=44444444444444444444" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --size: value exceeds 64-bit unsigned integer: '44444444444444444444' + \\ + )); + + try t.check(&.{ "values", "--size=100000000000000000" }, snap(@src(), + \\stdout: + \\int: 0 + \\size: 100000000000000000 + \\boolean: false + \\path: not-set + \\optional: null + \\choice: marlowe + \\ + )); + + try t.check(&.{ "values", "--size=100000000000000000kib" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --size: size in bytes exceeds 64-bit unsigned integer: '100000000000000000kib' + \\ + )); + + try t.check(&.{ "values", "--size=3bogus" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --size: invalid unit in size, needed KiB, MiB, GiB or TiB: '3bogus' + \\ + )); + + try t.check(&.{ "values", "--size=MiB" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --size: expected a size, but found: 'MiB' + \\ + )); + + try t.check(&.{ "values", "--path=" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --path: argument requires a value + \\ + )); + + try t.check(&.{ "values", "--optional=" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --optional: argument requires a value + \\ + )); + + try t.check(&.{ "values", "--choice=molière" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: --choice: expected one of 'marlowe' or 'shakespeare', but found 'molière' + \\ + )); + + try t.check(&.{"subcommand"}, snap(@src(), + \\status: 1 + \\stderr: + \\error: subcommand required, expected 'c1' or 'c2' + \\ + )); + try t.check(&.{ "subcommand", "c1", "--a" }, snap(@src(), + \\stdout: + \\c1.a: true + \\ + )); + try t.check(&.{ "subcommand", "c2", "--b" }, snap(@src(), + \\stdout: + \\c2.b: true + \\ + )); + try t.check(&.{ "subcommand", "c1", "--b" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: unexpected argument: '--b' + \\ + )); + try t.check(&.{ "subcommand", "c2", "--a" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: unexpected argument: '--a' + \\ + )); + try t.check(&.{ "subcommand", "--help" }, snap(@src(), + \\stdout: + \\subcommand help + \\ + )); + try t.check(&.{ "subcommand", "-h" }, snap(@src(), + \\stdout: + \\subcommand help + \\ + )); + + try t.check(&.{"extended"}, snap(@src(), + \\stdout: + \\flag: false + \\ + )); + try t.check(&.{ "extended", "--" }, snap(@src(), + \\stdout: + \\flag: false + \\ + )); + try t.check(&.{ "extended", "--flag", "--" }, snap(@src(), + \\stdout: + \\flag: true + \\ + )); + try t.check(&.{ "extended", "a" }, snap(@src(), + \\status: 1 + \\stderr: + \\error: unexpected argument: 'a'; expected '-- ...' + \\ + )); + try t.check(&.{ "extended", "--", "a" }, snap(@src(), + \\stdout: + \\flag: false + \\arg: a + \\ + )); + try t.check(&.{ "extended", "--flag", "--", "a" }, snap(@src(), + \\stdout: + \\flag: true + \\arg: a + \\ + )); + try t.check(&.{ "extended", "--", "a", "b" }, snap(@src(), + \\stdout: + \\flag: false + \\arg: a + \\arg: b + \\ + )); + try t.check(&.{ "extended", "--flag", "--", "a", "b" }, snap(@src(), + \\stdout: + \\flag: true + \\arg: a + \\arg: b + \\ + )); + try t.check(&.{ "extended", "--", "--flag" }, snap(@src(), + \\stdout: + \\flag: false + \\arg: --flag + \\ + )); + try t.check(&.{ "extended", "--", "--", "--" }, snap(@src(), + \\stdout: + \\flag: false + \\arg: -- + \\arg: -- + \\ + )); +} diff --git a/ocam/src/stdx/huge_page_allocator.zig b/ocam/src/stdx/huge_page_allocator.zig new file mode 100644 index 00000000..633da8bf --- /dev/null +++ b/ocam/src/stdx/huge_page_allocator.zig @@ -0,0 +1,115 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const stdx = @import("stdx.zig"); +const Allocator = std.mem.Allocator; + +const log = std.log.scoped(.allocator); + +const page_allocator_vtable = std.heap.page_allocator.vtable; + +/// Like `std.heap.page_allocator`, but on Linux applies `MADV_HUGEPAGE` to +/// allocated regions so that the kernel may back them with 2 MiB transparent +/// huge pages, reducing TLB pressure for large allocations. +/// +/// Only `alloc` is intercepted. `resize` and `remap` inherit the VMA flags +/// (including `VM_HUGEPAGE`) set on the original mapping, so they need no +/// additional `madvise` call. +/// +/// On non-Linux targets this is identical to `std.heap.page_allocator`. +pub const huge_page_allocator: Allocator = .{ + .ptr = std.heap.page_allocator.ptr, + .vtable = if (builtin.target.os.tag == .linux) &vtable else page_allocator_vtable, +}; + +const vtable: Allocator.VTable = .{ + .alloc = alloc, + .resize = page_allocator_vtable.resize, + .remap = page_allocator_vtable.remap, + .free = page_allocator_vtable.free, +}; + +fn alloc(context: *anyopaque, n: usize, alignment: std.mem.Alignment, ra: usize) ?[*]u8 { + const ptr = page_allocator_vtable.alloc(context, n, alignment, ra) orelse return null; + // This is just a hint, so if it fails we can safely ignore it. + std.posix.madvise(@alignCast(ptr), n, std.posix.MADV.HUGEPAGE) catch { + log.warn("Transparent Huge Pages (THP) are disabled.", .{}); + }; + return ptr; +} + +const testing = std.testing; +const assert = std.debug.assert; + +/// Checks /proc/self/smaps for the "hg" VmFlag on the mapping containing `ptr`. +fn verify_address_is_huge_page(ptr: [*]const u8) !bool { + assert(builtin.target.os.tag == .linux); + const addr = @intFromPtr(ptr); + + var file = try std.fs.openFileAbsolute("/proc/self/smaps", .{}); + defer file.close(); + + const content = try file.readToEndAlloc(testing.allocator, 10 * 1024 * 1024); + defer testing.allocator.free(content); + + var lines = std.mem.splitScalar(u8, content, '\n'); + + // Find the mapping header that contains our address. + while (lines.next()) |line| { + if (parse_mapping_range(line)) |range| { + if (addr >= range.min and addr < range.max) { + // Scan subsequent lines for VmFlags within this mapping. + while (lines.next()) |detail| { + if (stdx.cut_prefix(detail, "VmFlags:")) |rest| { + return stdx.cut(rest, "hg") != null; + } + } + } + } + } + return false; +} + +fn parse_mapping_range(line: []const u8) ?struct { min: u64, max: u64 } { + const addr_range, _ = stdx.cut(line, " ") orelse return null; + const addr_hex_min, const addr_hex_max = stdx.cut(addr_range, "-") orelse return null; + const addr_min = stdx.parse_int(u64, addr_hex_min, .{ .base = 16 }) catch return null; + const addr_max = stdx.parse_int(u64, addr_hex_max, .{ .base = 16 }) catch return null; + return .{ .min = addr_min, .max = addr_max }; +} + +test "huge_page_allocator: basic alloc and free" { + const slice = try huge_page_allocator.alloc(u8, 4096); + defer huge_page_allocator.free(slice); + + @memset(slice, 0xab); + try testing.expectEqual(@as(u8, 0xab), slice[0]); +} + +test "huge_page_allocator: large THP-eligible allocation" { + // 4 MiB — large enough for THP promotion on Linux. + const size = 4 * 1024 * 1024; + const slice = try huge_page_allocator.alloc(u8, size); + defer huge_page_allocator.free(slice); + + @memset(slice, 0xcd); + try testing.expectEqual(@as(u8, 0xcd), slice[size - 1]); + + if (builtin.target.os.tag == .linux) { + // Verify that MADV_HUGEPAGE was applied by checking VmFlags in /proc/self/smaps. + // The "hg" flag means the process requested hugepages via madvise — this is + // deterministic regardless of whether the kernel actually promoted the pages. + try testing.expect(try verify_address_is_huge_page(slice.ptr)); + } +} + +test "huge_page_allocator: as ArenaAllocator backing" { + var arena = std.heap.ArenaAllocator.init(huge_page_allocator); + defer arena.deinit(); + + const alloc1 = try arena.allocator().alloc(u8, 1024); + const alloc2 = try arena.allocator().alloc(u8, 2048); + @memset(alloc1, 1); + @memset(alloc2, 2); + try testing.expectEqual(@as(u8, 1), alloc1[0]); + try testing.expectEqual(@as(u8, 2), alloc2[0]); +} diff --git a/ocam/src/stdx/iops.zig b/ocam/src/stdx/iops.zig new file mode 100644 index 00000000..30d304ed --- /dev/null +++ b/ocam/src/stdx/iops.zig @@ -0,0 +1,134 @@ +const std = @import("std"); +const stdx = @import("stdx.zig"); +const assert = std.debug.assert; + +/// Take a u8 to limit to 255 items max (maxInt(u8) == 255). +pub fn IOPSType(comptime T: type, comptime size: u8) type { + const Map = stdx.BitSetType(size); + + return struct { + const IOPS = @This(); + + items: [size]T = undefined, + busy: Map = .{}, + + pub fn acquire(self: *IOPS) ?*T { + const i = self.busy.first_unset() orelse return null; + self.busy.set(i); + return &self.items[i]; + } + + pub fn release(self: *IOPS, item: *T) void { + item.* = undefined; + const i = self.index(item); + assert(self.busy.is_set(i)); + self.busy.unset(i); + } + + pub fn index(self: *const IOPS, item: *const T) usize { + const i = @divExact( + (@intFromPtr(item) - @intFromPtr(&self.items)), + @sizeOf(T), + ); + assert(i < size); + return i; + } + + /// Returns the count of IOPs available. + pub fn available(self: *const IOPS) usize { + return self.busy.capacity() - self.busy.count(); + } + + pub inline fn total(_: *const IOPS) usize { + return size; + } + + /// Returns the count of IOPs in use. + pub fn executing(self: *const IOPS) usize { + return self.busy.count(); + } + + pub const Iterator = struct { + iops: *IOPS, + bitset_iterator: Map.Iterator, + + pub fn next(iterator: *@This()) ?*T { + const i = iterator.bitset_iterator.next() orelse return null; + return &iterator.iops.items[i]; + } + }; + + pub const IteratorConst = struct { + iops: *const IOPS, + bitset_iterator: Map.Iterator, + + pub fn next(iterator: *@This()) ?*const T { + const i = iterator.bitset_iterator.next() orelse return null; + return &iterator.iops.items[i]; + } + }; + + /// Iterates over all currently executing IOPs. + pub fn iterate(self: *IOPS) Iterator { + return .{ + .iops = self, + .bitset_iterator = self.busy.iterate(), + }; + } + + /// Iterates over all currently executing IOPs. + pub fn iterate_const(self: *const IOPS) IteratorConst { + return .{ + .iops = self, + .bitset_iterator = self.busy.iterate(), + }; + } + }; +} + +test "IOPS" { + const testing = std.testing; + var iops = IOPSType(u32, 4){}; + + try testing.expectEqual(@as(usize, 4), iops.available()); + try testing.expectEqual(@as(usize, 0), iops.executing()); + + var one = iops.acquire().?; + + try testing.expectEqual(@as(usize, 3), iops.available()); + try testing.expectEqual(@as(usize, 1), iops.executing()); + + var two = iops.acquire().?; + var three = iops.acquire().?; + + try testing.expectEqual(@as(usize, 1), iops.available()); + try testing.expectEqual(@as(usize, 3), iops.executing()); + + var four = iops.acquire().?; + try testing.expectEqual(@as(?*u32, null), iops.acquire()); + + try testing.expectEqual(@as(usize, 0), iops.available()); + try testing.expectEqual(@as(usize, 4), iops.executing()); + + iops.release(two); + + try testing.expectEqual(@as(usize, 1), iops.available()); + try testing.expectEqual(@as(usize, 3), iops.executing()); + + // there is only one slot free, so we will get the same pointer back. + try testing.expectEqual(@as(?*u32, two), iops.acquire()); + + iops.release(four); + iops.release(two); + iops.release(one); + iops.release(three); + + try testing.expectEqual(@as(usize, 4), iops.available()); + try testing.expectEqual(@as(usize, 0), iops.executing()); + + one = iops.acquire().?; + two = iops.acquire().?; + three = iops.acquire().?; + four = iops.acquire().?; + try testing.expectEqual(@as(?*u32, null), iops.acquire()); +} diff --git a/ocam/src/stdx/mlock.zig b/ocam/src/stdx/mlock.zig new file mode 100644 index 00000000..3dc42413 --- /dev/null +++ b/ocam/src/stdx/mlock.zig @@ -0,0 +1,92 @@ +const builtin = @import("builtin"); +const std = @import("std"); +const os = std.os; + +const stdx = @import("stdx.zig"); + +const MiB = stdx.MiB; + +const log = std.log.scoped(.mlock); + +const MemoryLockError = error{memory_not_locked} || std.posix.UnexpectedError; + +const mlockall_error = "Unable to lock pages in memory ({s})" ++ + " - kernel swap would otherwise bypass TigerBeetle's storage fault tolerance."; + +/// Pin virtual memory pages allocated so far to physical pages in RAM, preventing the pages from +/// being swapped out and introducing storage error into memory, bypassing ECC RAM. +pub fn memory_lock_allocated(options: struct { allocated_size: usize }) MemoryLockError!void { + switch (builtin.os.tag) { + .linux => try memory_lock_allocated_linux(), + .macos => { + // macOS has mlock() but not mlockall(). mlock() requires an address range which + // would be difficult to gather for non-heap memory that is also faulted in, + // such as the stack, globals, etc. + }, + .windows => try memory_lock_allocated_windows(options.allocated_size), + else => @compileError("unsupported platform"), + } +} + +fn memory_lock_allocated_linux() MemoryLockError!void { + // https://github.com/torvalds/linux/blob/v6.12/include/uapi/asm-generic/mman.h#L18-L20 + const MCL_CURRENT = 1; // Lock all currently mapped pages. + const MCL_ONFAULT = 4; // Lock all pages faulted in (i.e. stack space). + const result = os.linux.syscall1(.mlockall, MCL_CURRENT | MCL_ONFAULT); + switch (os.linux.E.init(result)) { + .SUCCESS => return, + .AGAIN => log.warn(mlockall_error, .{"some addresses could not be locked"}), + .NOMEM => log.warn(mlockall_error, .{"memory would exceed RLIMIT_MEMLOCK"}), + .PERM => log.warn(mlockall_error, .{ + "insufficient privileges to lock memory", + }), + .INVAL => unreachable, // MCL_ONFAULT specified without MCL_CURRENT. + else => |err| return stdx.unexpected_errno("mlockall", err), + } + return error.memory_not_locked; +} + +fn memory_lock_allocated_windows(allocated_size: usize) MemoryLockError!void { + // Windows has VirtualLock which works similar to mlock with an address range. + // It would be difficult to gather the addresses of non-heap memory that is also + // faulted in, such as the stack, globals, etc. SetProcessWorkingSetSize can be + // used instead to lock all existing pages into memory to avoid swapping. + const process_handle = os.windows.kernel32.GetCurrentProcess(); + var working_set_min: os.windows.SIZE_T = 0; + var working_set_max: os.windows.SIZE_T = 0; + + if (stdx.windows.GetProcessWorkingSetSize( + process_handle, + &working_set_min, + &working_set_max, + ) == os.windows.FALSE) { + working_set_min = allocated_size; // Count bytes allocated so far. + working_set_min += 64 * MiB; // 64mb buffer room for stack/globals. + working_set_max = working_set_min * 2; // Buffer room for new page faults. + } + + if (stdx.windows.SetProcessWorkingSetSize( + process_handle, + working_set_min, + working_set_max, + ) == os.windows.FALSE) { + // From std.os.windows.unexpectedError(): + const format_flags = os.windows.FORMAT_MESSAGE_FROM_SYSTEM | + os.windows.FORMAT_MESSAGE_IGNORE_INSERTS; + + // 614 is the length of the longest windows error description. + var buffer: [614:0]os.windows.WCHAR = undefined; + const buffer_size = os.windows.kernel32.FormatMessageW( + format_flags, + null, + os.windows.kernel32.GetLastError(), + os.windows.LANG.NEUTRAL | (os.windows.SUBLANG.DEFAULT << 10), + &buffer, + buffer.len, + null, + ); + + log.warn(mlockall_error, .{std.unicode.fmtUtf16Le(buffer[0..buffer_size])}); + return error.memory_not_locked; + } +} diff --git a/ocam/src/stdx/net.zig b/ocam/src/stdx/net.zig new file mode 100644 index 00000000..1242f7c7 --- /dev/null +++ b/ocam/src/stdx/net.zig @@ -0,0 +1,527 @@ +//! Physical and logical representation of IP addresses. +//! +//! The primary purpose of `std.net.Address` is to match kernel ABI for Berkeley sockets. +//! It enables communication between the program and the kernel. +//! +//! Here, we instead focus on communication between programs running on different machines. +//! We don't support Unix domain socket, but we do support transferring over the wire in +//! binary format, avoiding stringification. +//! +//! Use uniform representation for both IPv6 and IPv4. As this is "outside view" of an IP address, +//! scope_id and flowinfo are not represented. +const builtin = @import("builtin"); +const std = @import("std"); +const stdx = @import("./stdx.zig"); +const assert = std.debug.assert; +const maybe = stdx.maybe; + +const expectError = std.testing.expectError; +const expectEqual = std.testing.expectEqual; +const expectEqualStrings = std.testing.expectEqualStrings; +const Snap = stdx.Snap; +const snap = Snap.snap_fn("src/stdx"); + +/// An IPv6 or IPv6-mapped IPv4. +pub const IPAddress = extern struct { + // - Array instead of u128 to avoid endian ambiguity. + // - Natural alignment to allow re-interpreting as u128. + big: [16]u8 align(16), + + pub const Family = enum { + IPv4, + IPv6, + + pub fn to_std(f: Family) u32 { + return switch (f) { + .IPv4 => std.posix.AF.INET, + .IPv6 => std.posix.AF.INET6, + }; + } + }; + + const IPv4_prefix: u128 = 0x0000_0000_0000_0000_0000_FFFF_0000_0000; + const IPv4_prefix_octets: [12]u8 = + @as([16]u8, @bitCast(std.mem.nativeToBig(u128, IPv4_prefix)))[0..12].*; + + pub const @"127.0.0.1": IPAddress = .ip("127.0.0.1"); + + comptime { + // The code is endianness-clean, aspirationally. Audit before running on your PowerPC! + assert(builtin.target.cpu.arch.endian() == .little); + + assert(@sizeOf(IPAddress) == 16); + assert(@alignOf(IPAddress) == 16); + + for (0..10) |i| assert(IPv4_prefix_octets[i] == 0); + for (10..12) |i| assert(IPv4_prefix_octets[i] == 0xFF); + assert(IPv4_prefix_octets.len == 12); + } + + pub fn from_v4(big: [4]u8) IPAddress { + return .{ .big = IPv4_prefix_octets ++ big }; + } + + pub fn from_v6(big: [16]u8) IPAddress { + return .{ .big = big }; + } + + pub fn family(address: IPAddress) Family { + if ((address.as_u128() >> 32) == (IPv4_prefix >> 32)) return .IPv4; + return .IPv6; + } + + pub inline fn ip(comptime text: []const u8) IPAddress { + return comptime parse(text) catch @compileError("invalid IP: " ++ text); + } + + test ip { + const v6: IPAddress = .ip("::1:2:3:4"); + comptime assert(std.mem.endsWith(u8, &v6.big, &.{ 0, 1, 0, 2, 0, 3, 0, 4 })); + } + + pub fn parse(text: []const u8) error{InvalidIPAddress}!IPAddress { + const v4 = std.mem.indexOfScalar(u8, text, '.') != null; + return if (v4) parse_v4(text) else parse_v6(text); + } + + fn parse_v4(text: []const u8) error{InvalidIPAddress}!IPAddress { + var octets: [4]u8 = undefined; + var rest = text; + for (0..octets.len - 1) |index| { + const octet_text, rest = stdx.cut(rest, ".") orelse return error.InvalidIPAddress; + octets[index] = stdx.parse_int(u8, octet_text, .{ .base = 10 }) catch + return error.InvalidIPAddress; + } + octets[octets.len - 1] = stdx.parse_int(u8, rest, .{ .base = 10 }) catch + return error.InvalidIPAddress; + + return IPAddress.from_v4(octets); + } + + fn parse_v6(text: []const u8) error{InvalidIPAddress}!IPAddress { + const prefix, const suffix: ?[]const u8 = + stdx.cut(text, "::") orelse .{ text, null }; + + inline for (.{ prefix, suffix orelse "" }) |affix| { + if (std.mem.endsWith(u8, affix, ":")) return error.InvalidIPAddress; + if (std.mem.startsWith(u8, affix, ":")) return error.InvalidIPAddress; + } + + const prefix_count = quibble_count(prefix); + const suffix_count = quibble_count(suffix orelse ""); + if (prefix_count +| suffix_count > 8) return error.InvalidIPAddress; + const shorthand_count = 8 - (prefix_count + suffix_count); + if (suffix == null) { + if (prefix_count != 8) return error.InvalidIPAddress; + } else { + maybe(shorthand_count == 1); // Non-canonical, but valid. + if (shorthand_count == 0) return error.InvalidIPAddress; + } + + var quibbles_big: [8]u16 = @splat(0); + inline for (.{ + .{ prefix, 0, prefix_count }, + .{ suffix orelse "", prefix_count + shorthand_count, suffix_count }, + }) |text_start_index_count| { + var rest, var index: usize, const count = text_start_index_count; + if (count > 0) { + for (0..count - 1) |_| { + const quibble_text, rest = stdx.cut(rest, ":").?; + const quibble = stdx.parse_int(u16, quibble_text, .{ + .base = 16, + .allow_leading_zero = true, + }) catch + return error.InvalidIPAddress; + quibbles_big[index] = std.mem.nativeToBig(u16, quibble); + index += 1; + } + const quibble = stdx.parse_int(u16, rest, .{ + .base = 16, + .allow_leading_zero = true, + }) catch + return error.InvalidIPAddress; + quibbles_big[index] = std.mem.nativeToBig(u16, quibble); + index += 1; + } + } + + return .{ .big = @bitCast(quibbles_big) }; + } + + fn quibble_count(text: []const u8) usize { + if (text.len == 0) return 0; + return std.mem.count(u8, text, ":") + 1; + } + + pub fn format( + address: IPAddress, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + comptime assert(fmt.len == 0); + _ = options; + switch (address.family()) { + .IPv4 => try address.format_v4(writer), + .IPv6 => try address.format_v6(writer), + } + } + + fn format_v4(address: IPAddress, writer: anytype) !void { + const octets = address.as_v4().?; + try writer.print("{}.{}.{}.{}", .{ octets[0], octets[1], octets[2], octets[3] }); + } + + // https://en.wikipedia.org/wiki/IPv6#Address_representation + fn format_v6(address: IPAddress, writer: anytype) !void { + const quibbles_big: [8]u16 = @bitCast(address.big); + + const run = compressable_run(quibbles_big); + + const prefix_count = if (run) |zeroes| zeroes.start else quibbles_big.len; + for (0..prefix_count) |index| { + if (index > 0) try writer.writeAll(":"); + const quibble = std.mem.bigToNative(u16, quibbles_big[index]); + try writer.print("{x}", .{quibble}); + } + if (run) |zeroes| { + try writer.writeAll("::"); + for (zeroes.start..zeroes.start + zeroes.count) |index| { + assert(quibbles_big[index] == 0); + } + for (zeroes.start + zeroes.count..quibbles_big.len) |index| { + if (index > zeroes.start + zeroes.count) try writer.writeAll(":"); + const quibble = std.mem.bigToNative(u16, quibbles_big[index]); + try writer.print("{x}", .{quibble}); + } + } + } + + const Run = struct { start: usize, count: usize }; + fn compressable_run(quibbles: [8]u16) ?Run { + var longest: ?Run = null; + var current: ?Run = null; + for (0..quibbles.len) |index| { + if (quibbles[index] == 0) { + if (current == null) current = .{ .start = index, .count = 0 }; + current.?.count += 1; + if (longest == null or + // The first sequence of zero bits MUST be shortened + // https://www.rfc-editor.org/info/rfc5952/#section-4.2.1 + longest.?.count < current.?.count) + { + longest = current.?; + } + } else { + current = null; + } + } + if (longest == null) return null; + if (longest.?.count < 2) return null; + return longest.?; + } + + fn as_v4(address: IPAddress) ?[4]u8 { + if (address.family() != .IPv4) return null; + return address.big[12..].*; + } + + fn as_u128(address: IPAddress) u128 { + return std.mem.bigToNative(u128, @bitCast(address.big)); + } + + fn arbitrary(prng: *stdx.PRNG) IPAddress { + var big: [16]u8 = @splat(0); + if (prng.boolean()) { + big[10] = 0xFF; + big[11] = 0xFF; + prng.fill(big[12..]); + } else { + prng.fill(&big); + } + return .{ .big = big }; + } +}; + +test IPAddress { + const gpa = std.testing.allocator; + + const T = struct { + fn check(options: struct { + ok_canonical: []const []const u8, + ok: []const []const u8, + err: []const []const u8, + }) !void { + for (options.ok_canonical) |case| try check_ok_canonical(case); + for (options.ok) |case| try check_ok_non_canonical(case); + for (options.err) |case| try check_err(case); + + // Fix seed run to assert that we touch the boundary. + var prng = stdx.PRNG.from_seed(92); + const results = try check_fuzz(&prng, .{ + .corpus = &.{ options.ok_canonical, options.ok, options.err }, + .test_count = 10_000, + }); + assert(results.ok > 0); // Positive space covered. + assert(results.err > 0); // Negative space covered. + + // Actual fuzzing with a true random seed. + prng = stdx.PRNG.from_seed_testing(); + _ = try check_fuzz(&prng, .{ + .corpus = &.{ options.ok_canonical, options.ok, options.err }, + .test_count = 10_000, + }); + } + + // An IPv6 address has many valid textual representations, but we print the canonical one. + // https://www.rfc-editor.org/info/rfc5952/#section-4.2.1 + fn check_ok_canonical(text: []const u8) !void { + const ip = try IPAddress.parse(text); + var buffer: [64]u8 = undefined; + const text_canonical = try std.fmt.bufPrint(&buffer, "{}", .{ip}); + try expectEqualStrings(text, text_canonical); + + try check_ok(text); + } + + fn check_ok_non_canonical(text: []const u8) !void { + const ip = try IPAddress.parse(text); + var buffer: [64]u8 = undefined; + const text_canonical = try std.fmt.bufPrint(&buffer, "{}", .{ip}); + if (std.mem.eql(u8, text, text_canonical)) { + std.log.err("{s} is already canonical", .{text}); + return error.TestUnexpectedResult; + } + + try check_ok(text); + } + + // For valid addresses, we agree with std, and agree with our own formatting. + fn check_ok(text: []const u8) !void { + errdefer std.log.err("text={s}", .{text}); + + const ip = try IPAddress.parse(text); + const address = SocketAddress.to_std(.{ .ip = ip, .port = 0 }); + const address_std = try parse_std(text); + if (!address.eql(address_std)) { + if (address.any.family == std.posix.AF.INET and + address_std.any.family == std.posix.AF.INET6 and + std.mem.eql(u8, &IPAddress.IPv4_prefix_octets, address_std.in6.sa.addr[0..12])) + { + // Std doesn't canonicalize IPv6-mapped IPv4 addresses. + } else { + std.log.err("{} != {}", .{ address, address_std }); + return error.TestUnexpectedResult; + } + } + + var buffer: [64]u8 = undefined; + const text_roundtrip = try std.fmt.bufPrint(&buffer, "{}", .{ip}); + const ip_roundtrip = try IPAddress.parse(text_roundtrip); + assert(std.meta.eql(ip, ip_roundtrip)); + } + + // For invalid addresses, both we and std rejects. + fn check_err(text: []const u8) !void { + errdefer std.log.err("text={s}", .{text}); + + try expectError(error.InvalidIPAddress, IPAddress.parse(text)); + + const address_std = parse_std(text) catch return; + if ((std.mem.startsWith(u8, text, ":") and !std.mem.startsWith(u8, text, "::")) or + (std.mem.endsWith(u8, text, ":") and !std.mem.endsWith(u8, text, "::"))) + { + return; //TODO(Zig): 0.14.1 incorrectly parses trailing/leading colons. + } + + if (stdx.cut(text, "%")) |cut| { + // Std supports scopes, but we intentionally don't. + const address = try IPAddress.parse(cut.@"0"); + assert(address.family() == .IPv6); + return; + } + + if (stdx.cut_prefix(text, "::ffff:")) |ipv4| { + // Similarly, don't support explicit IPv6-mapped-IPv4 syntax; + const address = try IPAddress.parse(ipv4); + assert(address.family() == .IPv4); + return; + } + + std.log.err("incorrectly parsed as {}", .{address_std}); + return error.ExpectedError; + } + + // - Build alphabet from corpus + random draw. + // - Compare with std, both negative and positive cases. + // - Count stats to make sure both are covered. + fn check_fuzz(prng: *stdx.PRNG, options: struct { + corpus: []const []const []const u8, + test_count: u32, + }) !struct { ok: u32, err: u32 } { + var corpus: std.ArrayListUnmanaged(u8) = .empty; + defer corpus.deinit(gpa); + + for (options.corpus) |cases| for (cases) |case| try corpus.appendSlice(gpa, case); + for (0..5) |_| try corpus.append(gpa, prng.int(u8)); + + std.mem.sort(u8, corpus.items, {}, std.sort.asc(u8)); + + const alphabet = stdx.unique(corpus.items); + + const text_size_max = 64; + var buffer: [text_size_max]u8 = undefined; + + var ok: u32 = 0; + var err: u32 = 0; + for (0..options.test_count) |_| { + const text_size = prng.range_inclusive(usize, 0, text_size_max); + const text = buffer[0..text_size]; + for (text) |*c| c.* = alphabet[prng.index(alphabet)]; + + if (IPAddress.parse(text)) |_| { + ok += 1; + try check_ok(text); + } else |_| { + err += 1; + try check_err(text); + } + } + + assert(ok + err == options.test_count); + return .{ .ok = ok, .err = err }; + } + + fn parse_std(text: []const u8) !std.net.Address { + return try std.net.Address.parseIp(text, 0); + } + }; + + try T.check(.{ + .ok_canonical = &.{ + "127.0.0.1", + "0.0.0.0", + "::", + "::1", + "1::", + "255.255.255.255", + "2001:db8::1:0:0:1", + "2001:db8:0:1:1:1:1:1", + "2001:db8::1", + "ff01::101", + }, + .ok = &.{ + "0::", + "2001:0db8:85a3::8a2e:0370:7334", + "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + "eebb::2:be8:622:4:b450:52a", + "2001:DB8:0:0:8:800:200C:417A", + "FF01:0:0:0:0:0:0:101", + "0:0:0:0:0:0:0:1", + "0:0:0:0:0:0:0:0", + "Ff01::101", + "::ffff:c0a8:64e4", + }, + .err = &.{ + "::d3:", + ":1d7d::", + "0.0.0.001", + "256.0.0.1", + "127.0.0.1.", + ".127.0.0.1", + "127.0.0", + "", + ":", + ":::", + "::::", + "b::8%4", + "::ffff:192.0.2.128", + }, + }); +} + +test "IPAddress: from_v4" { + const v4 = IPAddress.from_v4(.{ 1, 2, 3, 4 }); + const v4_std = std.net.Address.initIp4(.{ 1, 2, 3, 4 }, 0); + try expectEqual(v4, (try SocketAddress.from_std(v4_std)).ip); + + try snap(@src(), + \\00 00 00 00 00 00 00 00 00 00 ff ff 01 02 03 04 + ).diff_hex(&v4.big); +} + +pub const SocketAddress = struct { + ip: IPAddress, + port: u16, + + pub fn to_std(socket: SocketAddress) std.net.Address { + switch (socket.ip.family()) { + .IPv4 => { + const octets: [4]u8 = socket.ip.as_v4().?; + return .{ .in = std.net.Ip4Address.init(octets, socket.port) }; + }, + .IPv6 => { + // The following two fields are machine-local and can be safely zeroed-out. + // + // Flowinfo corresponds to the matching field in the IPv6 header, and is a property + // of a connection, rather than a part of the address proper. std.net needs it + // because the kernel API works this way. + const flowinfo = 0; + // On a machine with several network interfaces, each network interface might have + // the _same_ link-local IPv6 address (in addition to a separate, globally routable + // IPv6 address). Scope-id is another machine-local kernel API, telling the kernel + // which interface to use. + const scopeid = 0; + + return .{ .in6 = std.net.Ip6Address.init( + socket.ip.big, + socket.port, + flowinfo, + scopeid, + ) }; + }, + } + } + + pub fn from_std(address: std.net.Address) error{UnsupportedFamily}!SocketAddress { + switch (address.any.family) { + std.posix.AF.INET => { + const octets_big: [4]u8 = @bitCast(address.in.sa.addr); + const ip = IPAddress.from_v4(octets_big); + const port = std.mem.bigToNative(u16, address.in.sa.port); + return .{ .ip = ip, .port = port }; + }, + std.posix.AF.INET6 => { + const ip: IPAddress = .{ .big = address.in6.sa.addr }; + const port = std.mem.bigToNative(u16, address.in6.sa.port); + return .{ .ip = ip, .port = port }; + }, + else => return error.UnsupportedFamily, + } + } + + fn arbitrary(prng: *stdx.PRNG) SocketAddress { + return .{ .ip = IPAddress.arbitrary(prng), .port = prng.int(u16) }; + } +}; + +test "SocketAddress: from_std bad family" { + if (builtin.os.tag == .windows) return; + const unix_domain = try std.net.Address.initUnix("/tmp/socket"); + try expectError(error.UnsupportedFamily, SocketAddress.from_std(unix_domain)); +} + +test "SocketAddress: fuzz to_std/from_std" { + var prng = stdx.PRNG.from_seed_testing(); + for (0..1000) |_| { + const socket = SocketAddress.arbitrary(&prng); + const address = socket.to_std(); + const socket_roundtrip = try SocketAddress.from_std(address); + assert(std.meta.eql(socket, socket_roundtrip)); + + switch (socket.ip.family()) { + .IPv4 => assert(address.any.family == std.posix.AF.INET), + .IPv6 => assert(address.any.family == std.posix.AF.INET6), + } + } +} diff --git a/ocam/src/stdx/prng.zig b/ocam/src/stdx/prng.zig new file mode 100644 index 00000000..49cb34d6 --- /dev/null +++ b/ocam/src/stdx/prng.zig @@ -0,0 +1,710 @@ +//! TigerBeetle standard Pseudo Random Number generator. +//! +//! Import qualified and use `prng` for field/variable name: +//! +//! ``` +//! prng: *stdx.PRNG +//! ``` +//! +//! The implementation matches Zig's `std.Random.DefaultPrng`, but we avoid using that directly in +//! order to: +//! - remove floating point from the API, to ensure determinism +//! - isolate our test suite from stdlib API churn +//! - isolate TigerBeetle from the churn in the PRNG algorithms +//! - simplify and extend the API +//! - remove dynamic-dispatch indirection (a minor bonus). + +const builtin = @import("builtin"); +const std = @import("std"); +const stdx = @import("stdx.zig"); +const assert = std.debug.assert; +const math = std.math; +const Snap = stdx.Snap; +const module_path = "src/stdx"; +const snap = Snap.snap_fn(module_path); +const KiB = stdx.KiB; + +s: [4]u64, + +const PRNG = @This(); + +/// A less than one rational number, used to specify probabilities. +pub const Ratio = struct { + // Invariant: numerator ≤ denominator. + numerator: u64, + // Invariant: denominator ≠ 0. + denominator: u64, + + pub fn zero() Ratio { + return .{ .numerator = 0, .denominator = 1 }; + } + + pub fn format( + r: Ratio, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + if (r.numerator == 0) return writer.print("0", .{}); + return writer.print("{d}/{d}", .{ r.numerator, r.denominator }); + } + + pub fn parse_flag_value( + string: []const u8, + static_diagnostic: *?[]const u8, + ) error{InvalidFlagValue}!Ratio { + assert(string.len > 0); + if (string.len == 1 and string[0] == '0') return .zero(); + + const string_numerator, const string_denominator = stdx.cut(string, "/") orelse { + static_diagnostic.* = "expected 'a/b' ratio, but found:"; + return error.InvalidFlagValue; + }; + + const numerator = stdx.parse_int(u64, string_numerator, .{ + .base = 10, + .allow_separators = true, + }) catch { + static_diagnostic.* = "invalid numerator:"; + return error.InvalidFlagValue; + }; + const denominator = stdx.parse_int(u64, string_denominator, .{ + .base = 10, + .allow_separators = true, + }) catch { + static_diagnostic.* = "invalid denominator:"; + return error.InvalidFlagValue; + }; + if (denominator == 0) { + static_diagnostic.* = "denominator is zero:"; + return error.InvalidFlagValue; + } + if (numerator > denominator) { + static_diagnostic.* = "ratio greater than 1:"; + return error.InvalidFlagValue; + } + return ratio(numerator, denominator); + } +}; + +test "Ratio.parse_flag_value" { + try stdx.Flags.parse_flag_value_fuzz(Ratio, Ratio.parse_flag_value, .{ + .ok = &.{ + .{ "0", .zero() }, + .{ "3/4", ratio(3, 4) }, + .{ "10/100", ratio(10, 100) }, + }, + .err = &.{ + .{ "1/0", "denominator is zero" }, + .{ "0/0", "denominator is zero" }, + .{ "3", "expected 'a/b' ratio, but found" }, + .{ "π/4", "invalid numerator" }, + .{ "3/i", "invalid denominator" }, + .{ "4/3", "ratio greater than 1" }, + }, + }); +} + +/// Canonical constructor for Ratio. Import as `const ratio = stdx.PRNG.ratio`. +pub fn ratio(numerator: u64, denominator: u64) Ratio { + assert(denominator > 0); + assert(numerator <= denominator); + return .{ .numerator = numerator, .denominator = denominator }; +} + +pub fn from_seed(seed: u64) PRNG { + var s = seed; + return .{ .s = .{ + split_mix_64(&s), + split_mix_64(&s), + split_mix_64(&s), + split_mix_64(&s), + } }; +} + +pub fn from_seed_testing() PRNG { + comptime assert(@import("builtin").is_test); + return .from_seed(std.testing.random_seed); +} + +fn split_mix_64(s: *u64) u64 { + s.* +%= 0x9e3779b97f4a7c15; + + var z = s.*; + z = (z ^ (z >> 30)) *% 0xbf58476d1ce4e5b9; + z = (z ^ (z >> 27)) *% 0x94d049bb133111eb; + return z ^ (z >> 31); +} + +fn next(prng: *PRNG) u64 { + const r = std.math.rotl(u64, prng.s[0] +% prng.s[3], 23) +% prng.s[0]; + + const t = prng.s[1] << 17; + + prng.s[2] ^= prng.s[0]; + prng.s[3] ^= prng.s[1]; + prng.s[1] ^= prng.s[2]; + prng.s[0] ^= prng.s[3]; + + prng.s[2] ^= t; + + prng.s[3] = math.rotl(u64, prng.s[3], 45); + + return r; +} + +test next { + var prng = from_seed(92); + var distribution: [8]u32 = @splat(0); + for (0..1000) |_| { + distribution[prng.next() % 8] += 1; + } + try snap(@src(), + \\{ 134, 134, 117, 121, 117, 128, 131, 118 } + ).diff_fmt("{d}", .{distribution}); +} + +pub fn fill(prng: *PRNG, target: []u8) void { + var i: usize = 0; + const aligned_len = target.len - (target.len & 7); + + // Complete 8 byte segments. + while (i < aligned_len) : (i += 8) { + var n = prng.next(); + comptime var j: usize = 0; + inline while (j < 8) : (j += 1) { + target[i + j] = @as(u8, @truncate(n)); + n >>= 8; + } + } + + // Remaining (cuts the stream). + if (i != target.len) { + var n = prng.next(); + while (i < target.len) : (i += 1) { + target[i] = @as(u8, @truncate(n)); + n >>= 8; + } + } +} + +test fill { + const size_max = 128; + var buffer_max: [size_max]u8 = undefined; + var prng = from_seed(32); + + var distribution: [8]u32 = @splat(0); + for (0..size_max + 1) |size| { + // Check that the entire buffer is filled, by filling it over a couple of times + // and checking that each byte is non-zero at least once. + var non_zero: stdx.BitSetType(size_max) = .{}; + for (0..3) |_| { + const buffer = buffer_max[0..size]; + @memset(buffer, 0); + prng.fill(buffer); + for (buffer, 0..) |byte, i| { + distribution[byte % 8] += 1; + if (byte != 0) non_zero.set(i); + } + } + for (0..size) |i| assert(non_zero.is_set(i)); + } + + try snap(@src(), + \\{ 3120, 3084, 3089, 3103, 3092, 3120, 3074, 3086 } + ).diff_fmt("{d}", .{distribution}); +} + +/// Generate an unbiased, uniformly distributed integer r such that 0 ≤ r ≤ max. +/// +/// No biased version is provided --- while biased generation is simpler&faster, the bias can be +/// quite high depending on max! +pub fn int_inclusive(prng: *PRNG, Int: anytype, max: Int) Int { + comptime assert(@typeInfo(Int).int.signedness == .unsigned); + if (max == std.math.maxInt(Int)) { + return prng.int(Int); + } + + comptime assert(@typeInfo(Int).int.signedness == .unsigned); + const bits = @typeInfo(Int).int.bits; + const less_than = max + 1; + + // adapted from: + // http://www.pcg-random.org/posts/bounded-rands.html + // "Lemire's (with an extra tweak from Zig)" + var x = prng.int(Int); + var m = math.mulWide(Int, x, less_than); + var l: Int = @truncate(m); + if (l < less_than) { + var t = -%less_than; + + if (t >= less_than) { + t -= less_than; + if (t >= less_than) { + t %= less_than; + } + } + while (l < t) { + x = prng.int(Int); + m = math.mulWide(Int, x, less_than); + l = @truncate(m); + } + } + return @intCast(m >> bits); +} + +test int_inclusive { + var prng = from_seed(92); + for (0..8) |max_usize| { + const max: u8 = @intCast(max_usize); + var distribution: [8]u32 = @splat(0); + for (0..100) |_| { + distribution[prng.int_inclusive(u8, max)] += 1; + } + for (distribution[0 .. max + 1]) |d| assert(d > 0); + for (distribution[max + 1 ..]) |d| assert(d == 0); + } + + var distribution: [8]u32 = @splat(0); + for (0..1000) |_| { + const n = prng.int_inclusive(u128, 7); + distribution[@intCast(n)] += 1; + } + try snap(@src(), + \\{ 123, 127, 115, 125, 125, 139, 111, 135 } + ).diff_fmt("{d}", .{distribution}); + + var large: u32 = 0; + var small: u32 = 0; + for (0..1000) |_| { + if (prng.int_inclusive(u64, math.maxInt(u64) / 2) > math.maxInt(u64) / 4) { + large += 1; + } else { + small += 1; + } + } + try snap(@src(), + \\large=506 small=494 + ).diff_fmt("large={} small={}", .{ large, small }); +} + +// Deliberately excluded from the API to normalize everything to closed ranges. +// Somewhat surprisingly, closed ranges are more convenient for generating random numbers: +// - passing zero is not a subtle error +// - passing intMax allows generating any integer +// - at the call-site, inclusive is usually somewhat more obvious. +pub const int_exclusive = @compileError("intentionally not implemented"); + +/// Given a slice, generates a random valid index for the slice. +pub fn index(prng: *PRNG, slice: anytype) usize { + assert(slice.len > 0); + return prng.int_inclusive(usize, slice.len - 1); +} + +test index { + var prng = from_seed(92); + + var distribution: [8]u32 = @splat(0); + for (0..100) |_| { + distribution[index(&prng, &distribution)] += 1; + } + try snap(@src(), + \\{ 9, 13, 13, 11, 10, 16, 16, 12 } + ).diff_fmt("{d}", .{distribution}); +} + +/// Generates a uniform, unbiased integer r such that max ≤ r ≤ max. +pub fn range_inclusive(prng: *PRNG, Int: type, min: Int, max: Int) Int { + comptime assert(@typeInfo(Int).int.signedness == .unsigned); + assert(min <= max); + return min + prng.int_inclusive(Int, max - min); +} + +test range_inclusive { + var prng = from_seed(92); + for (0..8) |min| { + for (min..8) |max| { + var distribution: [8]u32 = @splat(0); + for (0..100) |_| { + distribution[prng.range_inclusive(usize, min, max)] += 1; + } + for (distribution, 0..) |d, i| { + assert((d > 0) == (min <= i and i <= max)); + } + } + } +} + +/// Returns a uniformly distributed integer of type T. +/// +/// That is, fills @sizeOf(T) bytes with random bits. +pub fn int(prng: *PRNG, Int: type) Int { + comptime assert(@typeInfo(Int).int.signedness == .unsigned); + if (Int == u64) return prng.next(); + if (@sizeOf(Int) < @sizeOf(u64)) return @truncate(prng.next()); + var result: Int = undefined; + prng.fill(std.mem.asBytes(&result)); + return result; +} + +test int { + try test_bytes_int(u8, snap(@src(), + \\{ 134, 134, 117, 121, 117, 128, 131, 118 } + )); + try test_bytes_int(u64, snap(@src(), + \\{ 134, 134, 117, 121, 117, 128, 131, 118 } + )); + try test_bytes_int(u128, snap(@src(), + \\{ 130, 143, 107, 135, 111, 119, 132, 123 } + )); +} + +fn test_bytes_int(Int: type, want: Snap) !void { + var prng = PRNG.from_seed(92); + var distribution: [8]u32 = @splat(0); + for (0..1000) |_| { + distribution[@intCast(prng.int(Int) % 8)] += 1; + } + try want.diff_fmt("{d}", .{distribution}); +} + +/// Returns true with probability 0.5. +pub fn boolean(prng: *PRNG) bool { + return prng.next() & 1 == 1; +} + +test boolean { + var prng = PRNG.from_seed(92); + var heads: u32 = 0; + var tails: u32 = 0; + for (0..1000) |_| { + if (prng.boolean()) heads += 1 else tails += 1; + } + try snap(@src(), + \\heads = 501 tails = 499 + ).diff_fmt("heads = {} tails = {}", .{ heads, tails }); +} + +/// Returns a Word with a single randomly-chosen bit set. +pub fn bit(prng: *PRNG, comptime Word: type) Word { + comptime assert(@typeInfo(Word) == .int); + comptime assert(@typeInfo(Word).int.signedness == .unsigned); + return @as(Word, 1) << prng.int_inclusive(std.math.Log2Int(Word), @bitSizeOf(Word) - 1); +} + +test bit { + var prng = PRNG.from_seed(92); + var hits: [8]u32 = @splat(0); + for (0..1000) |_| { + const word = prng.bit(u8); + assert(@popCount(word) == 1); + hits[@ctz(word)] += 1; + } + try snap(@src(), + \\{ 134, 134, 117, 121, 117, 128, 131, 118 } + ).diff_fmt("{any}", .{hits}); +} + +/// Returns true with the given rational probability. +pub fn chance(prng: *PRNG, probability: Ratio) bool { + assert(probability.denominator > 0); + assert(probability.numerator <= probability.denominator); + return prng.int_inclusive(u64, probability.denominator - 1) < probability.numerator; +} + +test chance { + var prng = PRNG.from_seed(92); + var balance: i32 = 0; + for (0..1000) |_| { + if (prng.chance(ratio(2, 7))) balance += 1 else balance -= 1; + if (prng.chance(ratio(5, 7))) balance += 1 else balance -= 1; + } + try snap(@src(), + \\balance = 46 + ).diff_fmt("balance = {d}", .{balance}); +} + +/// Like enum_weighted, but doesn't require specifying the enum up-front. +pub fn chances(prng: *PRNG, weights: anytype) std.meta.FieldEnum(@TypeOf(weights)) { + const Enum = std.meta.FieldEnum(@TypeOf(weights)); + return enum_weighted_impl(prng, Enum, weights); +} + +test chances { + var prng = from_seed(92); + var count: struct { a: u32 = 0, b: u32 = 0, c: u32 = 0 } = .{}; + for (0..1000) |_| { + switch (prng.chances(.{ .a = 1, .b = 3, .c = 2 })) { + inline else => |tag| @field(count, @tagName(tag)) += 1, + } + } + try snap(@src(), + \\a=166 b=475 c=359 + ).diff_fmt("a={} b={} c={}", .{ count.a, count.b, count.c }); +} + +pub fn error_uniform(prng: *PRNG, Error: type) Error { + const errors = @typeInfo(Error).error_set.?; + return switch (prng.index(errors)) { + inline 0...(errors.len - 1) => |i| @field(Error, errors[i].name), + else => unreachable, + }; +} + +/// Returns a random value of an enum. +pub fn enum_uniform(prng: *PRNG, Enum: type) Enum { + const values = std.enums.values(Enum); + return values[prng.index(values)]; +} + +test enum_uniform { + const E = enum(u8) { a, b, c = 8 }; // 8 tests that the discriminant is used properly. + + var prng = from_seed(92); + var count: struct { a: u32 = 0, b: u32 = 0, c: u32 = 0 } = .{}; + for (0..1000) |_| { + switch (prng.enum_uniform(E)) { + inline else => |tag| @field(count, @tagName(tag)) += 1, + } + } + + try snap(@src(), + \\a=318 b=323 c=359 + ).diff_fmt("a={} b={} c={}", .{ count.a, count.b, count.c }); +} + +pub fn EnumWeightsType(E: type) type { + return std.enums.EnumFieldStruct(E, u64, null); +} + +/// Returns a random value of an enum, where probability is proportional to weight. +pub fn enum_weighted(prng: *PRNG, Enum: type, weights: EnumWeightsType(Enum)) Enum { + return enum_weighted_impl(prng, Enum, weights); +} + +fn enum_weighted_impl(prng: *PRNG, Enum: type, weights: anytype) Enum { + const fields = @typeInfo(Enum).@"enum".fields; + var total: u64 = 0; + inline for (fields) |field| { + total += @field(weights, field.name); + } + assert(total > 0); + var pick = prng.int_inclusive(u64, total - 1); + inline for (fields) |field| { + const weight = @field(weights, field.name); + if (pick < weight) return @as(Enum, @enumFromInt(field.value)); + pick -= weight; + } + unreachable; +} + +test enum_weighted { + const E = enum(u8) { a, b, c = 8 }; // 8 tests that the discriminant is used properly. + + var prng = from_seed(92); + var count: struct { a: u32 = 0, b: u32 = 0, c: u32 = 0 } = .{}; + for (0..1000) |_| { + switch (prng.enum_weighted(E, .{ .a = 0, .b = 1, .c = 2 })) { + inline else => |tag| @field(count, @tagName(tag)) += 1, + } + } + + try snap(@src(), + \\a=0 b=318 c=682 + ).diff_fmt("a={} b={} c={}", .{ count.a, count.b, count.c }); +} + +/// Return a distribution for use with `random_enum`. +/// +/// This is swarm testing: some variants are disabled completely, +/// and the rest have wildly different probabilities. +pub fn enum_weights( + prng: *PRNG, + comptime Enum: type, +) EnumWeightsType(Enum) { + const fields = comptime std.meta.fieldNames(Enum); + + var combination = PRNG.Combination.init(.{ + .total = fields.len, + .sample = prng.range_inclusive(u32, 1, fields.len), + }); + defer assert(combination.done()); + + var weights: PRNG.EnumWeightsType(Enum) = undefined; + inline for (fields) |field| { + @field(weights, field) = if (combination.take(prng)) + prng.range_inclusive(u64, 1, 100) + else + 0; + } + + return weights; +} + +/// An iterator-style API for selecting a random combination of elements. +pub const Combination = struct { + total: u32, + sample: u32, + + taken: u32, + seen: u32, + + pub fn init(options: struct { total: u32, sample: u32 }) Combination { + assert(options.sample <= options.total); + return .{ + .total = options.total, + .sample = options.sample, + .taken = 0, + .seen = 0, + }; + } + + pub fn done(combination: *const Combination) bool { + return combination.taken == combination.sample and + combination.seen == combination.total; + } + + pub fn take(combination: *Combination, prng: *PRNG) bool { + assert(combination.seen < combination.total); + assert(combination.taken <= combination.sample); + + const n = combination.total - combination.seen; + const k = combination.sample - combination.taken; + const result = prng.chance(ratio(k, n)); + + combination.seen += 1; + if (result) combination.taken += 1; + return result; + } +}; + +test Combination { + var prng = from_seed(92); + + const pool: [7]u8 = "abcdefg".*; + var result: [3]u8 = undefined; + var result_count: usize = 0; + + var e_taken_count: u32 = 0; + for (0..1000) |_| { + result_count = 0; + var combination = Combination.init(.{ .total = pool.len, .sample = 3 }); + for (pool) |x| { + if (combination.take(&prng)) { + result[result_count] = x; + result_count += 1; + } + } + assert(combination.done()); + assert(result_count == 3); + + e_taken_count += @intFromBool(std.mem.indexOfScalar(u8, &result, 'e') != null); + } + + try snap(@src(), + \\e_taken_count = 432 expected_value=428 + ).diff_fmt("e_taken_count = {} expected_value={}", .{ e_taken_count, 1000 * 3 / 7 }); +} + +/// An iterator style API for selecting a single element out of the given weighted sequence, +/// without a priori knowledge about the total weight. +pub const Reservoir = struct { + total: u64, + + pub fn init() Reservoir { + return .{ .total = 0 }; + } + + pub fn replace(reservoir: *Reservoir, prng: *PRNG, weight: u64) bool { + reservoir.total += weight; + return prng.chance(ratio(weight, reservoir.total)); + } +}; + +test Reservoir { + var prng = from_seed(92); + const animals: []const []const u8 = &.{ "walrus", "kiwi", "capybara", "platypus" }; + var kiwi_count: u32 = 0; + + for (0..1000) |_| { + var reservoir = Reservoir.init(); + var pick: ?[]const u8 = null; + for (animals) |animal| { + if (reservoir.replace(&prng, animal.len)) pick = animal; + } + assert(pick != null); + kiwi_count += @intFromBool(std.mem.eql(u8, pick.?, "kiwi")); + } + + var total_weight: u64 = 0; + for (animals) |animal| total_weight += animal.len; + const expected_value = 1000 * "kiwi".len / total_weight; + + try snap(@src(), + \\kiwi_count = 141 expected_value=153 + ).diff_fmt("kiwi_count = {} expected_value={}", .{ kiwi_count, expected_value }); +} + +pub fn shuffle(prng: *PRNG, T: type, slice: []T) void { + for (0..slice.len) |i| { + const j = prng.int_inclusive(u64, i); + std.mem.swap(T, &slice[i], &slice[j]); + } +} + +test shuffle { + var prng = from_seed(92); + var g_first_count: u32 = 0; + + for (0..1000) |_| { + var buffer = "abcdefg".*; + shuffle(&prng, u8, &buffer); + g_first_count += @intFromBool(buffer[0] == 'g'); + } + + try snap(@src(), + \\g_first_count = 152 expected_value=142 + ).diff_fmt("g_first_count = {} expected_value={}", .{ g_first_count, 1000 / 7 }); +} + +test "no floating point please" { + const path = try std.fs.path.join(std.testing.allocator, &.{ + module_path, + @src().file, + }); + defer std.testing.allocator.free(path); + + const file_text = try std.fs.cwd().readFileAlloc(std.testing.allocator, path, 64 * KiB); + defer std.testing.allocator.free(file_text); + + assert(std.mem.indexOf(u8, file_text, "f" ++ "32") == null); + assert(std.mem.indexOf(u8, file_text, "f" ++ "64") == null); +} + +// Automatically determine a reasonable amount of iterations for a unit fuzz-test, based on time. +pub const FuzzIterations = struct { + // Don't inject time for test-only code. + timer: ?std.time.Timer = null, + iteration: u32 = 0, + + iterations_min: u32 = 10, + duration_max: stdx.Duration = .ms(100), + + pub fn more(clock: *FuzzIterations) bool { + comptime assert(builtin.is_test); + if (clock.timer == null) { + clock.timer = std.time.Timer.start() catch @panic("timer failed"); + } + + if (clock.iteration > clock.iterations_min and + clock.timer.?.read() > clock.duration_max.ns) + { + return false; + } + + clock.iteration += 1; + return true; + } +}; diff --git a/ocam/src/stdx/radix.zig b/ocam/src/stdx/radix.zig new file mode 100644 index 00000000..e2df1fb7 --- /dev/null +++ b/ocam/src/stdx/radix.zig @@ -0,0 +1,332 @@ +//! Stable, non-allocating, out-of-place LSD radix sort over unsigned integer keys. +//! Sorts `values` in ascending order by `key_from_value`, using `values_scratch` +//! as an equally sized, disjoint swap buffer. Keys must be an unsigned `Int`. +//! The sorted result is in the original buffer `values`. +//! The implementation builds per-pass histograms, skips trivial passes (all items +//! in one bucket), and uses a fixed digit width (8 or 11 bits based on `Value` size) +//! to reduce the number of passes. Buffers are swapped after each non-trivial pass; +//! if the number of such passes is odd, results are copied back so `values` holds +//! the output on return. + +const std = @import("std"); +const assert = std.debug.assert; +const stdx = @import("stdx.zig"); + +/// Stable, ascending radix sort for unsigned integers. The sorted result will be in `values`. +pub fn sort( + comptime Key: type, + comptime Value: type, + comptime key_from_value: fn (*const Value) callconv(.@"inline") Key, + values: []Value, + values_scratch: []Value, +) void { + comptime { + assert(@typeInfo(Key) == .int); + assert(@typeInfo(Key).int.signedness == .unsigned); + } + + assert(stdx.disjoint_slices(Value, Value, values, values_scratch)); + assert(values.len == values_scratch.len); + assert(values.len <= std.math.maxInt(u32)); + + if (values.len == 0) return; + if (values.len <= 32) { + return std.sort.insertion(Value, values, {}, struct { + fn lessThan(_: void, a: Value, b: Value) bool { + return key_from_value(&a) < key_from_value(&b); + } + }.lessThan); + } + radix_sort(Key, Value, key_from_value, values, values_scratch); +} + +fn radix_sort( + comptime Key: type, + comptime Value: type, + comptime key_from_value: fn (*const Value) callconv(.@"inline") Key, + values: []Value, + values_scratch: []Value, +) void { + const count: u32 = @intCast(values.len); + // Heuristic: use more bits for larger value sizes to reduce the number of passes. + const radix_bits_heuristic = if (@sizeOf(Value) >= 128) 11 else 8; + const radix_bits = @min(@bitSizeOf(Key), radix_bits_heuristic); + const radix_passes = stdx.div_ceil(@bitSizeOf(Key), radix_bits); + const radix_partitions = 1 << radix_bits; + const radix_mask: u32 = radix_partitions - 1; + + const BitsKey = std.math.Log2Int(Key); // Used to shift the key for each pass. + const Histograms: type = [radix_passes][radix_partitions]u32; + comptime assert(@sizeOf(Histograms) <= 200 * stdx.KiB); + + // Create histograms per radix pass in a single iteration over `values`. + var histograms: Histograms align(64) = @splat(@splat(0)); + for (values) |*value| { + const key = key_from_value(value); + inline for (0..radix_passes) |pass| { + const pass_bit_offset: BitsKey = @intCast(pass * radix_bits); + const partition_id: u32 = @intCast((key >> pass_bit_offset) & radix_mask); + histograms[pass][partition_id] += 1; + } + } + + var source: []Value = values; + var target: []Value = values_scratch; + var target_offsets: [radix_partitions]u32 = @splat(0); + + inline for (histograms[0..radix_passes], 0..radix_passes) |*histogram, pass| { + // Determine if a pass is trivial if exactly one partition has all `count` elements. + const pass_trivial: bool = for (histogram) |partition_count| { + if (partition_count == count) break true; + } else false; + + if (!pass_trivial) { + // Build prefix sums. + var next_offset: u32 = 0; + for (0..radix_partitions) |partition_id| { + target_offsets[partition_id] = next_offset; + next_offset += histogram[partition_id]; + } + + // Partitioning pass. + const pass_bit_offset: BitsKey = @intCast(pass * radix_bits); + for (source) |*value| { + const key: Key = key_from_value(value); + const partition_id: u32 = @intCast((key >> pass_bit_offset) & radix_mask); + + target[target_offsets[partition_id]] = value.*; + target_offsets[partition_id] += 1; + } + std.mem.swap([]Value, &source, &target); + } + } + + // Copy the values back into the input buffer `values`. + if (values.ptr != source.ptr) { + stdx.copy_disjoint(.exact, Value, values, values_scratch); + } +} + +const ratio = stdx.PRNG.ratio; + +pub fn TestValueType(comptime Key: type, comptime value_length: usize) type { + return struct { + const Value = @This(); + + x: Key, + y: u32, // y ensures that values are distinct for the purpose of checking stability. + padding: [value_length]u8 = @splat(0), + + inline fn key_from_value(value: *const Value) Key { + return value.x; + } + + fn compare_x_ascending(_: void, a: Value, b: Value) bool { + return a.x < b.x; + } + + fn compare_x_descending(_: void, a: Value, b: Value) bool { + return a.x > b.x; + } + }; +} + +test "radix_sort: smoke" { + const Value = TestValueType(u8, 0); + var values: [5]Value = .{ + Value{ .x = 3, .y = 0 }, + Value{ .x = 2, .y = 0 }, + Value{ .x = 3, .y = 1 }, + Value{ .x = 1, .y = 0 }, + Value{ .x = 5, .y = 0 }, + }; + const values_expected: [5]Value = .{ + Value{ .x = 1, .y = 0 }, + Value{ .x = 2, .y = 0 }, + Value{ .x = 3, .y = 0 }, + Value{ .x = 3, .y = 1 }, + Value{ .x = 5, .y = 0 }, + }; + var values_scratch: [5]Value = undefined; + radix_sort( + u8, + Value, + Value.key_from_value, + &values, + &values_scratch, + ); + try std.testing.expectEqual(values_expected, values); +} + +// Ascending order + stability against a (x,y) baseline, with a large Value +// payload to exercise the 11-bit radix path (since @sizeOf(Value) >= 128). +test "radix_sort: ascending & stable on many duplicates" { + const Key = u32; + const Value = TestValueType(Key, 128); // >=128 so radix_bits heuristic picks 11 + const allocator = std.testing.allocator; + + const n: usize = 2048; + + const values = try allocator.alloc(Value, n); + defer allocator.free(values); + + const scratch = try allocator.alloc(Value, n); + defer allocator.free(scratch); + + // Many duplicates; y = original index (used to check stability). + for (values, 0..) |*v, i| { + const k: Key = @intCast(i % 257); + v.* = .{ .x = k, .y = @intCast(i) }; + } + + radix_sort(Key, Value, Value.key_from_value, values, scratch); + + // Verify that the order is `ascending` and `stable`. + for (values[0 .. values.len - 1], values[1..]) |a, b| { + switch (std.math.order(a.x, b.x)) { + .eq => try std.testing.expect(a.y < b.y), + .lt => try std.testing.expect(a.x < b.x), + .gt => unreachable, + } + } +} + +// All keys equal → every pass is "trivial". Sort should be a no-op on values, +// keep relative order (stability). +test "radix_sort: all-equal keys preserve relative order (stability)" { + const Key = u64; + const Value = TestValueType(Key, 8); + const allocator = std.testing.allocator; + + const n: usize = 1024; + + const values = try allocator.alloc(Value, n); + defer allocator.free(values); + + const scratch = try allocator.alloc(Value, n); + defer allocator.free(scratch); + + // Fill scratch with a sentinel to detect writes. + const sentinel: Value = .{ .x = 0xFFFF_FFFF_FFFF_FFFF, .y = 0xDEAD_BEEF }; + for (scratch) |*s| s.* = sentinel; + + // All keys identical; y = original index to check stability. + for (values, 0..) |*v, i| { + v.* = .{ .x = 42, .y = @intCast(i) }; + } + + radix_sort(Key, Value, Value.key_from_value, values, scratch); + + // Verify that the order is `ascending` and `stable`. + for (values[0 .. values.len - 1], values[1..]) |a, b| { + switch (std.math.order(a.x, b.x)) { + .eq => try std.testing.expect(a.y < b.y), + .lt => try std.testing.expect(a.x < b.x), + .gt => unreachable, + } + } +} + +test "fuzz radix_sort_stable" { + inline for (.{ + .{ u3, 0 }, // Smaller than radix bits. + .{ u256, 130 }, // Largest histogram, requires multiple passes and bit heuristic. + }) |pair| { + const Key = pair.@"0"; + const value_size_min = pair.@"1"; + const allocator = std.testing.allocator; + + const Value = TestValueType(Key, value_size_min); + + var prng = stdx.PRNG.from_seed_testing(); + + const values_max = 1 << 18; // Explores uneven and even passes to test copy back. + const values_all = try allocator.alloc(Value, values_max); + defer allocator.free(values_all); + + const values_all_scratch = try allocator.alloc(Value, values_max); + defer allocator.free(values_all_scratch); + + for (0..64) |_| { + const values_count = prng.range_inclusive(u32, 2, values_max); + const values = values_all[0..values_count]; + const values_scratch = values_all_scratch[0..values_count]; + + { + // Set up `values`. + for (values) |*value| { + value.* = .{ + .x = prng.int_inclusive(Key, @min( + std.math.maxInt(Key), + values_count * 2 - 1, + )), + .y = undefined, + }; + } + + // Sort algorithms often optimize the case of already-sorted + // (or already-reverse-sorted) sub-arrays. + const partitions_count = prng.range_inclusive( + u32, + 1, + @max(values_count, 64) - 1, + ); + // The `partition_reverse_probability` is a subset of the partitions sorted by + // `partition_sort_percent`. + const partition_sort_probability = ratio(prng.int_inclusive(u8, 100), 100); + const partition_reverse_probability = ratio(prng.int_inclusive(u8, 100), 100); + + var partitions_remaining: u32 = partitions_count; + var partition_offset: u32 = 0; + while (partition_offset < values_count) { + const partition_size = size: { + if (partitions_remaining == 1) { + break :size values_count - partition_offset; + } else { + break :size prng.range_inclusive( + u32, + 1, + values_count - partition_offset, + ); + } + }; + + if (prng.chance(partition_sort_probability)) { + const partition = values[partition_offset..][0..partition_size]; + if (prng.chance(partition_reverse_probability)) { + std.mem.sortUnstable( + Value, + partition, + {}, + Value.compare_x_descending, + ); + } else { + std.mem.sortUnstable( + Value, + partition, + {}, + Value.compare_x_ascending, + ); + } + } + + partitions_remaining -= 1; + partition_offset += partition_size; + } + + for (values, 0..) |*value, i| value.y = @intCast(i); + } + + radix_sort(Key, Value, Value.key_from_value, values, values_scratch); + + // Verify that the order is `ascending` and `stable`. + for (values[0 .. values.len - 1], values[1..]) |a, b| { + switch (std.math.order(a.x, b.x)) { + .eq => try std.testing.expect(a.y < b.y), + .lt => try std.testing.expect(a.x < b.x), + .gt => unreachable, + } + } + } + } +} diff --git a/ocam/src/stdx/radix_benchmark.zig b/ocam/src/stdx/radix_benchmark.zig new file mode 100644 index 00000000..140a1b5c --- /dev/null +++ b/ocam/src/stdx/radix_benchmark.zig @@ -0,0 +1,102 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const stdx = @import("stdx.zig"); +const Bench = stdx.Bench; +const radix = @import("radix.zig"); + +const repetitions: usize = 32; +const layouts = .{ + .{ .Key = u64, .value_bytes = 16 }, + .{ .Key = u128, .value_bytes = 32 }, + .{ .Key = u256, .value_bytes = 128 }, + .{ .Key = u64, .value_bytes = 128 }, +}; + +test "benchmark: radix sort" { + var bench: Bench = .init(); + defer bench.deinit(); + + const values_count: usize = @intCast(bench.parameter("values_count", 64, 1 << 20)); + + var arena_instance = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_instance.deinit(); + + const arena = arena_instance.allocator(); + + var prng = stdx.PRNG.from_seed(bench.seed); + var checksum: u64 = 0; + inline for (layouts) |layout| { + checksum +%= try run( + &bench, + layout.Key, + layout.value_bytes, + values_count, + arena, + &prng, + ); + } + bench.report("checksum={}", .{checksum}); +} + +fn run( + bench: *Bench, + comptime Key: type, + comptime value_bytes: usize, + values_count: usize, + arena: std.mem.Allocator, + prng: *stdx.PRNG, +) !u64 { + const Value = ValueType(Key, value_bytes); + const values_original = try arena.alignedAlloc(Value, 64, values_count); + const values = try arena.alignedAlloc(Value, 64, values_count); + const values_scratch = try arena.alignedAlloc(Value, 64, values_count); + + for (values_original) |*value| value.key = prng.int(Key); + + var duration_samples: [repetitions]stdx.Duration = undefined; + var checksum: u64 = 0; + for (&duration_samples) |*duration| { + stdx.copy_disjoint(.exact, Value, values, values_original); + + bench.start(); + radix.sort(Key, Value, Value.key_from_value, values, values_scratch); + duration.* = bench.stop(); + + assert(std.sort.isSorted(Value, values, {}, Value.less_than)); + checksum +%= @truncate(values[values.len / 2].key); + } + + const duration_overall = bench.estimate(&duration_samples); + const duration_element: stdx.Duration = .{ .ns = duration_overall.ns / values_count }; + const duration_key_byte: stdx.Duration = .{ .ns = duration_element.ns / @sizeOf(Key) }; + bench.report("K={:_>2}B V={:_>3}B: {d:.0}, {} per element, {} per key byte", .{ + @sizeOf(Key), + @sizeOf(Value), + duration_overall, + duration_element, + duration_key_byte, + }); + return checksum; +} + +fn ValueType(comptime Key: type, comptime value_bytes: usize) type { + return struct { + const Value = @This(); + + key: Key, + body: [value_bytes - @sizeOf(Key)]u8 = @splat(0), + + comptime { + assert(@sizeOf(Value) == value_bytes); + } + + inline fn key_from_value(value: *const Value) Key { + return value.key; + } + + fn less_than(_: void, a: Value, b: Value) bool { + return a.key < b.key; + } + }; +} diff --git a/ocam/src/stdx/ring_buffer.zig b/ocam/src/stdx/ring_buffer.zig new file mode 100644 index 00000000..45899b79 --- /dev/null +++ b/ocam/src/stdx/ring_buffer.zig @@ -0,0 +1,511 @@ +const std = @import("std"); +const assert = std.debug.assert; +const math = std.math; +const mem = std.mem; + +const stdx = @import("stdx.zig"); + +/// A First In, First Out ring buffer. +pub fn RingBufferType( + comptime T: type, + comptime buffer_type: union(enum) { + array: usize, // capacity + slice, // (Capacity is passed to init() at runtime). + }, +) type { + return struct { + const RingBuffer = @This(); + + pub const count_max = switch (buffer_type) { + .array => |count_max_| count_max_, + .slice => {}, + }; + + buffer: switch (buffer_type) { + .array => |count_max_| [count_max_]T, + .slice => []T, + }, + + /// The index of the slot with the first item, if any. + index: usize = 0, + + /// The number of items in the buffer. + count: usize = 0, + + pub const init = switch (buffer_type) { + .array => init_array, + .slice => init_slice, + }; + + fn init_array() RingBuffer { + comptime assert(buffer_type == .array); + return .{ .buffer = undefined }; + } + + fn init_slice(allocator: mem.Allocator, capacity: usize) !RingBuffer { + comptime assert(buffer_type == .slice); + assert(capacity > 0); + + const buffer = try allocator.alloc(T, capacity); + errdefer allocator.free(buffer); + return RingBuffer{ .buffer = buffer }; + } + + pub const deinit = switch (buffer_type) { + .array => {}, + .slice => deinit_slice, + }; + + fn deinit_slice(self: *RingBuffer, allocator: mem.Allocator) void { + comptime assert(buffer_type == .slice); + allocator.free(self.buffer); + } + + pub inline fn clear(self: *RingBuffer) void { + self.index = 0; + self.count = 0; + } + + // TODO Add doc comments to these functions: + pub inline fn head(self: RingBuffer) ?T { + if (self.buffer.len == 0 or self.empty()) return null; + return self.buffer[self.index]; + } + + pub inline fn head_ptr(self: *RingBuffer) ?*T { + if (self.buffer.len == 0 or self.empty()) return null; + return &self.buffer[self.index]; + } + + pub inline fn head_ptr_const(self: *const RingBuffer) ?*const T { + if (self.buffer.len == 0 or self.empty()) return null; + return &self.buffer[self.index]; + } + + pub inline fn tail(self: RingBuffer) ?T { + if (self.buffer.len == 0 or self.empty()) return null; + return self.buffer[(self.index + self.count - 1) % self.buffer.len]; + } + + pub inline fn tail_ptr(self: *RingBuffer) ?*T { + if (self.buffer.len == 0 or self.empty()) return null; + return &self.buffer[(self.index + self.count - 1) % self.buffer.len]; + } + + pub inline fn tail_ptr_const(self: *const RingBuffer) ?*const T { + if (self.buffer.len == 0 or self.empty()) return null; + return &self.buffer[(self.index + self.count - 1) % self.buffer.len]; + } + + pub fn get(self: *const RingBuffer, index: usize) ?T { + if (self.buffer.len == 0) unreachable; + + if (index < self.count) { + return self.buffer[(self.index + index) % self.buffer.len]; + } else { + assert(index < self.buffer.len); + return null; + } + } + + pub inline fn get_ptr(self: *RingBuffer, index: usize) ?*T { + if (self.buffer.len == 0) unreachable; + + if (index < self.count) { + return &self.buffer[(self.index + index) % self.buffer.len]; + } else { + assert(index < self.buffer.len); + return null; + } + } + + pub inline fn next_tail(self: RingBuffer) ?T { + if (self.buffer.len == 0 or self.full()) return null; + return self.buffer[(self.index + self.count) % self.buffer.len]; + } + + pub inline fn next_tail_ptr(self: *RingBuffer) ?*T { + if (self.buffer.len == 0 or self.full()) return null; + return &self.buffer[(self.index + self.count) % self.buffer.len]; + } + + pub inline fn next_tail_ptr_const(self: *const RingBuffer) ?*const T { + if (self.buffer.len == 0 or self.full()) return null; + return &self.buffer[(self.index + self.count) % self.buffer.len]; + } + + pub inline fn advance_head(self: *RingBuffer) void { + self.index += 1; + self.index %= self.buffer.len; + self.count -= 1; + } + + pub fn advance_head_many(self: *RingBuffer, discard: u64) void { + assert(discard <= self.count); + if (self.buffer.len == 0) return; + + self.index += discard; + self.index %= self.buffer.len; + self.count -= discard; + } + + pub inline fn retreat_head(self: *RingBuffer) void { + assert(self.count < self.buffer.len); + + // This condition is covered by the above assert, but it is necessary to make it + // explicitly unreachable so that the compiler doesn't error when computing (at + // comptime) `buffer.len - 1` for a zero-capacity array-backed ring buffer. + if (self.buffer.len == 0) unreachable; + + self.index += self.buffer.len - 1; + self.index %= self.buffer.len; + self.count += 1; + } + + pub inline fn advance_tail(self: *RingBuffer) void { + assert(self.count < self.buffer.len); + self.count += 1; + } + + pub inline fn retreat_tail(self: *RingBuffer) void { + self.count -= 1; + } + + /// Returns whether the ring buffer is completely full. + pub inline fn full(self: RingBuffer) bool { + return self.count == self.buffer.len; + } + + pub inline fn spare_capacity(self: RingBuffer) usize { + return self.buffer.len - self.count; + } + + /// Returns whether the ring buffer is completely empty. + pub inline fn empty(self: RingBuffer) bool { + return self.count == 0; + } + + // Higher level, less error-prone wrappers: + + pub fn push_head(self: *RingBuffer, item: T) error{NoSpaceLeft}!void { + if (self.count == self.buffer.len) return error.NoSpaceLeft; + self.push_head_assume_capacity(item); + } + + pub fn push_head_assume_capacity(self: *RingBuffer, item: T) void { + assert(self.count < self.buffer.len); + + self.retreat_head(); + self.head_ptr().?.* = item; + } + + /// Add an element to the RingBuffer. Returns an error if the buffer + /// is already full and the element could not be added. + pub fn push(self: *RingBuffer, item: T) error{NoSpaceLeft}!void { + const ptr = self.next_tail_ptr() orelse return error.NoSpaceLeft; + ptr.* = item; + self.advance_tail(); + } + + /// Add an element to a RingBuffer, and assert that the capacity is sufficient. + pub fn push_assume_capacity(self: *RingBuffer, item: T) void { + self.push(item) catch |err| switch (err) { + error.NoSpaceLeft => unreachable, + }; + } + + pub fn push_slice(self: *RingBuffer, items: []const T) error{NoSpaceLeft}!void { + if (self.buffer.len == 0) return error.NoSpaceLeft; + if (self.count + items.len > self.buffer.len) return error.NoSpaceLeft; + + const pre_wrap_start = (self.index + self.count) % self.buffer.len; + const pre_wrap_count = @min(items.len, self.buffer.len - pre_wrap_start); + const post_wrap_count = items.len - pre_wrap_count; + + const pre_wrap_items = items[0..pre_wrap_count]; + const post_wrap_items = items[pre_wrap_count..]; + stdx.copy_disjoint(.inexact, T, self.buffer[pre_wrap_start..], pre_wrap_items); + stdx.copy_disjoint(.exact, T, self.buffer[0..post_wrap_count], post_wrap_items); + + self.count += items.len; + } + + /// Remove and return the next item, if any. + pub fn pop(self: *RingBuffer) ?T { + const result = self.head() orelse return null; + self.advance_head(); + return result; + } + + /// Remove and return the last item, if any. + pub fn pop_tail(self: *RingBuffer) ?T { + const result = self.tail() orelse return null; + self.retreat_tail(); + return result; + } + + pub const Iterator = struct { + ring: *const RingBuffer, + count: usize = 0, + + pub fn next(it: *Iterator) ?T { + if (it.next_ptr()) |item| { + return item.*; + } + return null; + } + + pub fn next_ptr(it: *Iterator) ?*const T { + assert(it.count <= it.ring.count); + if (it.ring.buffer.len == 0) return null; + if (it.count == it.ring.count) return null; + defer it.count += 1; + + return &it.ring.buffer[(it.ring.index + it.count) % it.ring.buffer.len]; + } + }; + + /// Returns an iterator to iterate through all `count` items in the ring buffer. + /// The iterator is invalidated if the ring buffer is advanced. + pub fn iterator(self: *const RingBuffer) Iterator { + return .{ .ring = self }; + } + + pub const IteratorMutable = struct { + ring: *RingBuffer, + count: usize = 0, + + pub fn next_ptr(it: *IteratorMutable) ?*T { + assert(it.count <= it.ring.count); + if (it.ring.buffer.len == 0) return null; + if (it.count == it.ring.count) return null; + defer it.count += 1; + + return &it.ring.buffer[(it.ring.index + it.count) % it.ring.buffer.len]; + } + }; + + pub fn iterator_mutable(self: *RingBuffer) IteratorMutable { + return .{ .ring = self }; + } + }; +} + +const testing = std.testing; + +fn test_iterator(comptime T: type, ring: *T, values: []const u32) !void { + const ring_index = ring.index; + + inline for (.{ .immutable, .mutable }) |mutability| { + for (0..2) |_| { + var iterator = switch (mutability) { + .immutable => ring.iterator(), + .mutable => ring.iterator_mutable(), + else => unreachable, + }; + var index: u32 = 0; + switch (mutability) { + .immutable => while (iterator.next()) |item| { + try testing.expectEqual(values[index], item); + index += 1; + }, + .mutable => { + const permutation = @divFloor(std.math.maxInt(u32), 2); + while (iterator.next_ptr()) |item| { + try testing.expectEqual(values[index], item.*); + item.* += permutation + index; + index += 1; + } + iterator = ring.iterator_mutable(); + var check_index: u32 = 0; + while (iterator.next_ptr()) |item| { + try testing.expectEqual( + values[check_index] + permutation + check_index, + item.*, + ); + item.* -= permutation + check_index; + check_index += 1; + } + try testing.expectEqual(index, check_index); + }, + else => unreachable, + } + try testing.expectEqual(values.len, index); + } + + try testing.expectEqual(ring_index, ring.index); + } +} + +fn test_low_level_interface(comptime Ring: type, ring: *Ring) !void { + try ring.push_slice(&[_]u32{}); + try test_iterator(Ring, ring, &[_]u32{}); + + try testing.expectError(error.NoSpaceLeft, ring.push_slice(&[_]u32{ 1, 2, 3 })); + + try ring.push_slice(&[_]u32{1}); + try testing.expectEqual(@as(?u32, 1), ring.tail()); + try testing.expectEqual(@as(u32, 1), ring.tail_ptr().?.*); + ring.advance_head(); + + try testing.expectEqual(@as(usize, 1), ring.index); + try testing.expectEqual(@as(usize, 0), ring.count); + try ring.push_slice(&[_]u32{ 1, 2 }); + try test_iterator(Ring, ring, &[_]u32{ 1, 2 }); + ring.advance_head(); + ring.advance_head(); + + try testing.expectEqual(@as(usize, 1), ring.index); + try testing.expectEqual(@as(usize, 0), ring.count); + try ring.push_slice(&[_]u32{1}); + try testing.expectEqual(@as(?u32, 1), ring.tail()); + try testing.expectEqual(@as(u32, 1), ring.tail_ptr().?.*); + ring.advance_head(); + + try testing.expectEqual(@as(?u32, null), ring.head()); + try testing.expectEqual(@as(?*u32, null), ring.head_ptr()); + try testing.expectEqual(@as(?u32, null), ring.tail()); + try testing.expectEqual(@as(?*u32, null), ring.tail_ptr()); + + ring.next_tail_ptr().?.* = 0; + ring.advance_tail(); + try testing.expectEqual(@as(?u32, 0), ring.tail()); + try testing.expectEqual(@as(u32, 0), ring.tail_ptr().?.*); + try test_iterator(Ring, ring, &[_]u32{0}); + + ring.next_tail_ptr().?.* = 1; + ring.advance_tail(); + try testing.expectEqual(@as(?u32, 1), ring.tail()); + try testing.expectEqual(@as(u32, 1), ring.tail_ptr().?.*); + try test_iterator(Ring, ring, &[_]u32{ 0, 1 }); + + try testing.expectEqual(@as(?u32, null), ring.next_tail()); + try testing.expectEqual(@as(?*u32, null), ring.next_tail_ptr()); + + try testing.expectEqual(@as(?u32, 0), ring.head()); + try testing.expectEqual(@as(u32, 0), ring.head_ptr().?.*); + ring.advance_head(); + try test_iterator(Ring, ring, &[_]u32{1}); + + ring.next_tail_ptr().?.* = 2; + ring.advance_tail(); + try testing.expectEqual(@as(?u32, 2), ring.tail()); + try testing.expectEqual(@as(u32, 2), ring.tail_ptr().?.*); + try test_iterator(Ring, ring, &[_]u32{ 1, 2 }); + + ring.advance_head(); + try test_iterator(Ring, ring, &[_]u32{2}); + + ring.next_tail_ptr().?.* = 3; + ring.advance_tail(); + try testing.expectEqual(@as(?u32, 3), ring.tail()); + try testing.expectEqual(@as(u32, 3), ring.tail_ptr().?.*); + try test_iterator(Ring, ring, &[_]u32{ 2, 3 }); + + try testing.expectEqual(@as(?u32, 2), ring.head()); + try testing.expectEqual(@as(u32, 2), ring.head_ptr().?.*); + ring.advance_head(); + try test_iterator(Ring, ring, &[_]u32{3}); + + try testing.expectEqual(@as(?u32, 3), ring.head()); + try testing.expectEqual(@as(u32, 3), ring.head_ptr().?.*); + ring.advance_head(); + try test_iterator(Ring, ring, &[_]u32{}); + + try testing.expectEqual(@as(?u32, null), ring.head()); + try testing.expectEqual(@as(?*u32, null), ring.head_ptr()); + try testing.expectEqual(@as(?u32, null), ring.tail()); + try testing.expectEqual(@as(?*u32, null), ring.tail_ptr()); +} + +test "RingBuffer: low level interface" { + const ArrayRing = RingBufferType(u32, .{ .array = 2 }); + var array_ring = ArrayRing.init(); + try test_low_level_interface(ArrayRing, &array_ring); + + const PointerRing = RingBufferType(u32, .slice); + var pointer_ring = try PointerRing.init(testing.allocator, 2); + defer pointer_ring.deinit(testing.allocator); + + try test_low_level_interface(PointerRing, &pointer_ring); +} + +test "RingBuffer: push/pop high level interface" { + var fifo = RingBufferType(u32, .{ .array = 3 }).init(); + + try testing.expect(!fifo.full()); + try testing.expect(fifo.empty()); + try testing.expectEqual(@as(?*u32, null), fifo.get_ptr(0)); + try testing.expectEqual(@as(?*u32, null), fifo.get_ptr(1)); + try testing.expectEqual(@as(?*u32, null), fifo.get_ptr(2)); + + try fifo.push(1); + try testing.expectEqual(@as(?u32, 1), fifo.head()); + try testing.expectEqual(@as(u32, 1), fifo.get_ptr(0).?.*); + try testing.expectEqual(@as(?*u32, null), fifo.get_ptr(1)); + + try testing.expect(!fifo.full()); + try testing.expect(!fifo.empty()); + + try fifo.push(2); + try testing.expectEqual(@as(?u32, 1), fifo.head()); + try testing.expectEqual(@as(u32, 2), fifo.get_ptr(1).?.*); + + try fifo.push(3); + try testing.expectError(error.NoSpaceLeft, fifo.push(4)); + + try testing.expect(fifo.full()); + try testing.expect(!fifo.empty()); + + try testing.expectEqual(@as(?u32, 1), fifo.head()); + try testing.expectEqual(@as(?u32, 1), fifo.pop()); + try testing.expectEqual(@as(u32, 2), fifo.get_ptr(0).?.*); + try testing.expectEqual(@as(u32, 3), fifo.get_ptr(1).?.*); + try testing.expectEqual(@as(?*u32, null), fifo.get_ptr(2)); + + try testing.expect(!fifo.full()); + try testing.expect(!fifo.empty()); + + try fifo.push(4); + + try testing.expectEqual(@as(?u32, 2), fifo.pop()); + try testing.expectEqual(@as(?u32, 3), fifo.pop()); + try testing.expectEqual(@as(?u32, 4), fifo.pop()); + try testing.expectEqual(@as(?u32, null), fifo.pop()); + + try testing.expect(!fifo.full()); + try testing.expect(fifo.empty()); +} + +test "RingBuffer: pop_tail" { + var lifo = RingBufferType(u32, .{ .array = 3 }).init(); + try lifo.push(1); + try lifo.push(2); + try lifo.push(3); + try testing.expect(lifo.full()); + + try testing.expectEqual(@as(?u32, 3), lifo.pop_tail()); + try testing.expectEqual(@as(?u32, 1), lifo.head()); + try testing.expectEqual(@as(?u32, 2), lifo.pop_tail()); + try testing.expectEqual(@as(?u32, 1), lifo.head()); + try testing.expectEqual(@as(?u32, 1), lifo.pop_tail()); + try testing.expectEqual(@as(?u32, null), lifo.pop_tail()); + try testing.expect(lifo.empty()); +} + +test "RingBuffer: push_head" { + var ring = RingBufferType(u32, .{ .array = 3 }).init(); + try ring.push_head(1); + try ring.push(2); + try ring.push_head(3); + try testing.expect(ring.full()); + + try testing.expectEqual(@as(?u32, 3), ring.pop()); + try testing.expectEqual(@as(?u32, 1), ring.pop()); + try testing.expectEqual(@as(?u32, 2), ring.pop()); + try testing.expect(ring.empty()); +} + +test "RingBuffer: count_max=0" { + std.testing.refAllDecls(RingBufferType(u32, .{ .array = 0 })); +} diff --git a/ocam/src/stdx/shell.zig b/ocam/src/stdx/shell.zig new file mode 100644 index 00000000..2ea8d227 --- /dev/null +++ b/ocam/src/stdx/shell.zig @@ -0,0 +1,1243 @@ +//! Collection of utilities for scripting: an in-process sh+coreutils combo. +//! +//! Keep this as a single file, independent from the rest of the codebase, to make it easier to +//! reuse across different processes (eg build.zig). +//! +//! If possible, avoid shelling out to `sh` or other systems utils --- the whole purpose here is to +//! avoid any extra dependencies. +//! +//! The `exec_` family of methods provides a convenience wrapper around `std.process.Child`: +//! - It allows constructing the array of arguments using convenient interpolation syntax a-la +//! `std.fmt` (but of course no actual string concatenation happens anywhere). +//! - `ChildProcess` is versatile and has many knobs, but they might be hard to use correctly (eg, +//! its easy to forget to check exit status). `Shell` instead is focused on providing a set of +//! specific narrow use-cases (eg, parsing the output of a subprocess) and takes care of setting +//! the right defaults. + +const std = @import("std"); +const stdx = @import("stdx.zig"); +const log = std.log; +const builtin = @import("builtin"); +const assert = std.debug.assert; + +const Shell = @This(); + +const MiB = stdx.MiB; + +const cwd_stack_max = 16; + +/// For internal use by the `Shell` itself. +gpa: std.mem.Allocator, + +/// To improve ergonomics, any returned data is owned by the `Shell` and is stored in this arena. +/// This way, the user doesn't need to worry about deallocating each individual string, as long as +/// they don't forget to call `Shell.destroy`. +arena: std.heap.ArenaAllocator, + +/// Root directory of this repository. +/// +/// This is initialized when a shell is created. It would be more flexible to lazily initialize this +/// on the first access, but, given that we always use `Shell` in the context of our repository, +/// eager initialization is more ergonomic. +project_root: std.fs.Dir, + +/// Shell's logical cwd which is used for all functions in this file. It might be different from +/// `std.fs.cwd()` and is set to `project_root` on init. +cwd: std.fs.Dir, + +// Stack of working directories backing pushd/popd. +cwd_stack: [cwd_stack_max]std.fs.Dir, +cwd_stack_count: usize, + +// Zig uses file-descriptor oriented APIs in the standard library, with the one exception being +// ChildProcess's cwd, which is required to be a path, rather than a file descriptor. This buffer +// is used to materialize the path to cwd when spawning a new process. +// +cwd_path_buffer: [std.fs.max_path_bytes]u8 = undefined, + +env: std.process.EnvMap, + +/// True if the process is run in CI (the CI env var is set) +ci: bool, + +/// Absolute path to the Zig binary. +zig_exe: ?[]const u8, + +pub fn create(gpa: std.mem.Allocator) !*Shell { + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + + var project_root = try discover_project_root(); + errdefer project_root.close(); + + var cwd = try project_root.openDir(".", .{}); + errdefer cwd.close(); + + var env = try std.process.getEnvMap(gpa); + errdefer env.deinit(); + + const ci = env.get("CI") != null; + + const result = try gpa.create(Shell); + errdefer gpa.destroy(result); + + result.* = Shell{ + .gpa = gpa, + .arena = arena, + .project_root = project_root, + .cwd = cwd, + .cwd_stack = undefined, + .cwd_stack_count = 0, + .env = env, + .ci = ci, + .zig_exe = env.get("ZIG_EXE"), + }; + + return result; +} + +pub fn destroy(shell: *Shell) void { + const gpa = shell.gpa; + + assert(shell.cwd_stack_count == 0); // pushd not paired by popd + + shell.env.deinit(); + shell.cwd.close(); + shell.project_root.close(); + shell.arena.deinit(); + gpa.destroy(shell); +} + +const ansi = .{ + .red = "\x1b[0;31m", + .reset = "\x1b[0m", +}; + +/// Prints formatted input to stderr. +/// Newline symbol is appended automatically. +/// ANSI colors are supported via `"{ansi-red}my colored text{ansi-reset}"` syntax. +pub fn echo(shell: *Shell, comptime format: []const u8, format_args: anytype) void { + _ = shell; + + comptime var format_ansi: []const u8 = ""; + comptime var pos: usize = 0; + comptime var pos_start: usize = 0; + + comptime next_pos: while (pos < format.len) { + if (format[pos] == '{') { + for (std.meta.fieldNames(@TypeOf(ansi))) |field_name| { + const tag = "{ansi-" ++ field_name ++ "}"; + if (std.mem.startsWith(u8, format[pos..], tag)) { + format_ansi = format_ansi ++ format[pos_start..pos] ++ @field(ansi, field_name); + pos += tag.len; + pos_start = pos; + continue :next_pos; + } + } + } + pos += 1; + }; + comptime assert(pos == format.len); + + format_ansi = format_ansi ++ format[pos_start..pos] ++ "\n"; + + std.debug.print(format_ansi, format_args); +} + +/// Opens a logical, named section of the script. +/// When the section is subsequently closed, its name and timing are printed. +/// Additionally on CI output from a section gets into a named, foldable group. +pub fn open_section(shell: *Shell, name: []const u8) !Section { + return Section.open(shell.ci, name); +} + +const Section = struct { + ci: bool, + name: []const u8, + timer: std.time.Timer, + + fn open(ci: bool, name: []const u8) !Section { + if (ci) { + // See + // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#grouping-log-lines + // https://github.com/actions/toolkit/issues/1001 + try std.io.getStdOut().writer().print("::group::{s}\n", .{name}); + } + + return .{ + .ci = ci, + .name = name, + .timer = try std.time.Timer.start(), + }; + } + + pub fn close(section: *Section) void { + const elapsed_ns = section.timer.lap(); + std.debug.print("{s}: {}\n", .{ section.name, std.fmt.fmtDuration(elapsed_ns) }); + if (section.ci) { + std.io.getStdOut().writer().print("::endgroup::\n", .{}) catch {}; + } + section.* = undefined; + } +}; + +/// Convenience string formatting function which uses shell's arena and doesn't require +/// freeing the resulting string. +pub fn fmt(shell: *Shell, comptime format: []const u8, format_args: anytype) ![]const u8 { + return std.fmt.allocPrint(shell.arena.allocator(), format, format_args); +} + +pub fn env_get_option(shell: *Shell, var_name: []const u8) ?[]const u8 { + return std.process.getEnvVarOwned(shell.arena.allocator(), var_name) catch null; +} + +pub fn env_get(shell: *Shell, var_name: []const u8) ![]const u8 { + errdefer { + log.warn("environment variable '{s}' not defined", .{var_name}); + } + + return try std.process.getEnvVarOwned(shell.arena.allocator(), var_name); +} + +/// Change `shell`'s working directory. It *must* be followed by +/// +/// defer shell.popd(); +/// +/// to restore the previous directory back. +pub fn pushd(shell: *Shell, path: []const u8) !void { + assert(shell.cwd_stack_count < cwd_stack_max); + // allow only explicitly relative paths or absolute paths + assert(path[0] == '.' or path[0] == '/'); + + const cwd_new = try shell.cwd.openDir(path, .{}); + + shell.cwd_stack[shell.cwd_stack_count] = shell.cwd; + shell.cwd_stack_count += 1; + shell.cwd = cwd_new; +} + +pub fn pushd_dir(shell: *Shell, dir: std.fs.Dir) !void { + assert(shell.cwd_stack_count < cwd_stack_max); + + // Re-open the directory such that `popd` can close it. + const cwd_new = try dir.openDir(".", .{}); + + shell.cwd_stack[shell.cwd_stack_count] = shell.cwd; + shell.cwd_stack_count += 1; + shell.cwd = cwd_new; +} + +pub fn popd(shell: *Shell) void { + shell.cwd.close(); + shell.cwd_stack_count -= 1; + shell.cwd = shell.cwd_stack[shell.cwd_stack_count]; +} + +/// Checks if the path exists and is a directory. +/// +/// Note: this api is prone to TOCTOU and exists primarily for assertions. +pub fn dir_exists(shell: *Shell, path: []const u8) !bool { + return subdir_exists(shell.cwd, path); +} + +/// Checks if the path exists and is a file. +/// +/// Note: this api is prone to TOCTOU and exists primarily for assertions. +pub fn file_exists(shell: *Shell, path: []const u8) bool { + const stat = shell.cwd.statFile(path) catch return false; + return stat.kind == .file; +} + +pub fn file_make_executable(shell: *Shell, path: []const u8) !void { + if (builtin.os.tag != .windows) { + const fd = try shell.cwd.openFile(path, .{ .mode = .read_write }); + defer fd.close(); + + try fd.chmod(0o755); + } +} + +fn subdir_exists(dir: std.fs.Dir, path: []const u8) !bool { + const stat = dir.statFile(path) catch |err| switch (err) { + error.FileNotFound => return false, + error.IsDir => return true, + else => return err, + }; + + return stat.kind == .directory; +} + +pub fn file_ensure_content( + shell: *Shell, + path: []const u8, + content: []const u8, + create_flags: std.fs.File.CreateFlags, +) !enum { unchanged, updated } { + const max_bytes = 1 * MiB; + const content_current = shell.cwd.readFileAlloc(shell.gpa, path, max_bytes) catch null; + defer if (content_current) |slice| shell.gpa.free(slice); + + if (content_current != null and std.mem.eql(u8, content_current.?, content)) { + return .unchanged; + } + + try shell.cwd.writeFile(.{ .sub_path = path, .data = content, .flags = create_flags }); + return .updated; +} + +/// Creates a new temporary directory (in the project-level .zig-cache) and returns the +/// absolute path. +/// +/// It's the callers responsibility to delete the directory when done with it, e.g. +/// with `defer shell.cwd.deleteTree(dir) catch {};`. +pub fn create_tmp_dir( + shell: *Shell, +) ![]const u8 { + const root = try shell.project_root.realpathAlloc(shell.arena.allocator(), "."); + const tmp_absolute = try shell.fmt("{s}/.zig-cache/tmp/{}", .{ + root, + std.crypto.random.int(u64), + }); + assert(!try shell.dir_exists(tmp_absolute)); + try shell.project_root.makePath(tmp_absolute); + return tmp_absolute; +} + +const FindOptions = struct { + where: []const []const u8, + extension: ?[]const u8 = null, + extensions: ?[]const []const u8 = null, +}; + +/// Analogue of the `find` utility, returns a set of paths matching filtering criteria. +/// +/// Returned slice is stored in `Shell.arena`. +pub fn find(shell: *Shell, options: FindOptions) ![]const []const u8 { + if (options.extension != null and options.extensions != null) { + @panic("conflicting extension filters"); + } + if (options.extension) |extension| { + assert(extension[0] == '.'); + } + if (options.extensions) |extensions| { + for (extensions) |extension| { + assert(extension[0] == '.'); + } + } + + var result = std.ArrayList([]const u8).init(shell.arena.allocator()); + + for (options.where) |base_path| { + var base_dir = try shell.cwd.openDir(base_path, .{ .iterate = true }); + defer base_dir.close(); + + var walker = try base_dir.walk(shell.gpa); + defer walker.deinit(); + + while (try walker.next()) |entry| { + if (entry.kind == .file and find_filter_path(entry.path, options)) { + const full_path = + try std.fs.path.join(shell.arena.allocator(), &.{ base_path, entry.path }); + try result.append(full_path); + } + } + } + + return result.items; +} + +fn find_filter_path(path: []const u8, options: FindOptions) bool { + if (options.extension == null and options.extensions == null) return true; + if (options.extension != null and options.extensions != null) @panic("conflicting filters"); + + if (options.extension) |extension| { + return std.mem.endsWith(u8, path, extension); + } + + if (options.extensions) |extensions| { + for (extensions) |extension| { + if (std.mem.endsWith(u8, path, extension)) return true; + } + return false; + } + + unreachable; +} + +/// Copy file, creating the destination directory as necessary. +pub fn copy_path( + src_dir: std.fs.Dir, + src_path: []const u8, + dst_dir: std.fs.Dir, + dst_path: []const u8, +) !void { + errdefer { + log.warn("failed to copy {s} to {s}", .{ src_path, dst_path }); + } + if (std.fs.path.dirname(dst_path)) |dir| { + try dst_dir.makePath(dir); + } + try src_dir.copyFile(src_path, dst_dir, dst_path, .{}); +} + +/// Runs the given command for side effects. +/// Returns an error if exit status is non-zero. +/// +/// Supports interpolation using the following syntax: +/// +/// ``` +/// shell.exec("git branch {op} {branches}", .{ +/// .op = "-D", +/// .branches = &.{"main", "feature"}, +/// }) +/// ``` +pub fn exec(shell: *Shell, comptime cmd: []const u8, cmd_args: anytype) !void { + var argv = try Argv.expand(shell.gpa, cmd, cmd_args); + defer argv.deinit(); + + return exec_inner(shell, argv.slice(), .{}); +} + +pub fn exec_options( + shell: *Shell, + options: struct { + stdin_slice: ?[]const u8 = null, + timeout: stdx.Duration = .minutes(10), + }, + comptime cmd: []const u8, + cmd_args: anytype, +) !void { + var argv = try Argv.expand(shell.gpa, cmd, cmd_args); + defer argv.deinit(); + + return exec_inner(shell, argv.slice(), .{ + .stdin_slice = options.stdin_slice, + .timeout = options.timeout, + }); +} + +/// Runs the given command and returns its output. +/// If the output is a single line, the final newline is stripped. +pub fn exec_stdout(shell: *Shell, comptime cmd: []const u8, cmd_args: anytype) ![]const u8 { + var argv = try Argv.expand(shell.gpa, cmd, cmd_args); + defer argv.deinit(); + + var captured_stdout: []const u8 = &.{}; + try exec_inner(shell, argv.slice(), .{ + .capture_stdout = &captured_stdout, + }); + return captured_stdout; +} + +pub fn exec_stdout_stderr(shell: *Shell, comptime cmd: []const u8, cmd_args: anytype) !struct { + []const u8, + []const u8, +} { + var argv = try Argv.expand(shell.gpa, cmd, cmd_args); + defer argv.deinit(); + + var captured_stdout: []const u8 = &.{}; + var captured_stderr: []const u8 = &.{}; + try exec_inner(shell, argv.slice(), .{ + .capture_stdout = &captured_stdout, + .capture_stderr = &captured_stderr, + }); + return .{ captured_stdout, captured_stderr }; +} + +pub fn exec_stdout_options( + shell: *Shell, + options: struct { + stdin_slice: ?[]const u8 = null, + timeout: stdx.Duration = .minutes(10), + }, + comptime cmd: []const u8, + cmd_args: anytype, +) ![]const u8 { + var argv = try Argv.expand(shell.gpa, cmd, cmd_args); + defer argv.deinit(); + + var captured_stdout: []const u8 = &.{}; + try exec_inner(shell, argv.slice(), .{ + .stdin_slice = options.stdin_slice, + .capture_stdout = &captured_stdout, + .timeout = options.timeout, + }); + return captured_stdout; +} + +/// Runs the zig compiler. +pub fn exec_zig(shell: *Shell, comptime cmd: []const u8, cmd_args: anytype) !void { + return shell.exec_zig_options(.{}, cmd, cmd_args); +} + +pub fn exec_zig_options( + shell: *Shell, + options: struct { + timeout: stdx.Duration = .minutes(10), + capture_stdout: ?*[]const u8 = null, + capture_stderr: ?*[]const u8 = null, + }, + comptime cmd: []const u8, + cmd_args: anytype, +) !void { + var argv = Argv.init(shell.gpa); + defer argv.deinit(); + + try argv.append_new_arg("{s}", .{shell.zig_exe.?}); + try expand_argv(&argv, cmd, cmd_args); + + return shell.exec_inner(argv.slice(), .{ + .timeout = options.timeout, + .capture_stdout = options.capture_stdout, + .capture_stderr = options.capture_stderr, + }); +} + +fn exec_inner( + shell: *Shell, + argv: []const []const u8, + options: struct { + stdin_slice: ?[]const u8 = null, + + // Optional out parameters: + capture_stdout: ?*[]const u8 = null, + capture_stderr: ?*[]const u8 = null, + + output_limit_bytes: usize = 128 * MiB, + timeout: stdx.Duration = .minutes(10), + }, +) !void { + const argv_formatted = try std.mem.join(shell.gpa, " ", argv); + defer shell.gpa.free(argv_formatted); + + var stdin_writer: ?std.Thread = null; + defer if (stdin_writer) |thread| thread.join(); + + const Streams = enum { stdout, stderr }; + var poller: ?std.io.Poller(Streams) = null; + defer if (poller) |*p| p.deinit(); + + errdefer |err| { + log.err("process failed with {s}: {s}", .{ @errorName(err), argv_formatted }); + if (poller) |*p| { + inline for (comptime std.enums.values(Streams)) |stream| { + if (p.fifo(stream).count > 0) { + log.err("{s}:\n++++\n{s}++++\n", .{ + @tagName(stream), + p.fifo(stream).readableSlice(0), + }); + } + } + } + } + + var child = std.process.Child.init(argv, shell.gpa); + child.cwd = try shell.cwd.realpath(".", &shell.cwd_path_buffer); + child.env_map = &shell.env; + child.stdin_behavior = if (options.stdin_slice != null) .Pipe else .Ignore; + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + try child.spawn(); + errdefer { + _ = child.kill() catch {}; + } + + if (options.stdin_slice) |stdin_slice| { + stdin_writer = try write_stdin(&child, stdin_slice); + } + + poller = std.io.poll(shell.gpa, Streams, .{ + .stdout = child.stdout.?, + .stderr = child.stderr.?, + }); + + { + defer inline for (comptime std.enums.values(Streams)) |stream| { + assert(poller.?.fifo(stream).head == 0); + }; + + var timer = try std.time.Timer.start(); + for (0..1_000_000) |_| { + const timeout_remaining = options.timeout.ns -| timer.read(); + if (timeout_remaining == 0) { + return error.ExecTimeout; + } + if (!try poller.?.pollTimeout(@intCast(timeout_remaining))) break; + inline for (comptime std.enums.values(Streams)) |stream| { + if (poller.?.fifo(stream).count > options.output_limit_bytes) { + return error.StdoutStreamTooLong; + } + } + } else @panic("exec: safety counter exceeded"); + } + + const term = try child.wait(); + switch (term) { + .Exited => |code| if (code != 0) return error.ExecNonZeroExitStatus, + else => return error.ExecFailed, + } + + inline for ( + .{ options.capture_stdout, options.capture_stderr }, + .{ .stdout, .stderr }, + ) |capture_destination, capture_stream| { + if (capture_destination) |destination| { + const stream = poller.?.fifo(capture_stream).readableSlice(0); + const trailing_newline = if (std.mem.indexOfScalar(u8, stream, '\n')) |first_newline| + first_newline == stream.len - 1 + else + false; + const len_without_newline = stream.len - @intFromBool(trailing_newline); + destination.* = try shell.arena.allocator().dupe(u8, stream[0..len_without_newline]); + } + } +} + +fn write_stdin(child: *std.process.Child, stdin: []const u8) !std.Thread { + assert(child.stdin != null); + defer child.stdin = null; + + // Spawn a thread to avoid deadlock between us writing to stdin and reading from stdout. + return try std.Thread.spawn( + .{}, + struct { + fn write_stdin(destination: std.fs.File, source: []const u8) void { + defer destination.close(); + + destination.writeAll(source) catch {}; + } + }.write_stdin, + .{ child.stdin.?, stdin }, + ); +} + +/// Run the command and return its status, stderr and stdout. +/// The caller is responsible for checking the status. +pub fn exec_raw( + shell: *Shell, + comptime cmd: []const u8, + cmd_args: anytype, +) !std.process.Child.RunResult { + var argv = try Argv.expand(shell.gpa, cmd, cmd_args); + defer argv.deinit(); + + return try std.process.Child.run(.{ + .allocator = shell.arena.allocator(), + .argv = argv.slice(), + .cwd = try shell.cwd.realpath(".", &shell.cwd_path_buffer), + .env_map = &shell.env, + }); +} + +pub const SpawnOptions = struct { + stdin_behavior: std.process.Child.StdIo = .Ignore, + stdout_behavior: std.process.Child.StdIo = .Ignore, + stderr_behavior: std.process.Child.StdIo = .Ignore, +}; + +pub fn spawn( + shell: *Shell, + options: SpawnOptions, + comptime cmd: []const u8, + cmd_args: anytype, +) !std.process.Child { + var argv = try Argv.expand(shell.gpa, cmd, cmd_args); + defer argv.deinit(); + + return shell.spawn_argv(options, &argv); +} + +pub fn spawn_zig( + shell: *Shell, + options: SpawnOptions, + comptime cmd: []const u8, + cmd_args: anytype, +) !std.process.Child { + var argv = Argv.init(shell.gpa); + defer argv.deinit(); + + try argv.append_new_arg("{s}", .{shell.zig_exe.?}); + try expand_argv(&argv, cmd, cmd_args); + return shell.spawn_argv(options, &argv); +} + +fn spawn_argv( + shell: *Shell, + options: SpawnOptions, + argv: *const Argv, +) !std.process.Child { + var child = std.process.Child.init(argv.slice(), shell.gpa); + child.cwd = try shell.cwd.realpath(".", &shell.cwd_path_buffer); + child.env_map = &shell.env; + child.stdin_behavior = options.stdin_behavior; + child.stdout_behavior = options.stdout_behavior; + child.stderr_behavior = options.stderr_behavior; + try child.spawn(); + return child; +} + +/// On GitHub Actions runners, `git commit` fails with an "Author identity unknown" error. +/// +/// This function sets up appropriate environmental variables to correct that error. +pub fn git_env_setup(shell: *Shell, options: struct { use_hostname: bool }) !void { + if (options.use_hostname) { + if (builtin.target.os.tag != .linux) { + @panic("use_hostname only supported on linux"); + } + var hostname_buffer: [std.posix.HOST_NAME_MAX]u8 = @splat(0); + const hostname = try std.posix.gethostname(&hostname_buffer); + + try shell.env.put("GIT_AUTHOR_NAME", hostname); + try shell.env.put("GIT_COMMITTER_NAME", hostname); + } else { + try shell.env.put("GIT_AUTHOR_NAME", "TigerBeetle Bot"); + try shell.env.put("GIT_COMMITTER_NAME", "TigerBeetle Bot"); + } + + try shell.env.put("GIT_AUTHOR_EMAIL", "bot@tigerbeetle.com"); + try shell.env.put("GIT_COMMITTER_EMAIL", "bot@tigerbeetle.com"); +} + +pub fn git_commit_timestamp(shell: *Shell, sha: []const u8) !stdx.InstantUnix { + assert(sha.len == 40); + + const timestamp_s = try shell.exec_stdout("git show -s --format=%ct {sha}", .{ .sha = sha }); + return stdx.InstantUnix.from_timestamp_s( + try stdx.parse_int(u64, timestamp_s, .{}), + ); +} + +const Argv = struct { + args: std.ArrayList([]const u8), + + fn init(gpa: std.mem.Allocator) Argv { + return Argv{ .args = std.ArrayList([]const u8).init(gpa) }; + } + + fn expand(gpa: std.mem.Allocator, comptime cmd: []const u8, cmd_args: anytype) !Argv { + var result = Argv.init(gpa); + errdefer result.deinit(); + try expand_argv(&result, cmd, cmd_args); + return result; + } + + fn deinit(argv: *Argv) void { + for (argv.args.items) |arg| argv.args.allocator.free(arg); + argv.args.deinit(); + } + + fn slice(argv: *const Argv) []const []const u8 { + return argv.args.items; + } + + fn append_new_arg(argv: *Argv, comptime arg_fmt: []const u8, arg: anytype) !void { + const arg_owned = try std.fmt.allocPrint( + argv.args.allocator, + arg_fmt, + arg, + ); + errdefer argv.args.allocator.free(arg_owned); + + try argv.args.append(arg_owned); + } + + fn extend_last_arg(argv: *Argv, comptime arg_fmt: []const u8, arg: anytype) !void { + assert(argv.args.items.len > 0); + const arg_allocated = try std.fmt.allocPrint( + argv.args.allocator, + "{s}" ++ arg_fmt, + .{argv.args.items[argv.args.items.len - 1]} ++ arg, + ); + argv.args.allocator.free(argv.args.items[argv.args.items.len - 1]); + argv.args.items[argv.args.items.len - 1] = arg_allocated; + } +}; + +/// Expands `cmd` into an array of command arguments, substituting values from `cmd_args`. +/// +/// This avoids shell injection by construction as it doesn't concatenate strings. +fn expand_argv(argv: *Argv, comptime cmd: []const u8, cmd_args: anytype) !void { + @setEvalBranchQuota(5_000); + // Mostly copy-paste from std.fmt.format + + comptime var pos: usize = 0; + + // For arguments like `tigerbeetle-{version}.exe`, we want to concatenate literal suffix + // ("tigerbeetle-") and prefix (".exe") to the value of `version` interpolated argument. + // + // These two variables track the spaces around `{}` syntax. + comptime var concat_left: bool = false; + comptime var concat_right: bool = false; + + const arg_count = std.meta.fields(@TypeOf(cmd_args)).len; + comptime var args_used: stdx.BitSetType(arg_count) = .{}; + comptime assert(std.mem.indexOfScalar(u8, cmd, '\'') == null); // Intentionally unsupported. + comptime assert(std.mem.indexOfScalar(u8, cmd, '"') == null); + inline while (pos < cmd.len) { + inline while (pos < cmd.len and (cmd[pos] == ' ' or cmd[pos] == '\n')) { + pos += 1; + } + + const pos_start = pos; + inline while (pos < cmd.len) : (pos += 1) { + switch (cmd[pos]) { + ' ', '\n', '{' => break, + else => {}, + } + } + + const pos_end = pos; + if (pos_start != pos_end) { + if (concat_right) { + assert(pos_start > 0 and cmd[pos_start - 1] == '}'); + try argv.extend_last_arg("{s}", .{cmd[pos_start..pos_end]}); + } else { + try argv.append_new_arg("{s}", .{cmd[pos_start..pos_end]}); + } + } + + concat_left = false; + concat_right = false; + + if (pos >= cmd.len) break; + if (cmd[pos] == ' ' or cmd[pos] == '\n') continue; + + comptime assert(cmd[pos] == '{'); + concat_left = pos > 0 and cmd[pos - 1] != ' ' and cmd[pos - 1] != '\n'; + if (concat_left) assert(argv.slice().len > 0); + pos += 1; + + const pos_arg_start = pos; + inline while (pos < cmd.len and cmd[pos] != '}') : (pos += 1) {} + const pos_arg_end = pos; + + if (pos >= cmd.len) @compileError("Missing closing }"); + + comptime assert(cmd[pos] == '}'); + concat_right = pos + 1 < cmd.len and cmd[pos + 1] != ' ' and cmd[pos + 1] != '\n'; + pos += 1; + + const arg_name = comptime cmd[pos_arg_start..pos_arg_end]; + const arg_or_slice = @field(cmd_args, arg_name); + comptime args_used.set(for (std.meta.fieldNames(@TypeOf(cmd_args)), 0..) |field, index| { + if (std.mem.eql(u8, field, arg_name)) break index; + } else unreachable); + + const T = @TypeOf(arg_or_slice); + + if (@typeInfo(T) == .int or @typeInfo(T) == .comptime_int) { + if (concat_left) { + try argv.extend_last_arg("{d}", .{arg_or_slice}); + } else { + try argv.append_new_arg("{d}", .{arg_or_slice}); + } + } else if (std.meta.Elem(T) == u8) { + if (concat_left) { + try argv.extend_last_arg("{s}", .{arg_or_slice}); + } else { + try argv.append_new_arg("{s}", .{arg_or_slice}); + } + } else if (std.meta.Elem(T) == []const u8) { + if (concat_left or concat_right) @compileError("Can't concatenate slices"); + for (arg_or_slice) |arg_part| { + try argv.append_new_arg("{s}", .{arg_part}); + } + } else { + @compileError("Unsupported argument type"); + } + } + + comptime if (args_used.count() != arg_count) @compileError("Unused argument"); +} + +const Snap = stdx.Snap; +const snap = Snap.snap_fn("src"); + +test "shell: expand_argv" { + const T = struct { + fn check( + comptime cmd: []const u8, + args: anytype, + want: Snap, + ) !void { + var argv = Argv.init(std.testing.allocator); + defer argv.deinit(); + + try expand_argv(&argv, cmd, args); + try want.diff_zon(argv.slice()); + } + }; + + try T.check("zig version", .{}, snap(@src(), + \\.{ "zig", "version" } + )); + try T.check(" zig version ", .{}, snap(@src(), + \\.{ "zig", "version" } + )); + + try T.check( + "zig {version}", + .{ .version = @as([]const u8, "version") }, + snap(@src(), + \\.{ "zig", "version" } + ), + ); + + try T.check( + "zig {version}", + .{ .version = @as([]const []const u8, &.{ "version", "--verbose" }) }, + snap(@src(), + \\.{ + \\ "zig", + \\ "version", + \\ "--verbose", + \\} + ), + ); + + try T.check( + "git fetch origin refs/pull/{pr}/head", + .{ .pr = 92 }, + snap(@src(), + \\.{ + \\ "git", + \\ "fetch", + \\ "origin", + \\ "refs/pull/92/head", + \\} + ), + ); + try T.check( + "gh pr checkout {pr}", + .{ .pr = @as(u32, 92) }, + snap(@src(), + \\.{ + \\ "gh", + \\ "pr", + \\ "checkout", + \\ "92", + \\} + ), + ); +} + +/// Finds the root of TigerBeetle repo. +/// +/// Caller is responsible for closing the dir. +fn discover_project_root() !std.fs.Dir { + var current = try std.fs.cwd().openDir(".", .{}); + errdefer current.close(); // Caller is responsible for closing on success. + + for (0..16) |_| { + if (detect_project_root(current)) |_| { + return current; + } else |err| switch (err) { + error.FileNotFound => { + const parent = try current.openDir("..", .{}); + current.close(); + current = parent; + }, + else => return err, + } + } + + return error.DiscoverProjectRootDepthExceeded; +} + +fn detect_project_root(dir: std.fs.Dir) !void { + try dir.access("build.zig", .{}); + try dir.access("src", .{}); +} + +pub const HttpOptions = struct { + pub const ContentType = enum { + json, + + fn string(content_type: ContentType) []const u8 { + return switch (content_type) { + .json => "application/json", + }; + } + }; + + content_type: ?ContentType = null, + authorization: ?[]const u8 = null, + + response_body_size_max: u32 = 512 * stdx.KiB, + expected_response_code: std.http.Status = .ok, +}; + +pub fn http_get(shell: *Shell, url: []const u8, options: HttpOptions) ![]const u8 { + return shell.http_request(.get, url, options); +} + +pub fn http_post( + shell: *Shell, + url: []const u8, + body: []const u8, + options: HttpOptions, +) ![]const u8 { + return shell.http_request(.{ .post = body }, url, options); +} + +/// Issues an HTTP request to the given `url` and returns the response. +/// +/// The returned body is owned by the shell arena and doesn't need to be freed. +/// If the response is not 200 OK, the response body is logged and an error is returned. +fn http_request( + shell: *Shell, + method: union(enum) { get, post: []const u8 }, + url: []const u8, + options: HttpOptions, +) ![]const u8 { + errdefer |err| log.err( + "failed to HTTP {s} to \"{s}\": {s}", + .{ @tagName(method), url, @errorName(err) }, + ); + + var client = std.http.Client{ .allocator = shell.gpa }; + defer client.deinit(); + + const uri = try std.Uri.parse(url); + var header_buffer: [4 * stdx.KiB]u8 = undefined; + var request = try client.open( + switch (method) { + .post => .POST, + .get => .GET, + }, + uri, + .{ .server_header_buffer = &header_buffer }, + ); + defer request.deinit(); + + if (options.content_type) |content_type| { + request.headers.content_type = .{ .override = content_type.string() }; + } + + if (options.authorization) |authorization| { + request.headers.authorization = .{ .override = authorization }; + } + + if (method == .post) { + request.transfer_encoding = .{ .content_length = method.post.len }; + } + + try request.send(); + if (method == .post) { + try request.writeAll(method.post); + } + try request.finish(); + try request.wait(); + + // If the response is compressed, content_length is the compressed size, not the decoded size. + const compressed = request.response.transfer_compression != .identity; + const response_body_buffer_size: usize = blk: { + if (!compressed) { + if (request.response.content_length) |response_content_length| { + break :blk response_content_length; + } + } + break :blk options.response_body_size_max; + }; + + if (response_body_buffer_size > options.response_body_size_max) { + return error.ResponseTooLarge; + } + + const response_body_buffer = try shell.arena.allocator().alloc(u8, response_body_buffer_size); + const response_body_size = try request.readAll(response_body_buffer); + assert(response_body_size <= options.response_body_size_max); + const response_body = response_body_buffer[0..response_body_size]; + + if (!compressed) { + if (request.response.content_length) |response_content_length| { + assert(response_content_length == response_body_size); + } + } + + if (request.response.status != options.expected_response_code) { + log.err("response: {s}", .{response_body}); + return error.ResponseWrongStatus; + } + + return response_body; +} + +/// Converts an ISO8601 timestamp into seconds from the epoch by shelling out to the `date` util. +pub fn iso8601_to_timestamp_seconds(shell: *Shell, datetime_iso8601: []const u8) !u64 { + return try stdx.parse_int(u64, try shell.exec_stdout( + "date -d {datetime_iso8601} +%s", + .{ .datetime_iso8601 = datetime_iso8601 }, + ), .{}); +} + +pub fn unzip_executable( + shell: *Shell, + zip_path: []const u8, + executable_name: []const u8, +) !void { + const zip_file = try shell.cwd.openFile(zip_path, .{}); + defer zip_file.close(); + + try std.zip.extract(shell.cwd, zip_file.seekableStream(), .{}); + + const zip_extracted = try shell.cwd.openFile(executable_name, .{}); + defer zip_extracted.close(); + + // Zig's std.zip.extract doesn't handle permissions. + if (builtin.os.tag != .windows) { + try zip_extracted.chmod(0o755); + } +} + +pub const DOSTimestamp = struct { + time: u16, + date: u16, +}; + +pub fn unix_to_dos_timestamp(instant: stdx.InstantUnix) DOSTimestamp { + const date_time = instant.date_time(); + assert(date_time.year >= 1980 and date_time.year <= 2107); + + const time: u16 = + (@as(u16, date_time.hour) << 11) | + (@as(u16, date_time.minute) << 5) | + (@as(u16, @divFloor(date_time.second, 2))); + + const date: u16 = + ((@as(u16, date_time.year - 1980)) << 9) | + (@as(u16, date_time.month) << 5) | + (@as(u16, date_time.day)); + + return .{ .time = time, .date = date }; +} + +pub fn zip_executable( + shell: *Shell, + zip_file: std.fs.File, + input: struct { + executable_name: []const u8, + executable_mtime: stdx.InstantUnix, + max_size: u64, + }, +) !void { + assert(std.mem.eql(u8, std.fs.path.basename(input.executable_name), input.executable_name)); + + var zip_file_writer = std.io.countingWriter(zip_file.writer()); + + const executable = try shell.cwd.readFileAlloc( + shell.gpa, + input.executable_name, + input.max_size, + ); + defer shell.gpa.free(executable); + + const executable_mtime_dos = unix_to_dos_timestamp(input.executable_mtime); + const crc32 = std.hash.Crc32.hash(executable); + + const executable_deflated_buffer = try shell.gpa.alloc(u8, input.max_size); + defer shell.gpa.free(executable_deflated_buffer); + + const executable_deflated = blk: { + var executable_stream = std.io.fixedBufferStream(executable); + var executable_deflated_stream = std.io.fixedBufferStream(executable_deflated_buffer); + + try std.compress.flate.deflate.compress( + .raw, + executable_stream.reader(), + executable_deflated_stream.writer(), + .{ .level = .best }, + ); + assert(executable_stream.pos == executable.len); + + break :blk executable_deflated_stream.getWritten(); + }; + + const zip_version_20 = 0x14; + const zip_unix = 0x0300; + + const local_file_header: std.zip.LocalFileHeader = .{ + .signature = std.zip.local_file_header_sig, + .version_needed_to_extract = zip_version_20, + .flags = .{ .encrypted = false, ._ = 0 }, + .compression_method = .deflate, + .last_modification_time = executable_mtime_dos.time, + .last_modification_date = executable_mtime_dos.date, + .crc32 = crc32, + .compressed_size = @intCast(executable_deflated.len), + .uncompressed_size = @intCast(executable.len), + .filename_len = @intCast(input.executable_name.len), + .extra_len = 0, + }; + + try zip_file_writer.writer().writeStructEndian(local_file_header, .little); + try zip_file_writer.writer().writeAll(input.executable_name); + try zip_file_writer.writer().writeAll(executable_deflated); + + const central_directory_file_header: std.zip.CentralDirectoryFileHeader = .{ + .signature = std.zip.central_file_header_sig, + .version_made_by = zip_unix | zip_version_20, + .version_needed_to_extract = zip_version_20, + .flags = .{ .encrypted = false, ._ = 0 }, + .compression_method = .deflate, + .last_modification_time = executable_mtime_dos.time, + .last_modification_date = executable_mtime_dos.date, + .crc32 = crc32, + .compressed_size = @intCast(executable_deflated.len), + .uncompressed_size = @intCast(executable.len), + .filename_len = @intCast(input.executable_name.len), + .extra_len = 0, + .comment_len = 0, + .disk_number = 0, + .internal_file_attributes = 0, + .external_file_attributes = 0o0100755 << 16, // Regular file, executable. + .local_file_header_offset = 0, + }; + + const central_directory_offset = zip_file_writer.bytes_written; + try zip_file_writer.writer().writeStructEndian(central_directory_file_header, .little); + try zip_file_writer.writer().writeAll(input.executable_name); + const central_directory_end = zip_file_writer.bytes_written; + + const end_record: std.zip.EndRecord = .{ + .signature = std.zip.end_record_sig, + .disk_number = 0, + .central_directory_disk_number = 0, + .record_count_disk = 1, + .record_count_total = 1, + .central_directory_size = @intCast(central_directory_end - central_directory_offset), + .central_directory_offset = @intCast(central_directory_offset), + .comment_len = 0, + }; + try zip_file_writer.writer().writeStructEndian(end_record, .little); +} + +pub fn sha256sum(shell: *Shell, file_path: []const u8) !u256 { + const buffer = try shell.gpa.alloc(u8, 512 * stdx.KiB); + defer shell.gpa.free(buffer); + + var hasher = std.crypto.hash.sha2.Sha256.init(.{}); + + const file = try shell.cwd.openFile(file_path, .{}); + defer file.close(); + + const stat = try file.stat(); + var bytes_read_total: u64 = 0; + while (bytes_read_total < stat.size) { + const bytes_read = try file.readAll(buffer); + if (bytes_read == 0) { + break; + } + + bytes_read_total += bytes_read; + hasher.update(buffer[0..bytes_read]); + } + + assert(stat.size == bytes_read_total); + + var output: u256 = undefined; + hasher.final(std.mem.asBytes(&output)); + + return output; +} diff --git a/ocam/src/stdx/sort_test.zig b/ocam/src/stdx/sort_test.zig new file mode 100644 index 00000000..aeea2cb2 --- /dev/null +++ b/ocam/src/stdx/sort_test.zig @@ -0,0 +1,112 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const stdx = @import("stdx.zig"); +const ratio = stdx.PRNG.ratio; + +test "sort_stable" { + const Value = struct { + const Value = @This(); + + x: u32, // x determines the order of the values. + y: u32, // y ensures that values are distinct for the purpose of checking stability. + + fn compare_x_ascending(_: void, a: Value, b: Value) bool { + return a.x < b.x; + } + + fn compare_x_descending(_: void, a: Value, b: Value) bool { + return a.x > b.x; + } + + fn compare_xy_ascending(_: void, a: Value, b: Value) bool { + if (a.x < b.x) return true; + if (a.x > b.x) return false; + return a.y < b.y; + } + }; + + const allocator = std.testing.allocator; + + var prng = stdx.PRNG.from_seed_testing(); + + const values_max = 1 << 15; + const values_all = try allocator.alloc(Value, values_max); + defer allocator.free(values_all); + + const values_all_expected = try allocator.alloc(Value, values_max); + defer allocator.free(values_all_expected); + + for (0..256) |_| { + const values_count = prng.range_inclusive(u32, 2, values_max); + const values_expected = values_all_expected[0..values_count]; + const values = values_all[0..values_count]; + + { + // Set up `values`. + + for (values) |*value| { + value.* = .{ + .x = prng.int_inclusive(u32, values_count * 2 - 1), + .y = undefined, + }; + } + + // Sort algorithms often optimize the case of already-sorted (or already-reverse-sorted) + // sub-arrays. + const partitions_count = prng.range_inclusive(u32, 1, @max(values_count, 64) - 1); + // The `partition_reverse_probability` is a subset of the partitions sorted by + // `partition_sort_percent`. + const partition_sort_probability = ratio(prng.int_inclusive(u8, 100), 100); + const partition_reverse_probability = ratio(prng.int_inclusive(u8, 100), 100); + + var partitions_remaining: u32 = partitions_count; + var partition_offset: u32 = 0; + while (partition_offset < values_count) { + const partition_size = size: { + if (partitions_remaining == 1) { + break :size values_count - partition_offset; + } else { + break :size prng.range_inclusive(u32, 1, values_count - partition_offset); + } + }; + + if (prng.chance(partition_sort_probability)) { + const partition = values[partition_offset..][0..partition_size]; + if (prng.chance(partition_reverse_probability)) { + std.mem.sortUnstable(Value, partition, {}, Value.compare_x_descending); + } else { + std.mem.sortUnstable(Value, partition, {}, Value.compare_x_ascending); + } + } + + partitions_remaining -= 1; + partition_offset += partition_size; + } + + for (values, 0..) |*value, i| value.y = @intCast(i); + } + + { + // Set up `values_expected`. + stdx.copy_disjoint(.exact, Value, values_expected, values); + std.mem.sortUnstable(Value, values_expected, {}, Value.compare_xy_ascending); + + // Sanity-check the expected values' order. + for ( + values_expected[0 .. values_count - 1], + values_expected[1..], + ) |a, b| { + assert(a.x <= b.x); + if (a.x == b.x) assert(a.y < b.y); + } + } + + std.mem.sort(Value, values, {}, Value.compare_x_ascending); + + for (values, values_expected) |value, value_expected| { + try std.testing.expectEqual(value.x, value_expected.x); + try std.testing.expectEqual(value.y, value_expected.y); + } + } +} diff --git a/ocam/src/stdx/stdx.zig b/ocam/src/stdx/stdx.zig new file mode 100644 index 00000000..359b3ad8 --- /dev/null +++ b/ocam/src/stdx/stdx.zig @@ -0,0 +1,1265 @@ +//! Extensions to the standard library -- things which could have been in std, but aren't. +//! +//! Unlike std, the namespacing is relatively flat: `stdx.PRNG` rather than `stdx.random.PRNG`. +//! We don't care about backwards compatibility and prefer directness to scalability. Hierarchy can +//! always be introduced later, when/if stdx grows too large. + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; + +pub const BitSetType = @import("bit_set.zig").BitSetType; +pub const IOPSType = @import("iops.zig").IOPSType; +pub const BoundedArrayType = @import("bounded_array.zig").BoundedArrayType; +pub const PRNG = @import("prng.zig"); +pub const RingBufferType = @import("ring_buffer.zig").RingBufferType; +pub const Bench = @import("testing/bench.zig"); +pub const Snap = @import("testing/snaptest.zig").Snap; +pub const ZipfianGenerator = @import("zipfian.zig").ZipfianGenerator; +pub const ZipfianShuffled = @import("zipfian.zig").ZipfianShuffled; + +pub const huge_page_allocator = @import("huge_page_allocator.zig").huge_page_allocator; + +pub const aegis = @import("vendored/aegis.zig"); +pub const dbg = @import("debug.zig").dbg; +pub const Flags = @import("flags.zig"); +pub const memory_lock_allocated = @import("mlock.zig").memory_lock_allocated; +pub const Shell = @import("shell.zig"); +pub const timeit = @import("debug.zig").timeit; +pub const unshare = @import("unshare.zig"); +pub const windows = @import("windows.zig"); +pub const radix_sort = @import("radix.zig").sort; + +pub const Instant = @import("time_units.zig").Instant; +pub const Duration = @import("time_units.zig").Duration; +pub const InstantUnix = @import("time_units.zig").InstantUnix; + +const net = @import("./net.zig"); +pub const IPAddress = net.IPAddress; +pub const SocketAddress = net.SocketAddress; + +// Import these as `const GiB = stdx.GiB;` +pub const KiB = 1 << 10; +pub const MiB = 1 << 20; +pub const GiB = 1 << 30; +pub const TiB = 1 << 40; +pub const PiB = 1 << 50; +// pub const NiB = "Some people say my love cannot be true"; + +comptime { + assert(KiB == 1024); + assert(MiB == 1024 * KiB); + assert(GiB == 1024 * MiB); + assert(TiB == 1024 * GiB); + assert(PiB == 1024 * TiB); +} + +pub inline fn div_ceil(numerator: anytype, denominator: anytype) @TypeOf(numerator, denominator) { + comptime { + switch (@typeInfo(@TypeOf(numerator))) { + .int => |int| assert(int.signedness == .unsigned), + .comptime_int => assert(numerator >= 0), + else => @compileError("div_ceil: invalid numerator type"), + } + + switch (@typeInfo(@TypeOf(denominator))) { + .int => |int| assert(int.signedness == .unsigned), + .comptime_int => assert(denominator > 0), + else => @compileError("div_ceil: invalid denominator type"), + } + } + + assert(denominator > 0); + + if (numerator == 0) return 0; + return @divFloor(numerator - 1, denominator) + 1; +} + +test "div_ceil" { + // Comptime ints. + try std.testing.expectEqual(div_ceil(0, 8), 0); + try std.testing.expectEqual(div_ceil(1, 8), 1); + try std.testing.expectEqual(div_ceil(7, 8), 1); + try std.testing.expectEqual(div_ceil(8, 8), 1); + try std.testing.expectEqual(div_ceil(9, 8), 2); + + // Unsized ints + const max = std.math.maxInt(u64); + try std.testing.expectEqual(div_ceil(@as(u64, 0), 8), 0); + try std.testing.expectEqual(div_ceil(@as(u64, 1), 8), 1); + try std.testing.expectEqual(div_ceil(@as(u64, max), 2), max / 2 + 1); + try std.testing.expectEqual(div_ceil(@as(u64, max) - 1, 2), max / 2); + try std.testing.expectEqual(div_ceil(@as(u64, max) - 2, 2), max / 2); +} + +pub const SizePrecision = enum { exact, inexact }; + +pub inline fn copy_left( + comptime precision: SizePrecision, + comptime T: type, + target: []T, + source: []const T, +) void { + switch (precision) { + .exact => assert(target.len == source.len), + .inexact => assert(target.len >= source.len), + } + + if (!disjoint_slices(T, T, target, source)) { + assert(@intFromPtr(target.ptr) < @intFromPtr(source.ptr)); + } + + // (Bypass tidy's ban.) + const copyForwards = std.mem.copyForwards; + copyForwards(T, target, source); +} + +test "copy_left" { + const a = try std.testing.allocator.alloc(usize, 8); + defer std.testing.allocator.free(a); + + for (a, 0..) |*v, i| v.* = i; + copy_left(.exact, usize, a[0..6], a[2..]); + try std.testing.expect(std.mem.eql(usize, a, &.{ 2, 3, 4, 5, 6, 7, 6, 7 })); +} + +pub inline fn copy_right( + comptime precision: SizePrecision, + comptime T: type, + target: []T, + source: []const T, +) void { + switch (precision) { + .exact => assert(target.len == source.len), + .inexact => assert(target.len >= source.len), + } + + if (!disjoint_slices(T, T, target, source)) { + assert(@intFromPtr(target.ptr) > @intFromPtr(source.ptr)); + } + + // (Bypass tidy's ban.) + const copyBackwards = std.mem.copyBackwards; + copyBackwards(T, target, source); +} + +test "copy_right" { + const a = try std.testing.allocator.alloc(usize, 8); + defer std.testing.allocator.free(a); + + for (a, 0..) |*v, i| v.* = i; + copy_right(.exact, usize, a[2..], a[0..6]); + try std.testing.expect(std.mem.eql(usize, a, &.{ 0, 1, 0, 1, 2, 3, 4, 5 })); +} + +pub inline fn copy_disjoint( + comptime precision: SizePrecision, + comptime T: type, + target: []T, + source: []const T, +) void { + switch (precision) { + .exact => assert(target.len == source.len), + .inexact => assert(target.len >= source.len), + } + + // disjoint_slices() doesn't work in comptime, because of limitations with @intFromPtr: + // https://github.com/ziglang/zig/issues/23072. + // + // It's also possible to construct slices into an array at comptime that are _not_ disjoint, + // which would violate the intention of this function, so it can't just be skipped. + assert(!@inComptime()); + assert(disjoint_slices(T, T, target, source)); + + @memcpy // Bypass tidy ban. + (target[0..source.len], source); +} + +pub inline fn disjoint_slices(comptime A: type, comptime B: type, a: []const A, b: []const B) bool { + return @intFromPtr(a.ptr) + a.len * @sizeOf(A) <= @intFromPtr(b.ptr) or + @intFromPtr(b.ptr) + b.len * @sizeOf(B) <= @intFromPtr(a.ptr); +} + +test "disjoint_slices" { + const a = try std.testing.allocator.alignedAlloc(u8, @sizeOf(u32), 8 * @sizeOf(u32)); + defer std.testing.allocator.free(a); + + const b = try std.testing.allocator.alloc(u32, 8); + defer std.testing.allocator.free(b); + + try std.testing.expectEqual(true, disjoint_slices(u8, u32, a, b)); + try std.testing.expectEqual(true, disjoint_slices(u32, u8, b, a)); + + try std.testing.expectEqual(true, disjoint_slices(u8, u8, a, a[0..0])); + try std.testing.expectEqual(true, disjoint_slices(u32, u32, b, b[0..0])); + + try std.testing.expectEqual(false, disjoint_slices(u8, u8, a, a[0..1])); + try std.testing.expectEqual(false, disjoint_slices(u8, u8, a, a[a.len - 1 .. a.len])); + + try std.testing.expectEqual(false, disjoint_slices(u32, u32, b, b[0..1])); + try std.testing.expectEqual(false, disjoint_slices(u32, u32, b, b[b.len - 1 .. b.len])); + + try std.testing.expectEqual(false, disjoint_slices(u8, u32, a, std.mem.bytesAsSlice(u32, a))); + try std.testing.expectEqual(false, disjoint_slices(u32, u8, b, std.mem.sliceAsBytes(b))); +} + +/// Checks that a byteslice is zeroed. +pub fn zeroed(bytes: []const u8) bool { + // This implementation already gets vectorized + // https://godbolt.org/z/46cMsPKPc + var byte_bits: u8 = 0; + for (bytes) |byte| { + byte_bits |= byte; + } + return byte_bits == 0; +} + +/// Similar to `std.mem.bytesAsSlice`, but allows buffers with inexact sizes, +/// returning the largest possible slice that is less than or equal to the buffer length. +/// Differently from `std.mem.bytesAsSlice` that can return `[]align(1) T`, this function +/// always `@alignCast` the result. +pub fn bytes_as_slice( + comptime precision: SizePrecision, + comptime T: type, + bytes: anytype, +) type: { + const type_info = @typeInfo(@TypeOf(bytes)); + switch (type_info) { + .pointer => |info| switch (info.size) { + .one => switch (@typeInfo(info.child)) { + .array => |array_info| assert(array_info.child == u8), + else => unreachable, + }, + .slice => assert(info.child == u8), + else => unreachable, + }, + else => unreachable, + } + + break :type if (type_info.pointer.is_const) []const T else []T; +} { + switch (precision) { + .exact => { + assert(bytes.len % @sizeOf(T) == 0); + return @alignCast(std.mem.bytesAsSlice(T, bytes)); + }, + .inexact => { + const size = @divFloor(bytes.len, @sizeOf(T)) * @sizeOf(T); + return @alignCast(std.mem.bytesAsSlice(T, bytes[0..size])); + }, + } +} + +test bytes_as_slice { + var buffer: [64]u8 = undefined; + const T10 = extern struct { content: [10]u8 }; + const T16 = extern struct { content: [16]u8 }; + + try std.testing.expectEqual( + @as(usize, 4), + bytes_as_slice(.exact, T16, buffer[0..]).len, + ); + try std.testing.expectEqual( + @as(usize, 6), + bytes_as_slice(.exact, T10, buffer[0..60]).len, + ); + + try std.testing.expectEqual( + @as(usize, 6), + bytes_as_slice(.inexact, T10, buffer[0..]).len, + ); + try std.testing.expectEqual( + @as(usize, 4), + bytes_as_slice(.inexact, T16, buffer[0..]).len, + ); + try std.testing.expectEqual( + @as(usize, 6), + bytes_as_slice(.inexact, T10, buffer[0 .. buffer.len - 1]).len, + ); + try std.testing.expectEqual( + @as(usize, 3), + bytes_as_slice(.inexact, T16, buffer[0 .. buffer.len - 1]).len, + ); + try std.testing.expectEqual( + @as(usize, 5), + bytes_as_slice(.inexact, T10, buffer[0 .. buffer.len - 10]).len, + ); + try std.testing.expectEqual( + @as(usize, 3), + bytes_as_slice(.inexact, T16, buffer[0 .. buffer.len - 10]).len, + ); +} + +/// Splits the `haystack` around the first occurrence of `needle`, returning parts before and after. +/// +/// This is a Zig version of Go's `string.Cut` / Rust's `str::split_once`. Cut turns out to be a +/// surprisingly versatile primitive for ad-hoc string processing. Often `std.mem.indexOf` and +/// `std.mem.split` can be replaced with a shorter and clearer code using `cut`. +pub fn cut(haystack: []const u8, needle: []const u8) ?struct { []const u8, []const u8 } { + const index = std.mem.indexOf(u8, haystack, needle) orelse return null; + + return .{ haystack[0..index], haystack[index + needle.len ..] }; +} + +test cut { + try std.testing.expectEqualStrings("he", cut("hello world", "l").?[0]); + try std.testing.expectEqualStrings("lo world", cut("hello world", "l").?[1]); + assert(null == cut("hello world", "x")); +} + +pub fn cut_prefix(haystack: []const u8, needle: []const u8) ?[]const u8 { + if (std.mem.startsWith(u8, haystack, needle)) { + return haystack[needle.len..]; + } + return null; +} + +test cut_prefix { + try std.testing.expectEqualStrings(" world", cut_prefix("hello world", "hello").?); + assert(null == cut_prefix("hello world", "hellnope")); +} + +pub fn cut_suffix(haystack: []const u8, needle: []const u8) ?[]const u8 { + if (std.mem.endsWith(u8, haystack, needle)) { + return haystack[0 .. haystack.len - needle.len]; + } + return null; +} + +test cut_suffix { + try std.testing.expectEqualStrings("hello ", cut_suffix("hello world", "world").?); + assert(null == cut_suffix("hello world", "hello")); +} + +pub fn unique(sorted: []u8) []u8 { + assert(sorted.len > 0); + + var count: usize = 1; + for (1..sorted.len) |index| { + assert(sorted[count - 1] <= sorted[index]); + if (sorted[count - 1] == sorted[index]) { + // Duplicate! Skip to the next index. + } else { + sorted[count] = sorted[index]; + count += 1; + } + } + + return sorted[0..count]; +} + +test unique { + var abba = "AAABBBCaaa".*; + try std.testing.expectEqualStrings("ABCa", unique(&abba)); +} + +/// `maybe` is the dual of `assert`: it signals that condition is sometimes true +/// and sometimes false. +/// +/// Currently we use it for documentation, but maybe one day we plug it into +/// coverage. +pub fn maybe(ok: bool) void { + assert(ok or !ok); +} + +pub const log = if (builtin.is_test) + // Downgrade `err` to `warn` for tests. + // Zig fails any test that does `log.err`, but we want to test those code paths here. + struct { + pub fn scoped(comptime scope: @Type(.enum_literal)) type { + const base = std.log.scoped(scope); + return struct { + pub const err = warn; + pub const warn = base.warn; + pub const info = base.info; + pub const debug = base.debug; + }; + } + } +else + std.log; + +/// An alternative to the default logFn from `std.log`, which prepends a UTC timestamp. +pub fn log_with_timestamp( + comptime message_level: std.log.Level, + comptime scope: @Type(.enum_literal), + comptime format: []const u8, + args: anytype, +) void { + const level_text = comptime message_level.asText(); + const scope_prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; + const instant_unix = InstantUnix.now(); + + const stderr = std.io.getStdErr().writer(); + var buffered_writer = std.io.bufferedWriter(stderr); + const writer = buffered_writer.writer(); + + nosuspend { + instant_unix.format("", .{}, writer) catch return; + writer.print(" " ++ level_text ++ scope_prefix ++ format ++ "\n", args) catch return; + buffered_writer.flush() catch return; + } +} + +/// Compare two values by directly comparing the underlying memory. +/// +/// Assert at compile time that this is a reasonable thing to do for a given `T`. That is, check +/// that: +/// - `T` doesn't have any non-deterministic padding, +/// - `T` doesn't embed any pointers. +pub fn equal_bytes(comptime T: type, a: *const T, b: *const T) bool { + comptime assert(has_unique_representation(T)); + comptime assert(!has_pointers(T)); + comptime assert(@sizeOf(T) * 8 == @bitSizeOf(T)); + + // Pick the biggest "word" for word-wise comparison, and don't try to early-return on the first + // mismatch, so that a compiler can vectorize the loop. + + const Word = comptime for (.{ u64, u32, u16, u8 }) |Word| { + if (@alignOf(T) >= @alignOf(Word) and @sizeOf(T) % @sizeOf(Word) == 0) break Word; + } else unreachable; + + // NOTE: Alignment is intentionally downcast here to work around a Zig code generation bug + // described in https://codeberg.org/ziglang/zig/issues/31473. + // TODO: Remove the following two lines once the issue is fixed. + const a_bytes: *align(@alignOf(Word)) const [@sizeOf(T)]u8 = + @alignCast(std.mem.asBytes(a)); + const b_bytes: *align(@alignOf(Word)) const [@sizeOf(T)]u8 = + @alignCast(std.mem.asBytes(b)); + + const a_words = std.mem.bytesAsSlice(Word, a_bytes); + const b_words = std.mem.bytesAsSlice(Word, b_bytes); + + assert(a_words.len == b_words.len); + + var total: Word = 0; + for (a_words, b_words) |a_word, b_word| { + total |= a_word ^ b_word; + } + + return total == 0; +} + +fn has_pointers(comptime T: type) bool { + switch (@typeInfo(T)) { + .pointer => return true, + // Be conservative. + else => return true, + + .bool, .int, .@"enum" => return false, + + .array => |info| return comptime has_pointers(info.child), + .@"struct" => |info| { + inline for (info.fields) |field| { + if (comptime has_pointers(field.type)) return true; + } + return false; + }, + } +} + +/// Checks that a type does not have implicit padding. +pub fn no_padding(comptime T: type) bool { + comptime switch (@typeInfo(T)) { + .void => return true, + .int => return @bitSizeOf(T) == 8 * @sizeOf(T), + .array => |info| return no_padding(info.child), + .@"struct" => |info| { + switch (info.layout) { + .auto => return false, + .@"extern" => { + for (info.fields) |field| { + if (!no_padding(field.type)) return false; + } + + // Check offsets of u128 and pseudo-u256 fields. + for (info.fields) |field| { + if (field.type == u128) { + const offset = @offsetOf(T, field.name); + if (offset % @sizeOf(u128) != 0) return false; + + if (@hasField(T, field.name ++ "_padding")) { + if (offset % @sizeOf(u256) != 0) return false; + if (offset + @sizeOf(u128) != + @offsetOf(T, field.name ++ "_padding")) + { + return false; + } + } + } + } + + var offset = 0; + for (info.fields) |field| { + const field_offset = @offsetOf(T, field.name); + if (offset != field_offset) return false; + offset += @sizeOf(field.type); + } + return offset == @sizeOf(T); + }, + .@"packed" => return @bitSizeOf(T) == 8 * @sizeOf(T), + } + }, + .@"enum" => |info| { + maybe(info.is_exhaustive); + return no_padding(info.tag_type); + }, + .pointer => return false, + .@"union" => return false, + else => return false, + }; +} + +test no_padding { + comptime for (.{ + u8, + extern struct { x: u8 }, + packed struct { x: u7, y: u1 }, + extern struct { x: extern struct { y: u64, z: u64 } }, + enum(u8) { x }, + }) |T| { + assert(no_padding(T)); + }; + + comptime for (.{ + u7, + struct { x: u7 }, + struct { x: u8 }, + struct { x: u64, y: u32 }, + extern struct { x: extern struct { y: u64, z: u32 } }, + packed struct { x: u7 }, + enum(u7) { x }, + }) |T| { + assert(!no_padding(T)); + }; +} + +pub inline fn hash_inline(value: anytype) u64 { + comptime { + assert(no_padding(@TypeOf(value))); + assert(has_unique_representation(@TypeOf(value))); + } + return low_level_hash(0, switch (@typeInfo(@TypeOf(value))) { + .@"struct", .int => std.mem.asBytes(&value), + else => @compileError("unsupported hashing for " ++ @typeName(@TypeOf(value))), + }); +} + +/// Inline version of Google Abseil "LowLevelHash" (inspired by wyhash). +/// https://github.com/abseil/abseil-cpp/blob/master/absl/hash/internal/low_level_hash.cc +inline fn low_level_hash(seed: u64, input: anytype) u64 { + const salt = [_]u64{ + 0xa0761d6478bd642f, + 0xe7037ed1a0b428db, + 0x8ebc6af09c88c6e3, + 0x589965cc75374cc3, + 0x1d8e4e27c47d124f, + }; + + var in: []const u8 = input; + var state = seed ^ salt[0]; + const starting_len = input.len; + + if (in.len > 64) { + var dup = [_]u64{ state, state }; + defer state = dup[0] ^ dup[1]; + + while (in.len > 64) : (in = in[64..]) { + for (@as([2][4]u64, @bitCast(in[0..64].*)), 0..) |chunk, i| { + const mix1 = @as(u128, chunk[0] ^ salt[(i * 2) + 1]) *% (chunk[1] ^ dup[i]); + const mix2 = @as(u128, chunk[2] ^ salt[(i * 2) + 2]) *% (chunk[3] ^ dup[i]); + dup[i] = @as(u64, @truncate(mix1 ^ (mix1 >> 64))); + dup[i] ^= @as(u64, @truncate(mix2 ^ (mix2 >> 64))); + } + } + } + + while (in.len > 16) : (in = in[16..]) { + const chunk = @as([2]u64, @bitCast(in[0..16].*)); + const mixed = @as(u128, chunk[0] ^ salt[1]) *% (chunk[1] ^ state); + state = @as(u64, @truncate(mixed ^ (mixed >> 64))); + } + + var chunk: [2]u64 = .{ 0, 0 }; + if (in.len > 8) { + chunk[0] = @as(u64, @bitCast(in[0..8].*)); + chunk[1] = @as(u64, @bitCast(in[in.len - 8 ..][0..8].*)); + } else if (in.len > 3) { + chunk[0] = @as(u32, @bitCast(in[0..4].*)); + chunk[1] = @as(u32, @bitCast(in[in.len - 4 ..][0..4].*)); + } else if (in.len > 0) { + chunk[0] = (@as(u64, in[0]) << 16) | (@as(u64, in[in.len / 2]) << 8) | in[in.len - 1]; + } + + var mixed = @as(u128, chunk[0] ^ salt[1]) *% (chunk[1] ^ state); + mixed = @as(u64, @truncate(mixed ^ (mixed >> 64))); + mixed *%= (@as(u64, starting_len) ^ salt[1]); + return @as(u64, @truncate(mixed ^ (mixed >> 64))); +} + +test "hash_inline" { + for (@import("testing/low_level_hash_vectors.zig").cases) |case| { + var buffer: [0x100]u8 = undefined; + + const b64 = std.base64.standard; + const input = buffer[0..try b64.Decoder.calcSizeForSlice(case.b64)]; + try b64.Decoder.decode(input, case.b64); + + const hash = low_level_hash(case.seed, input); + try std.testing.expectEqual(case.hash, hash); + } +} + +/// Returns a copy of `base` with fields changed according to `diff`. +/// +/// Intended exclusively for table-driven prototype-based tests. Write +/// updates explicitly in production code. +pub fn update(base: anytype, diff: anytype) @TypeOf(base) { + assert(builtin.is_test); + assert(@typeInfo(@TypeOf(base)) == .@"struct"); + + var updated = base; + inline for (std.meta.fields(@TypeOf(diff))) |f| { + @field(updated, f.name) = @field(diff, f.name); + } + return updated; +} + +// TODO(zig): std doesn't have the statfs / fstatfs syscalls to get the type of a filesystem. +// Once those are available, this can be removed. +// The `statfs` definition used by the Linux kernel, and the magic number for tmpfs, from +// `man 2 fstatfs`. +const fsblkcnt64_t = u64; +const fsfilcnt64_t = u64; +const fsword_t = i64; +const fsid_t = u64; + +pub const TmpfsMagic = 0x01021994; +pub const StatFs = extern struct { + f_type: fsword_t, + f_bsize: fsword_t, + f_blocks: fsblkcnt64_t, + f_bfree: fsblkcnt64_t, + f_bavail: fsblkcnt64_t, + f_files: fsfilcnt64_t, + f_ffree: fsfilcnt64_t, + f_fsid: fsid_t, + f_namelen: fsword_t, + f_frsize: fsword_t, + f_flags: fsword_t, + f_spare: [4]fsword_t, +}; + +pub fn fstatfs(fd: i32, statfs_buf: *StatFs) usize { + return std.os.linux.syscall2( + if (@hasField(std.os.linux.SYS, "fstatfs64")) .fstatfs64 else .fstatfs, + @as(usize, @bitCast(@as(isize, fd))), + @intFromPtr(statfs_buf), + ); +} + +/// True if every value of the type `T` has a unique bit pattern representing it. +/// In other words, `T` has no unused bits and no padding. +pub fn has_unique_representation(comptime T: type) bool { + switch (@typeInfo(T)) { + else => return false, // TODO can we know if it's true for some of these types ? + + .@"enum", + .error_set, + .@"fn", + => return true, + + .bool => return false, + + .int => |info| return @sizeOf(T) * 8 == info.bits, + + .pointer => |info| return info.size != .slice, + + .array => |info| return comptime has_unique_representation(info.child), + + .@"struct" => |info| { + // Only consider packed structs unique if they are byte aligned. + if (info.backing_integer) |backing_integer| { + return @sizeOf(T) * 8 == @bitSizeOf(backing_integer); + } + + var sum_size = @as(usize, 0); + + inline for (info.fields) |field| { + const FieldType = field.type; + if (comptime !has_unique_representation(FieldType)) return false; + sum_size += @sizeOf(FieldType); + } + + return @sizeOf(T) == sum_size; + }, + + .vector => |info| return comptime has_unique_representation(info.child) and + @sizeOf(T) == @sizeOf(info.child) * info.len, + } +} + +// Test vectors mostly from upstream, with some added to test the packed struct case. +test "has_unique_representation" { + const TestStruct1 = struct { + a: u32, + b: u32, + }; + + try std.testing.expect(has_unique_representation(TestStruct1)); + + const TestStruct2 = struct { + a: u32, + b: u16, + }; + + try std.testing.expect(!has_unique_representation(TestStruct2)); + + const TestStruct3 = struct { + a: u32, + b: u32, + }; + + try std.testing.expect(has_unique_representation(TestStruct3)); + + const TestStruct4 = struct { a: []const u8 }; + + try std.testing.expect(!has_unique_representation(TestStruct4)); + + const TestStruct5 = struct { a: TestStruct4 }; + + try std.testing.expect(!has_unique_representation(TestStruct5)); + + const TestStruct6 = packed struct { + a: u32, + b: u31, + }; + + try std.testing.expect(!has_unique_representation(TestStruct6)); + + const TestStruct7 = struct { + a: u64, + b: TestStruct6, + }; + + try std.testing.expect(!has_unique_representation(TestStruct7)); + + const TestStruct8 = packed struct { + a: u32, + b: u32, + }; + + try std.testing.expect(has_unique_representation(TestStruct8)); + + const TestStruct9 = struct { + a: u64, + b: TestStruct8, + }; + + try std.testing.expect(has_unique_representation(TestStruct9)); + + const TestStruct10 = packed struct { + a: TestStruct8, + b: TestStruct8, + }; + + try std.testing.expect(has_unique_representation(TestStruct10)); + + const TestUnion1 = packed union { + a: u32, + b: u16, + }; + + try std.testing.expect(!has_unique_representation(TestUnion1)); + + const TestUnion2 = extern union { + a: u32, + b: u16, + }; + + try std.testing.expect(!has_unique_representation(TestUnion2)); + + const TestUnion3 = union { + a: u32, + b: u16, + }; + + try std.testing.expect(!has_unique_representation(TestUnion3)); + + const TestUnion4 = union(enum) { + a: u32, + b: u16, + }; + + try std.testing.expect(!has_unique_representation(TestUnion4)); + + inline for ([_]type{ i0, u8, i16, u32, i64 }) |T| { + try std.testing.expect(has_unique_representation(T)); + } + inline for ([_]type{ i1, u9, i17, u33, i24 }) |T| { + try std.testing.expect(!has_unique_representation(T)); + } + + try std.testing.expect(!has_unique_representation([]u8)); + try std.testing.expect(!has_unique_representation([]const u8)); + + try std.testing.expect(has_unique_representation(@Vector(4, u16))); +} + +/// Construct a `union(Enum)` type, where each union "value" type is defined in terms of the +/// variant. +/// +/// That is, `EnumUnionType(Enum, TypeForVariant)` is equivalent to: +/// +/// union(Enum) { +/// // For every `e` in `Enum`: +/// e: TypeForVariant(e), +/// } +/// +pub fn EnumUnionType( + comptime Enum: type, + comptime TypeForVariant: fn (comptime variant: Enum) type, +) type { + const UnionField = std.builtin.Type.UnionField; + + var fields: [std.enums.values(Enum).len]UnionField = undefined; + for (std.enums.values(Enum), 0..) |enum_variant, i| { + fields[i] = .{ + .name = @tagName(enum_variant), + .type = TypeForVariant(enum_variant), + .alignment = @alignOf(TypeForVariant(enum_variant)), + }; + } + + return @Type(.{ .@"union" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .tag_type = Enum, + } }); +} + +/// Constructs an `enum` type from names. +pub fn EnumType(comptime names: anytype) type { + comptime assert(names.len > 0); + const EnumField = std.builtin.Type.EnumField; + var fields: [names.len]EnumField = undefined; + for (names, 0..) |name, i| { + fields[i] = .{ + .name = name, + .value = i, + }; + } + + return @Type(.{ .@"enum" = .{ + .fields = &fields, + .decls = &.{}, + .tag_type = std.math.IntFittingRange(0, names.len), + .is_exhaustive = true, + } }); +} + +/// Creates a slice to a comptime slice without triggering +/// `error: runtime value contains reference to comptime var` +pub fn comptime_slice(comptime slice: anytype, comptime len: usize) []const @TypeOf(slice[0]) { + return &@as([len]@TypeOf(slice[0]), slice[0..len].*); +} + +/// Return a Formatter for a u64 value representing a file size. +/// This formatter statically checks that the number is a multiple of 1024, +/// and represents it using the IEC measurement units (KiB, MiB, GiB, ...). +pub fn fmt_int_size_bin_exact(comptime value: u64) std.fmt.Formatter(format_int_size_bin_exact) { + comptime assert(value < 1024 or value % 1024 == 0); + return .{ .data = value }; +} + +fn format_int_size_bin_exact( + value: u64, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, +) !void { + _ = fmt; + if (value == 0) { + return std.fmt.formatBuf("0B", options, writer); + } + + // The worst case in terms of space needed is 20 bytes, + // since `maxInt(u64)` is the highest number, + // + 3 bytes for the measurement units suffix. + comptime assert(std.fmt.comptimePrint("{}GiB", .{std.math.maxInt(u64)}).len == 23); + var buf: [23]u8 = undefined; + + var magnitude: u8 = 0; + var value_unit = value; + while (value_unit % 1024 == 0) : (magnitude += 1) { + value_unit = @divExact(value_unit, 1024); + } + + const magnitudes_iec = "BKMGTPEZY"; + const suffix = magnitudes_iec[magnitude]; + + const length: usize = length: { + const i = std.fmt.formatIntBuf(&buf, value_unit, 10, .lower, .{}); + if (magnitude == 0) { + buf[i] = suffix; + break :length i + 1; + } else { + buf[i..][0..3].* = [_]u8{ suffix, 'i', 'B' }; + break :length i + 3; + } + }; + + return std.fmt.formatBuf(buf[0..length], options, writer); +} + +test fmt_int_size_bin_exact { + try std.testing.expectFmt("0B", "{}", .{fmt_int_size_bin_exact(0)}); + try std.testing.expectFmt("128B", "{}", .{fmt_int_size_bin_exact(128)}); + try std.testing.expectFmt("8KiB", "{}", .{fmt_int_size_bin_exact(8 * 1024)}); + try std.testing.expectFmt("1025KiB", "{}", .{fmt_int_size_bin_exact(1025 * 1024)}); + try std.testing.expectFmt("12345KiB", "{}", .{fmt_int_size_bin_exact(12345 * 1024)}); + try std.testing.expectFmt("42MiB", "{}", .{fmt_int_size_bin_exact(42 * 1024 * 1024)}); + try std.testing.expectFmt("18014398509481983KiB", "{}", .{ + fmt_int_size_bin_exact(std.math.maxInt(u64) - 1023), + }); +} + +// Dodge tidy: +const std_parse_int = @field(std.fmt, "parse" ++ "Int"); +const std_parse_unsigned = @field(std.fmt, "parse" ++ "Unsigned"); + +/// Strict by default integer parsing. +pub fn parse_int(T: type, text: []const u8, comptime options: struct { + base: u8 = 10, + allow_leading_zero: bool = false, + allow_separators: bool = false, +}) !T { + comptime assert((options.base == 10) or (options.base == 16)); + if (!options.allow_leading_zero) { + if (text.len > 1 and text[0] == '0') return error.LeadingZero; + } + for (text) |c| switch (c) { + '0'...'9', 'a'...'f', 'A'...'F' => {}, + '_' => if (!options.allow_separators) return error.InvalidCharacter, + '-' => if (@typeInfo(T).int.signedness == .unsigned) return error.InvalidCharacter, + else => return error.InvalidCharacter, + }; + return std_parse_int(T, text, options.base); +} + +test parse_int { + const T = struct { + fn check(text: []const u8) !void { + _ = try std_parse_int(u8, text, 10); + _ = parse_int(u8, text, .{}) catch return; + return error.TestExpectedError; + } + }; + + // Examples that parse with std, but should be rejected by default. + try T.check("0_0"); + try T.check("000"); + try T.check("-0"); +} + +// Allows `0b`, `0o`, `0x` prefixes to select the base, matches the syntax of Zig literals. +pub fn parse_int_with_base(T: type, text: []const u8) !T { + comptime assert(@typeInfo(T).int.signedness == .unsigned); + return std_parse_unsigned(T, text, 0); +} + +test parse_int_with_base { + try std.testing.expectEqual(0xBEE71E, try parse_int_with_base(u32, "0xBE_E71E")); +} + +/// Like std.fmt.bufPrint, but checks, at compile time, that the buffer is sufficiently large. +pub fn array_print( + comptime n: usize, + buffer: *[n]u8, + comptime fmt: []const u8, + args: anytype, +) []const u8 { + const Args = @TypeOf(args); + const ArgsStruct = @typeInfo(Args).@"struct"; + comptime assert(ArgsStruct.is_tuple); + + comptime { + var args_worst_case: Args = undefined; + for (ArgsStruct.fields, 0..) |field, index| { + const arg_worst_case = switch (field.type) { + u8, u16, u32, u64, u128 => std.math.maxInt(field.type), + else => @compileError("array_print: unsupported type: " ++ @typeName(field.type)), + }; + args_worst_case[index] = arg_worst_case; + } + const buffer_size = std.fmt.count(fmt, args_worst_case); + assert(n >= buffer_size); // array_print buffer too small + } + + return std.fmt.bufPrint(buffer, fmt, args) catch |err| switch (err) { + error.NoSpaceLeft => unreachable, + }; +} + +/// Like std.posix version, but log unconditionally, not just when mode=Debug. +/// The added `label` argument works around the absence of stack traces in ReleaseSafe builds. +pub fn unexpected_errno(label: []const u8, err: std.posix.system.E) std.posix.UnexpectedError { + log.scoped(.stdx).err("unexpected errno: {s}: code={d} name={?s}", .{ + label, + @intFromEnum(err), + std.enums.tagName(std.posix.system.E, err), + }); + + if (builtin.mode == .Debug) { + std.debug.dumpCurrentStackTrace(null); + } + return error.Unexpected; +} + +pub fn unique_u128() u128 { + const value = std.crypto.random.int(u128); + + // Broken CSPRNG is the likeliest explanation for zero or all ones. + assert(value != 0); + assert(value != std.math.maxInt(u128)); + + return value; +} + +/// NB: intended for parsing CLI arguments where we care to preserve the user-specified unit. +/// Use `size: u64` for all other use-cases. +pub const ByteSize = struct { + value: u64, + unit: Unit = .bytes, + + const Unit = enum(u64) { + bytes = 1, + kib = KiB, + mib = MiB, + gib = GiB, + tib = TiB, + }; + + pub fn parse_flag_value( + string: []const u8, + static_diagnostic: *?[]const u8, + ) error{InvalidFlagValue}!ByteSize { + assert(string.len != 0); + + const split_index = for (string, 0..) |c, index| { + if (std.ascii.isDigit(c) or c == '_') { + // Numeric part continues. + } else break index; + } else string.len; + + const string_amount = string[0..split_index]; + const string_unit = string[split_index..]; + maybe(string_amount.len == 0); + maybe(string_unit.len == 0); + + const amount = parse_int(u64, string_amount, .{ .allow_separators = true }) catch |err| + switch (err) { + error.Overflow => { + static_diagnostic.* = "value exceeds 64-bit unsigned integer:"; + return error.InvalidFlagValue; + }, + error.InvalidCharacter => { + static_diagnostic.* = "expected a size, but found:"; + return error.InvalidFlagValue; + }, + error.LeadingZero => { + static_diagnostic.* = "leading zero disallowed:"; + return error.InvalidFlagValue; + }, + }; + + const unit = if (string_unit.len == 0) + .bytes + else inline for (comptime std.enums.values(Unit)) |tag| { + if (std.ascii.eqlIgnoreCase(string_unit, @tagName(tag))) break tag; + } else { + static_diagnostic.* = "invalid unit in size, needed KiB, MiB, GiB or TiB:"; + return error.InvalidFlagValue; + }; + + _ = std.math.mul(u64, amount, @intFromEnum(unit)) catch { + static_diagnostic.* = "size in bytes exceeds 64-bit unsigned integer:"; + return error.InvalidFlagValue; + }; + + return .{ .value = amount, .unit = unit }; + } + + pub fn bytes(size: *const ByteSize) u64 { + return std.math.mul( + u64, + size.value, + @intFromEnum(size.unit), + ) catch unreachable; + } + + pub fn suffix(size: *const ByteSize) []const u8 { + return switch (size.unit) { + .bytes => "", + .kib => "KiB", + .mib => "MiB", + .gib => "GiB", + .tib => "TiB", + }; + } +}; + +test "ByteSize.parse_flag_value" { + try Flags.parse_flag_value_fuzz(ByteSize, ByteSize.parse_flag_value, .{ + .ok = &.{ + .{ "0", .{ .value = 0, .unit = .bytes } }, + .{ "1", .{ .value = 1, .unit = .bytes } }, + + .{ "140737488355328", .{ .value = 140737488355328, .unit = .bytes } }, + .{ "128TiB", .{ .value = 128, .unit = .tib } }, + .{ "1TiB", .{ .value = 1, .unit = .tib } }, + .{ "10tib", .{ .value = 10, .unit = .tib } }, + .{ "1GiB", .{ .value = 1, .unit = .gib } }, + .{ "10gib", .{ .value = 10, .unit = .gib } }, + .{ "1MiB", .{ .value = 1, .unit = .mib } }, + .{ "10mib", .{ .value = 10, .unit = .mib } }, + .{ "1KiB", .{ .value = 1, .unit = .kib } }, + .{ "10kib", .{ .value = 10, .unit = .kib } }, + .{ "1_0kib", .{ .value = 10, .unit = .kib } }, + }, + .err = &.{ + .{ "18446744073709551616", "value exceeds 64-bit unsigned integer" }, + .{ "MiB", "expected a size, but found" }, + .{ "_MiB", "expected a size" }, + .{ "10bananas", "invalid unit in size, needed KiB, MiB, GiB or TiB" }, + .{ "10GB", "invalid unit in size" }, + .{ "18446744073709551GiB", "size in bytes exceeds 64-bit unsigned integer" }, + .{ "0009GiB", "leading zero disallowed" }, + }, + }); +} + +// Fast alternative to modulo reduction (Note, it is not the same as modulo). +// See https://github.com/lemire/fastrange/ and +// https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction/ +pub inline fn fastrange(word: u64, p: u64) u64 { + const lword: u128 = @intCast(word); + const lp: u128 = @intCast(p); + const ln: u128 = lword *% lp; + return @truncate(ln >> 64); +} + +// For Zig 14.1 the compiler generates branchless code as is: https://godbolt.org/z/hc563WPKP +pub inline fn branchless_select(comptime T: type, flag: bool, a: T, b: T) T { + @branchHint(.unpredictable); + return if (flag) a else b; +} + +const snap = Snap.snap_fn("src/stdx"); + +test fastrange { + var prng = PRNG.from_seed(42); + var distribution: [8]u32 = @splat(0); + for (0..10_000) |_| { + const key = prng.int(u64); + distribution[fastrange(key, 8)] += 1; + } + try snap(@src(), + \\{ 1263, 1273, 1244, 1226, 1228, 1276, 1169, 1321 } + ).diff_fmt("{d}", .{distribution}); +} + +// This test shows that fastrange is not equivalent to modulo, but rather an alternative method. +// It is best used uniformly distributed hashes or random numbers across the full range. +test "fastrange not modulo" { + var distribution: [8]u32 = @splat(0); + for (0..10_000) |key| { + distribution[fastrange(key, 8)] += 1; + } + try snap(@src(), + \\{ 10000, 0, 0, 0, 0, 0, 0, 0 } + ).diff_fmt("{d}", .{distribution}); +} + +/// `status` is a waitpid() status result. +pub fn term_from_status(status: u32) std.process.Child.Term { + const Term = std.process.Child.Term; + return if (std.posix.W.IFEXITED(status)) + Term{ .Exited = std.posix.W.EXITSTATUS(status) } + else if (std.posix.W.IFSIGNALED(status)) + Term{ .Signal = std.posix.W.TERMSIG(status) } + else if (std.posix.W.IFSTOPPED(status)) + Term{ .Stopped = std.posix.W.STOPSIG(status) } + else + Term{ .Unknown = status }; +} + +/// Converts a snake_case identifier to another identifier case at comptime. +pub fn to_case( + comptime snake_case: []const u8, + comptime case: enum { camelCase, @"kebab-case", PascalCase, UPPER_CASE }, +) []const u8 { + return comptime blk: { + var output: [snake_case.len]u8 = undefined; + switch (case) { + .@"kebab-case" => { + for (snake_case, 0..) |byte, index| output[index] = if (byte == '_') '-' else byte; + break :blk comptime_slice(&output, snake_case.len); + }, + .UPPER_CASE => { + const len = std.ascii.upperString(output[0..], snake_case).len; + break :blk comptime_slice(&output, len); + }, + .camelCase, .PascalCase => { + var len: usize = 0; + var iterator = std.mem.tokenizeScalar(u8, snake_case, '_'); + while (iterator.next()) |word| { + _ = std.ascii.lowerString(output[len..], word); + output[len] = std.ascii.toUpper(output[len]); + len += word.len; + } + + output[0] = switch (case) { + .camelCase => std.ascii.toLower(output[0]), + .PascalCase => std.ascii.toUpper(output[0]), + .@"kebab-case", .UPPER_CASE => unreachable, + }; + + break :blk comptime_slice(&output, len); + }, + } + }; +} + +test "to_case" { + try std.testing.expectEqualStrings("createAccounts", to_case("create_accounts", .camelCase)); + try std.testing.expectEqualStrings("CreateTransfers", to_case("create_transfers", .PascalCase)); + try std.testing.expectEqualStrings("user-data-128", to_case("user_data_128", .@"kebab-case")); + try std.testing.expectEqualStrings( + "GET_ACCOUNT_BALANCES", + to_case("get_account_balances", .UPPER_CASE), + ); +} + +comptime { + _ = @import("bit_set.zig"); + _ = @import("bounded_array.zig"); + _ = @import("flags.zig"); + _ = @import("huge_page_allocator.zig"); + _ = @import("iops.zig"); + _ = @import("net.zig"); + _ = @import("prng.zig"); + _ = @import("radix.zig"); + _ = @import("radix_benchmark.zig"); + _ = @import("ring_buffer.zig"); + _ = @import("shell.zig"); + _ = @import("sort_test.zig"); + _ = @import("stdx.zig"); + _ = @import("testing/bench.zig"); + _ = @import("testing/snaptest.zig"); + _ = @import("time_units.zig"); + _ = @import("unshare.zig"); + _ = @import("vendored/aegis.zig"); + _ = @import("zipfian.zig"); +} diff --git a/ocam/src/stdx/testing/bench.zig b/ocam/src/stdx/testing/bench.zig new file mode 100644 index 00000000..792712ba --- /dev/null +++ b/ocam/src/stdx/testing/bench.zig @@ -0,0 +1,146 @@ +//! Micro benchmarking harness. +//! +//! Goals: +//! - relative (comparative) benchmarking, +//! - manual checks when refactoring/optimizing/upgrading compiler, +//! - no benchmark bitrot. +//! +//! Non-goals: +//! - absolute benchmarking, +//! - continuous benchmarking, +//! - automatic regression detection. +//! +//! If you run +//! $ ./zig/zig build test +//! the benchmarks are run in "test" mode which uses small inputs, finishes quickly, and doesn't +//! print anything to stdout. +//! +//! If you run +//! $ ./zig/zig build -Drelease test -- "benchmark: binary search" +//! the benchmark is run for real, with a large input, longer runtime, and results on stderr. +//! The `benchmark` in the test name is the secret code to unlock benchmarking code. + +test "benchmark: API tutorial" { // `benchmark:` in the name is important! + var bench: Bench = .init(); + defer bench.deinit(); + + // Parameters are named, and have two default values. + // The small value is used in tests, to prevent bitrot. + // The large value is the canonical when running benchmark "for real". + // You can pass custom values via env variables: + // $ a=92 ./zig/zig build test -- "benchmark: tutorial" + const a = bench.parameter("a", 1, 1_000); + const b = bench.parameter("b", 2, 2_000); + + bench.start(); // Built-in timer. + const c = a + b; + const elapsed = bench.stop(); + + // Always print a "hash" of the run: + // - to prevent compiler from optimizing the code away, + // - to prevent YOU from "optimizing" the code by changing semantics. + bench.report("hash: {}", .{c}); + // Print the time, and any other metrics you find important. + bench.report("elapsed: {}", .{elapsed}); + + // NB: print as little as possible, because humans read slowly. + // It's the job of benchmark author to optimize for conciseness. + + // You can compile individual benchmark via + // ./zig/zig build test:unit:build -- "benchmark: binary search" + // and use the resulting binary with perf/hyperfine/poop. +} + +const std = @import("std"); +const assert = std.debug.assert; +const stdx = @import("../stdx.zig"); +const Duration = stdx.Duration; +const Instant = stdx.Instant; +const Time = @import("./time.zig"); + +const seed_benchmark: u64 = 42; + +const mode: enum { smoke, benchmark } = + // See build.zig for how this is ultimately determined. + if (@import("test_options").benchmark) .benchmark else .smoke; + +seed: u64, +time: Time = .{}, +timer: ?Instant = null, + +const Bench = @This(); + +pub fn init() Bench { + return .{ + // Benchmarks require a fixed seed for reproducibility; smoke mode uses a random seed. + .seed = if (mode == .benchmark) seed_benchmark else std.testing.random_seed, + }; +} + +pub fn deinit(bench: *Bench) void { + assert(bench.timer == null); + bench.* = undefined; +} + +pub fn parameter( + b: *const Bench, + comptime name: []const u8, + value_smoke: u64, + value_benchmark: u64, +) u64 { + assert(value_smoke < value_benchmark); + const value = parameter_fallible(name, value_smoke, value_benchmark) catch |err| switch (err) { + error.InvalidCharacter, error.Overflow => @panic("invalid benchmark parameter value"), + }; + b.report("{s}={}", .{ name, value }); + return value; +} + +fn parameter_fallible( + comptime name: []const u8, + value_smoke: u64, + value_benchmark: u64, +) std.fmt.ParseIntError!u64 { + assert(value_smoke < value_benchmark); + return switch (mode) { + .smoke => value_smoke, + .benchmark => std.process.parseEnvVarInt(name, u64, 10) catch |err| switch (err) { + error.EnvironmentVariableNotFound => return value_benchmark, + else => |e| return e, + }, + }; +} + +pub fn start(bench: *Bench) void { + assert(bench.timer == null); + defer assert(bench.timer != null); + + bench.timer = bench.time.benchmark_monotonic(); +} + +pub fn stop(bench: *Bench) Duration { + assert(bench.timer != null); + defer assert(bench.timer == null); + + const instant_stop = bench.time.benchmark_monotonic(); + const elapsed = bench.timer.?.elapsed(instant_stop); + bench.timer = null; + return elapsed; +} + +// Sort the durations and return the third-fastest sample (discarding the two fastest outliers) +// to get a more stable estimate, assuming benchmark timings are roughly log-normal. +// E.g. see https://lemire.me/blog/2018/01/16/microbenchmarking-calls-for-idealized-conditions/ +pub fn estimate(bench: *const Bench, durations: []Duration) Duration { + assert(durations.len >= 8); // Ensure that we have enough samples to get a meaningful result. + _ = bench; + std.sort.block(stdx.Duration, durations, {}, stdx.Duration.sort.asc); + return durations[2]; +} + +pub fn report(_: *const Bench, comptime fmt: []const u8, args: anytype) void { + switch (mode) { + .smoke => {}, + .benchmark => std.debug.print(fmt ++ "\n", args), + } +} diff --git a/ocam/src/stdx/testing/low_level_hash_vectors.zig b/ocam/src/stdx/testing/low_level_hash_vectors.zig new file mode 100644 index 00000000..9d15e460 --- /dev/null +++ b/ocam/src/stdx/testing/low_level_hash_vectors.zig @@ -0,0 +1,142 @@ +//! Test vectors for `stdx.inline_hash` from +//! +//! + +pub const Case = struct { seed: u64, hash: u64, b64: []const u8 }; + +pub const cases = [_]Case{ + .{ .seed = 0xec42b7ab404b8acb, .hash = 0xe5a40d39ab796423, .b64 = "" }, + .{ .seed = 0, .hash = 0x1766974bf7527d81, .b64 = "ICAg" }, + .{ .seed = 0, .hash = 0x5c3bbbe230db17a8, .b64 = "YWFhYQ==" }, + .{ .seed = 0, .hash = 0xa6630143a7e6aa6f, .b64 = "AQID" }, + .{ .seed = 0, .hash = 0x8787cb2d04b0c984, .b64 = "AQIDBA==" }, + .{ .seed = 0, .hash = 0x33603654ff574ac2, .b64 = "dGhpcmRfcGFydHl8d3loYXNofDY0" }, + .{ .seed = 0xeeee074043a3ee0f, .hash = 0xa6564b468248c683, .b64 = "Zw==" }, + .{ .seed = 0x857902089c393de, .hash = 0xef192f401b116e1c, .b64 = "xmk=" }, + .{ .seed = 0x993df040024ca3af, .hash = 0xbe8dc0c54617639d, .b64 = "c1H/" }, + .{ .seed = 0xc4e4c2acea740e96, .hash = 0x93d7f665b5521c8e, .b64 = "SuwpzQ==" }, + .{ .seed = 0x6a214b3db872d0cf, .hash = 0x646d70bb42445f28, .b64 = "uqvy++M=" }, + .{ .seed = 0x44343db6a89dba4d, .hash = 0x96a7b1e3cc9bd426, .b64 = "RnzCVPgb" }, + .{ .seed = 0x77b5d6d1ae1dd483, .hash = 0x76020289ab0790c4, .b64 = "6OeNdlouYw==" }, + .{ .seed = 0x89ab8ecb44d221f1, .hash = 0x39f842e4133b9b44, .b64 = "M5/JmmYyDbc=" }, + .{ .seed = 0x60244b17577ca81b, .hash = 0x2b8d7047be4bcaab, .b64 = "MVijWiVdBRdY" }, + .{ .seed = 0x59a08dcee0717067, .hash = 0x99628abef6716a97, .b64 = "6V7Uq7LNxpu0VA==" }, + .{ .seed = 0xf5f20db3ade57396, .hash = 0x4432e02ba42b2740, .b64 = "EQ6CdEEhPdyHcOk=" }, + .{ .seed = 0xbf8dee0751ad3efb, .hash = 0x74d810efcad7918a, .b64 = "PqFB4fxnPgF+l+rc" }, + .{ .seed = 0x6b7a06b268d63e30, .hash = 0x88c84e986002507f, .b64 = "a5aPOFwq7LA7+zKvPA==" }, + .{ .seed = 0xb8c37f0ae0f54c82, .hash = 0x4f99acf193cf39b9, .b64 = "VOwY21wCGv5D+/qqOvs=" }, + .{ .seed = 0x9fcbed0c38e50eef, .hash = 0xd90e7a3655891e37, .b64 = "KdHmBTx8lHXYvmGJ+Vy7" }, + .{ .seed = 0x2af4bade1d8e3a1d, .hash = 0x3bb378b1d4df8fcf, .b64 = "qJkPlbHr8bMF7/cA6aE65Q==" }, + .{ .seed = 0x714e3aa912da2f2c, .hash = 0xf78e94045c052d47, .b64 = "ygvL0EhHZL0fIx6oHHtkxRQ=" }, + .{ .seed = 0xf5ee75e3cbb82c1c, .hash = 0x26da0b2130da6b40, .b64 = "c1rFXkt5YztwZCQRngncqtSs" }, + .{ .seed = 0x620e7007321b93b9, .hash = 0x30b4d426af8c6986, .b64 = "8hsQrzszzeNQSEcVXLtvIhm6mw==" }, + .{ .seed = 0xc08528cac2e551fc, .hash = 0x5413b4aaf3baaeae, .b64 = "ffUL4RocfyP4KfikGxO1yk7omDI=" }, + .{ .seed = 0x6a1debf9cc3ad39, .hash = 0x756ab265370a1597, .b64 = "OOB5TT00vF9Od/rLbAWshiErqhpV" }, + .{ .seed = 0x7e0a3c88111fc226, .hash = 0xdaf5f4b7d09814fb, .b64 = "or5wtXM7BFzTNpSzr+Lw5J5PMhVJ/Q==" }, + .{ .seed = 0x1301fef15df39edb, .hash = 0x8f874ae37742b75e, .b64 = "gk6pCHDUsoopVEiaCrzVDhioRKxb844=" }, + .{ .seed = 0x64e181f3d5817ab, .hash = 0x8fecd03956121ce8, .b64 = "TNctmwlC5QbEM6/No4R/La3UdkfeMhzs" }, + .{ .seed = 0xafafc44961078ecb, .hash = 0x229c292ea7a08285, .b64 = "SsQw9iAjhWz7sgcE9OwLuSC6hsM+BfHs2Q==" }, + .{ .seed = 0x4f7bb45549250094, .hash = 0xbb4bf0692d14bae, .b64 = "ZzO3mVCj4xTT2TT3XqDyEKj2BZQBvrS8RHg=" }, + .{ .seed = 0xa30061abaa2818c, .hash = 0x207b24ca3bdac1db, .b64 = "+klp5iPQGtppan5MflEls0iEUzqU+zGZkDJX" }, + .{ .seed = 0xd902ee3e44a5705f, .hash = 0x64f6cd6745d3825b, .b64 = "RO6bvOnlJc8I9eniXlNgqtKy0IX6VNg16NRmgg==" }, + .{ .seed = 0x316d36da516f583, .hash = 0xa2b2e1656b58df1e, .b64 = "ZJjZqId1ZXBaij9igClE3nyliU5XWdNRrayGlYA=" }, + .{ .seed = 0x402d83f9f834f616, .hash = 0xd01d30d9ee7a148, .b64 = "7BfkhfGMDGbxfMB8uyL85GbaYQtjr2K8g7RpLzr/" }, + .{ .seed = 0x9c604164c016b72c, .hash = 0x1cb4cd00ab804e3b, .b64 = "rycWk6wHH7htETQtje9PidS2YzXBx+Qkg2fY7ZYS7A==" }, + .{ .seed = 0x3f4507e01f9e73ba, .hash = 0x4697f2637fd90999, .b64 = "RTkC2OUK+J13CdGllsH0H5WqgspsSa6QzRZouqx6pvI=" }, + .{ .seed = 0xc3fe0d5be8d2c7c7, .hash = 0x8383a756b5688c07, .b64 = "tKjKmbLCNyrLCM9hycOAXm4DKNpM12oZ7dLTmUx5iwAi" }, + .{ .seed = 0x531858a40bfa7ea1, .hash = 0x695c29cb3696a975, .b64 = "VprUGNH+5NnNRaORxgH/ySrZFQFDL+4VAodhfBNinmn8cg==" }, + .{ .seed = 0x86689478a7a7e8fa, .hash = 0xda2e5a5a5e971521, .b64 = "gc1xZaY+q0nPcUvOOnWnT3bqfmT/geth/f7Dm2e/DemMfk4=" }, + .{ .seed = 0x4ec948b8e7f27288, .hash = 0x7935d4befa056b2b, .b64 = "Mr35fIxqx1ukPAL0su1yFuzzAU3wABCLZ8+ZUFsXn47UmAph" }, + .{ .seed = 0xce46c7213c10032, .hash = 0x38dd541ca95420fe, .b64 = "A9G8pw2+m7+rDtWYAdbl8tb2fT7FFo4hLi2vAsa5Y8mKH3CX3g==" }, + .{ .seed = 0xf63e96ee6f32a8b6, .hash = 0xcc06c7a4963f967f, .b64 = "DFaJGishGwEHDdj9ixbCoaTjz9KS0phLNWHVVdFsM93CvPft3hM=" }, + .{ .seed = 0x1cfe85e65fc5225, .hash = 0xbf0f6f66e232fb20, .b64 = "7+Ugx+Kr3aRNgYgcUxru62YkTDt5Hqis+2po81hGBkcrJg4N0uuy" }, + .{ .seed = 0x45c474f1cee1d2e8, .hash = 0xf7efb32d373fe71a, .b64 = "H2w6O8BUKqu6Tvj2xxaecxEI2wRgIgqnTTG1WwOgDSINR13Nm4d4Vg==" }, + .{ .seed = 0x6e024e14015f329c, .hash = 0xe2e64634b1c12660, .b64 = "1XBMnIbqD5jy65xTDaf6WtiwtdtQwv1dCVoqpeKj+7cTR1SaMWMyI04=" }, + .{ .seed = 0x760c40502103ae1c, .hash = 0x285b8fd1638e306d, .b64 = "znZbdXG2TSFrKHEuJc83gPncYpzXGbAebUpP0XxzH0rpe8BaMQ17nDbt" }, + .{ .seed = 0x17fd05c3c560c320, .hash = 0x658e8a4e3b714d6c, .b64 = "ylu8Atu13j1StlcC1MRMJJXIl7USgDDS22HgVv0WQ8hx/8pNtaiKB17hCQ==" }, + .{ .seed = 0x8b34200a6f8e90d9, .hash = 0xf391fb968e0eb398, .b64 = "M6ZVVzsd7vAvbiACSYHioH/440dp4xG2mLlBnxgiqEvI/aIEGpD0Sf4VS0g=" }, + .{ .seed = 0x6be89e50818bdf69, .hash = 0x744a9ea0cc144bf2, .b64 = "li3oFSXLXI+ubUVGJ4blP6mNinGKLHWkvGruun85AhVn6iuMtocbZPVhqxzn" }, + .{ .seed = 0xfb389773315b47d8, .hash = 0x12636f2be11012f1, .b64 = "kFuQHuUCqBF3Tc3hO4dgdIp223ShaCoog48d5Do5zMqUXOh5XpGK1t5XtxnfGA==" }, + .{ .seed = 0x4f2512a23f61efee, .hash = 0x29c57de825948f80, .b64 = "jWmOad0v0QhXVJd1OdGuBZtDYYS8wBVHlvOeTQx9ZZnm8wLEItPMeihj72E0nWY=" }, + .{ .seed = 0x59ccd92fc16c6fda, .hash = 0x58c6f99ab0d1c021, .b64 = "z+DHU52HaOQdW4JrZwDQAebEA6rm13Zg/9lPYA3txt3NjTBqFZlOMvTRnVzRbl23" }, + .{ .seed = 0x25c5a7f5bd330919, .hash = 0x13e7b5a7b82fe3bb, .b64 = "MmBiGDfYeTayyJa/tVycg+rN7f9mPDFaDc+23j0TlW9094er0ADigsl4QX7V3gG/qw==" }, + .{ .seed = 0x51df4174d34c97d7, .hash = 0x10fbc87901e02b63, .b64 = "774RK+9rOL4iFvs1q2qpo/JVc/I39buvNjqEFDtDvyoB0FXxPI2vXqOrk08VPfIHkmU=" }, + .{ .seed = 0x80ce6d76f89cb57, .hash = 0xa24c9184901b748b, .b64 = "+slatXiQ7/2lK0BkVUI1qzNxOOLP3I1iK6OfHaoxgqT63FpzbElwEXSwdsryq3UlHK0I" }, + .{ .seed = 0x20961c911965f684, .hash = 0xcac4fd4c5080e581, .b64 = "64mVTbQ47dHjHlOHGS/hjJwr/K2frCNpn87exOqMzNUVYiPKmhCbfS7vBUce5tO6Ec9osQ==" }, + .{ .seed = 0x4e5b926ec83868e7, .hash = 0xc38bdb7483ba68e1, .b64 = "fIsaG1r530SFrBqaDj1kqE0AJnvvK8MNEZbII2Yw1OK77v0V59xabIh0B5axaz/+a2V5WpA=" }, + .{ .seed = 0x3927b30b922eecef, .hash = 0xdb2a8069b2ceaffa, .b64 = "PGih0zDEOWCYGxuHGDFu9Ivbff/iE7BNUq65tycTR2R76TerrXALRosnzaNYO5fjFhTi+CiS" }, + .{ .seed = 0xbd0291284a49b61c, .hash = 0xdf9fe91d0d1c7887, .b64 = "RnpA/zJnEnnLjmICORByRVb9bCOgxF44p3VMiW10G7PvW7IhwsWajlP9kIwNA9FjAD2GoQHk2Q==" }, + .{ .seed = 0x73a77c575bcc956, .hash = 0xe83f49e96e2e6a08, .b64 = "qFklMceaTHqJpy2qavJE+EVBiNFOi6OxjOA3LeIcBop1K7w8xQi3TrDk+BrWPRIbfprszSaPfrI=" }, + .{ .seed = 0x766a0e2ade6d09a6, .hash = 0xc69e61b62ca2b62, .b64 = "cLbfUtLl3EcQmITWoTskUR8da/VafRDYF/ylPYwk7/zazk6ssyrzxMN3mmSyvrXR2yDGNZ3WDrTT" }, + .{ .seed = 0x2599f4f905115869, .hash = 0xb4a4f3f85f8298fe, .b64 = "s/Jf1+FbsbCpXWPTUSeWyMH6e4CvTFvPE5Fs6Z8hvFITGyr0dtukHzkI84oviVLxhM1xMxrMAy1dbw==" }, + .{ .seed = 0xd8256e5444d21e53, .hash = 0x167a1b39e1e95f41, .b64 = "FvyQ00+j7nmYZVQ8hI1Edxd0AWplhTfWuFGiu34AK5X8u2hLX1bE97sZM0CmeLe+7LgoUT1fJ/axybE=" }, + .{ .seed = 0xf664a91333fb8dfd, .hash = 0xf8a2a5649855ee41, .b64 = "L8ncxMaYLBH3g9buPu8hfpWZNlOF7nvWLNv9IozH07uQsIBWSKxoPy8+LW4tTuzC6CIWbRGRRD1sQV/4" }, + .{ .seed = 0x9625b859be372cd1, .hash = 0x27992565b595c498, .b64 = "CDK0meI07yrgV2kQlZZ+wuVqhc2NmzqeLH7bmcA6kchsRWFPeVF5Wqjjaj556ABeUoUr3yBmfU3kWOakkg==" }, + .{ .seed = 0x7b99940782e29898, .hash = 0x3e08cca5b71f9346, .b64 = "d23/vc5ONh/HkMiq+gYk4gaCNYyuFKwUkvn46t+dfVcKfBTYykr4kdvAPNXGYLjM4u1YkAEFpJP+nX7eOvs=" }, + .{ .seed = 0x4fe12fa5383b51a8, .hash = 0xad406b10c770a6d2, .b64 = "NUR3SRxBkxTSbtQORJpu/GdR6b/h6sSGfsMj/KFd99ahbh+9r7LSgSGmkGVB/mGoT0pnMTQst7Lv2q6QN6Vm" }, + .{ .seed = 0xe2ccb09ac0f5b4b6, .hash = 0xd1713ce6e552bcf2, .b64 = "2BOFlcI3Z0RYDtS9T9Ie9yJoXlOdigpPeeT+CRujb/O39Ih5LPC9hP6RQk1kYESGyaLZZi3jtabHs7DiVx/VDg==" }, + .{ .seed = 0x7d0a37adbd7b753b, .hash = 0x753b287194c73ad3, .b64 = "FF2HQE1FxEvWBpg6Z9zAMH+Zlqx8S1JD/wIlViL6ZDZY63alMDrxB0GJQahmAtjlm26RGLnjW7jmgQ4Ie3I+014=" }, + .{ .seed = 0xd3ae96ef9f7185f2, .hash = 0x5ae41a95f600af1c, .b64 = "tHmO7mqVL/PX11nZrz50Hc+M17Poj5lpnqHkEN+4bpMx/YGbkrGOaYjoQjgmt1X2QyypK7xClFrjeWrCMdlVYtbW" }, + .{ .seed = 0x4fb88ea63f79a0d8, .hash = 0x4a61163b86a8bb4c, .b64 = "/WiHi9IQcxRImsudkA/KOTqGe8/gXkhKIHkjddv5S9hi02M049dIK3EUyAEjkjpdGLUs+BN0QzPtZqjIYPOgwsYE9g==" }, + .{ .seed = 0xed564e259bb5ebe9, .hash = 0x42eeaa79e760c7e4, .b64 = "qds+1ExSnU11L4fTSDz/QE90g4Jh6ioqSh3KDOTOAo2pQGL1k/9CCC7J23YF27dUTzrWsCQA2m4epXoCc3yPHb3xElA=" }, + .{ .seed = 0x3e3256b60c428000, .hash = 0x698df622ef465b0a, .b64 = "8FVYHx40lSQPTHheh08Oq0/pGm2OlG8BEf8ezvAxHuGGdgCkqpXIueJBF2mQJhTfDy5NncO8ntS7vaKs7sCNdDaNGOEi" }, + .{ .seed = 0xfb05bad59ec8705, .hash = 0x157583111e1a6026, .b64 = "4ZoEIrJtstiCkeew3oRzmyJHVt/pAs2pj0HgHFrBPztbQ10NsQ/lM6DM439QVxpznnBSiHMgMQJhER+70l72LqFTO1JiIQ==" }, + .{ .seed = 0xafdc251dbf97b5f8, .hash = 0xaa1388f078e793e0, .b64 = "hQPtaYI+wJyxXgwD5n8jGIKFKaFA/P83KqCKZfPthnjwdOFysqEOYwAaZuaaiv4cDyi9TyS8hk5cEbNP/jrI7q6pYGBLbsM=" }, + .{ .seed = 0x10ec9c92ddb5dcbc, .hash = 0xf10d68d0f3309360, .b64 = "S4gpMSKzMD7CWPsSfLeYyhSpfWOntyuVZdX1xSBjiGvsspwOZcxNKCRIOqAA0moUfOh3I5+juQV4rsqYElMD/gWfDGpsWZKQ" }, + .{ .seed = 0x9a767d5822c7dac4, .hash = 0x2af056184457a3de, .b64 = "oswxop+bthuDLT4j0PcoSKby4LhF47ZKg8K17xxHf74UsGCzTBbOz0MM8hQEGlyqDT1iUiAYnaPaUpL2mRK0rcIUYA4qLt5uOw==" }, + .{ .seed = 0xee46254080d6e2db, .hash = 0x6d0058e1590b2489, .b64 = "0II/697p+BtLSjxj5989OXI004TogEb94VUnDzOVSgMXie72cuYRvTFNIBgtXlKfkiUjeqVpd4a+n5bxNOD1TGrjQtzKU5r7obo=" }, + .{ .seed = 0xbbb669588d8bf398, .hash = 0x638f287f68817f12, .b64 = "E84YZW2qipAlMPmctrg7TKlwLZ68l4L+c0xRDUfyyFrA4MAti0q9sHq3TDFviH0Y+Kq3tEE5srWFA8LM9oomtmvm5PYxoaarWPLc" }, + .{ .seed = 0xdc2afaa529beef44, .hash = 0xc46b71fecefd5467, .b64 = "x3pa4HIElyZG0Nj7Vdy9IdJIR4izLmypXw5PCmZB5y68QQ4uRaVVi3UthsoJROvbjDJkP2DQ6L/eN8pFeLFzNPKBYzcmuMOb5Ull7w==" }, + .{ .seed = 0xf1f67391d45013a8, .hash = 0x2c8e94679d964e0a, .b64 = "jVDKGYIuWOP/QKLdd2wi8B2VJA8Wh0c8PwrXJVM8FOGM3voPDVPyDJOU6QsBDPseoR8uuKd19OZ/zAvSCB+zlf6upAsBlheUKgCfKww=" }, + .{ .seed = 0x16fce2b8c65a3429, .hash = 0x8612b797ce22503a, .b64 = "mkquunhmYe1aR2wmUz4vcvLEcKBoe6H+kjUok9VUn2+eTSkWs4oDDtJvNCWtY5efJwg/j4PgjRYWtqnrCkhaqJaEvkkOwVfgMIwF3e+d" }, + .{ .seed = 0xf4b096699f49fe67, .hash = 0x59f929babfba7170, .b64 = "fRelvKYonTQ+s+rnnvQw+JzGfFoPixtna0vzcSjiDqX5s2Kg2//UGrK+AVCyMUhO98WoB1DDbrsOYSw2QzrcPe0+3ck9sePvb+Q/IRaHbw==" }, + .{ .seed = 0xca584c4bc8198682, .hash = 0x9527556923fb49a0, .b64 = "DUwXFJzagljo44QeJ7/6ZKw4QXV18lhkYT2jglMr8WB3CHUU4vdsytvw6AKv42ZcG6fRkZkq9fpnmXy6xG0aO3WPT1eHuyFirAlkW+zKtwg=" }, + .{ .seed = 0xed269fc3818b6aad, .hash = 0x1039ab644f5e150b, .b64 = "cYmZCrOOBBongNTr7e4nYn52uQUy2mfe48s50JXx2AZ6cRAt/xRHJ5QbEoEJOeOHsJyM4nbzwFm++SlT6gFZZHJpkXJ92JkR86uS/eV1hJUR" }, + .{ .seed = 0x33f253cbb8fe66a8, .hash = 0x7816c83f3aa05e6d, .b64 = "EXeHBDfhwzAKFhsMcH9+2RHwV+mJaN01+9oacF6vgm8mCXRd6jeN9U2oAb0of5c5cO4i+Vb/LlHZSMI490SnHU0bejhSCC2gsC5d2K30ER3iNA==" }, + .{ .seed = 0xd0b76b2c1523d99c, .hash = 0xf51d2f564518c619, .b64 = "FzkzRYoNjkxFhZDso94IHRZaJUP61nFYrh5MwDwv9FNoJ5jyNCY/eazPZk+tbmzDyJIGw2h3GxaWZ9bSlsol/vK98SbkMKCQ/wbfrXRLcDzdd/8=" }, + .{ .seed = 0xfd28f0811a2a237f, .hash = 0x67d494cff03ac004, .b64 = "Re4aXISCMlYY/XsX7zkIFR04ta03u4zkL9dVbLXMa/q6hlY/CImVIIYRN3VKP4pnd0AUr/ugkyt36JcstAInb4h9rpAGQ7GMVOgBniiMBZ/MGU7H" }, + .{ .seed = 0x6261fb136482e84, .hash = 0x2802d636ced1cfbb, .b64 = "ueLyMcqJXX+MhO4UApylCN9WlTQ+ltJmItgG7vFUtqs2qNwBMjmAvr5u0sAKd8jpzV0dDPTwchbIeAW5zbtkA2NABJV6hFM48ib4/J3A5mseA3cS8w==" }, + .{ .seed = 0x458efc750bca7c3a, .hash = 0xf64e20bad771cb12, .b64 = "6Si7Yi11L+jZMkwaN+GUuzXMrlvEqviEkGOilNq0h8TdQyYKuFXzkYc/q74gP3pVCyiwz9KpVGMM9vfnq36riMHRknkmhQutxLZs5fbmOgEO69HglCU=" }, + .{ .seed = 0xa7e69ff84e5e7c27, .hash = 0xb9a6cf84a83e15e, .b64 = "Q6AbOofGuTJOegPh9Clm/9crtUMQqylKrTc1fhfJo1tqvpXxhU4k08kntL1RG7woRnFrVh2UoMrL1kjin+s9CanT+y4hHwLqRranl9FjvxfVKm3yvg68" }, + .{ .seed = 0x3c59bfd0c29efe9e, .hash = 0x8da6630319609301, .b64 = "ieQEbIPvqY2YfIjHnqfJiO1/MIVRk0RoaG/WWi3kFrfIGiNLCczYoklgaecHMm/1sZ96AjO+a5stQfZbJQwS7Sc1ODABEdJKcTsxeW2hbh9A6CFzpowP1A==" }, + .{ .seed = 0x10befacc6afd298d, .hash = 0x40946a86e2a996f3, .b64 = "zQUv8hFB3zh2GGl3KTvCmnfzE+SUgQPVaSVIELFX5H9cE3FuVFGmymkPQZJLAyzC90Cmi8GqYCvPqTuAAB//XTJxy4bCcVArgZG9zJXpjowpNBfr3ngWrSE=" }, + .{ .seed = 0x41d5320b0a38efa7, .hash = 0xcab7f5997953fa76, .b64 = "US4hcC1+op5JKGC7eIs8CUgInjKWKlvKQkapulxW262E/B2ye79QxOexf188u2mFwwe3WTISJHRZzS61IwljqAWAWoBAqkUnW8SHmIDwHUP31J0p5sGdP47L" }, + .{ .seed = 0x58db1c7450fe17f3, .hash = 0x39129ca0e04fc465, .b64 = "9bHUWFna2LNaGF6fQLlkx1Hkt24nrkLE2CmFdWgTQV3FFbUe747SSqYw6ebpTa07MWSpWRPsHesVo2B9tqHbe7eQmqYebPDFnNqrhSdZwFm9arLQVs+7a3Ic6A==" }, + .{ .seed = 0x6098c055a335b7a6, .hash = 0x5238221fd685e1b8, .b64 = "Kb3DpHRUPhtyqgs3RuXjzA08jGb59hjKTOeFt1qhoINfYyfTt2buKhD6YVffRCPsgK9SeqZqRPJSyaqsa0ovyq1WnWW8jI/NhvAkZTVHUrX2pC+cD3OPYT05Dag=" }, + .{ .seed = 0x1bbacec67845a801, .hash = 0x175130c407dbcaab, .b64 = "gzxyMJIPlU+bJBwhFUCHSofZ/319LxqMoqnt3+L6h2U2+ZXJCSsYpE80xmR0Ta77Jq54o92SMH87HV8dGOaCTuAYF+lDL42SY1P316Cl0sZTS2ow3ZqwGbcPNs/1" }, + .{ .seed = 0xc419cfc7442190, .hash = 0x2f20e7536c0b0df, .b64 = "uR7V0TW+FGVMpsifnaBAQ3IGlr1wx5sKd7TChuqRe6OvUXTlD4hKWy8S+8yyOw8lQabism19vOQxfmocEOW/vzY0pEa87qHrAZy4s9fH2Bltu8vaOIe+agYohhYORQ==" }, + .{ .seed = 0xc95e510d94ba270c, .hash = 0x2742cb488a04ad56, .b64 = "1UR5eoo2aCwhacjZHaCh9bkOsITp6QunUxHQ2SfeHv0imHetzt/Z70mhyWZBalv6eAx+YfWKCUib2SHDtz/A2dc3hqUWX5VfAV7FQsghPUAtu6IiRatq4YSLpDvKZBQ=" }, + .{ .seed = 0xff1ae05c98089c3f, .hash = 0xd6afb593879ff93b, .b64 = "opubR7H63BH7OtY+Avd7QyQ25UZ8kLBdFDsBTwZlY6gA/u+x+czC9AaZMgmQrUy15DH7YMGsvdXnviTtI4eVI4aF1H9Rl3NXMKZgwFOsdTfdcZeeHVRzBBKX8jUfh1il" }, + .{ .seed = 0x90c02b8dceced493, .hash = 0xf50ad64caac0ca7f, .b64 = "DC0kXcSXtfQ9FbSRwirIn5tgPri0sbzHSa78aDZVDUKCMaBGyFU6BmrulywYX8yzvwprdLsoOwTWN2wMjHlPDqrvVHNEjnmufRDblW+nSS+xtKNs3N5xsxXdv6JXDrAB/Q==" }, + .{ .seed = 0x9f8a76697ab1aa36, .hash = 0x2ade95c4261364ae, .b64 = "BXRBk+3wEP3Lpm1y75wjoz+PgB0AMzLe8tQ1AYU2/oqrQB2YMC6W+9QDbcOfkGbeH+b7IBkt/gwCMw2HaQsRFEsurXtcQ3YwRuPz5XNaw5NAvrNa67Fm7eRzdE1+hWLKtA8=" }, + .{ .seed = 0x6ba1bf3d811a531d, .hash = 0x5c4f3299faacd07a, .b64 = "RRBSvEGYnzR9E45Aps/+WSnpCo/X7gJLO4DRnUqFrJCV/kzWlusLE/6ZU6RoUf2ROwcgEvUiXTGjLs7ts3t9SXnJHxC1KiOzxHdYLMhVvgNd3hVSAXODpKFSkVXND55G2L1W" }, + .{ .seed = 0x6a418974109c67b4, .hash = 0xfffe3bff0ae5e9bc, .b64 = "jeh6Qazxmdi57pa9S3XSnnZFIRrnc6s8QLrah5OX3SB/V2ErSPoEAumavzQPkdKF1/SfvmdL+qgF1C+Yawy562QaFqwVGq7+tW0yxP8FStb56ZRgNI4IOmI30s1Ei7iops9Uuw==" }, + .{ .seed = 0x8472f1c2b3d230a3, .hash = 0x1db785c0005166e4, .b64 = "6QO5nnDrY2/wrUXpltlKy2dSBcmK15fOY092CR7KxAjNfaY+aAmtWbbzQk3MjBg03x39afSUN1fkrWACdyQKRaGxgwq6MGNxI6W+8DLWJBHzIXrntrE/ml6fnNXEpxplWJ1vEs4=" }, + .{ .seed = 0x5e06068f884e73a7, .hash = 0xea000d962ad18418, .b64 = "0oPxeEHhqhcFuwonNfLd5jF3RNATGZS6NPoS0WklnzyokbTqcl4BeBkMn07+fDQv83j/BpGUwcWO05f3+DYzocfnizpFjLJemFGsls3gxcBYxcbqWYev51tG3lN9EvRE+X9+Pwww" }, + .{ .seed = 0x55290b1a8f170f59, .hash = 0xe42aef38359362d9, .b64 = "naSBSjtOKgAOg8XVbR5cHAW3Y+QL4Pb/JO9/oy6L08wvVRZqo0BrssMwhzBP401Um7A4ppAupbQeJFdMrysY34AuSSNvtNUy5VxjNECwiNtgwYHw7yakDUv8WvonctmnoSPKENegQg==" }, + .{ .seed = 0x5501cfd83dfe706a, .hash = 0xc8e95657348a3891, .b64 = "vPyl8DxVeRe1OpilKb9KNwpGkQRtA94UpAHetNh+95V7nIW38v7PpzhnTWIml5kw3So1Si0TXtIUPIbsu32BNhoH7QwFvLM+JACgSpc5e3RjsL6Qwxxi11npwxRmRUqATDeMUfRAjxg=" }, + .{ .seed = 0xe43ed13d13a66990, .hash = 0xc162eca864f238c6, .b64 = "QC9i2GjdTMuNC1xQJ74ngKfrlA4w3o58FhvNCltdIpuMhHP1YsDA78scQPLbZ3OCUgeQguYf/vw6zAaVKSgwtaykqg5ka/4vhz4hYqWU5ficdXqClHl+zkWEY26slCNYOM5nnDlly8Cj" }, + .{ .seed = 0xdf43bc375cf5283f, .hash = 0xbe1fb373e20579ad, .b64 = "7CNIgQhAHX27nxI0HeB5oUTnTdgKpRDYDKwRcXfSFGP1XeT9nQF6WKCMjL1tBV6x7KuJ91GZz11F4c+8s+MfqEAEpd4FHzamrMNjGcjCyrVtU6y+7HscMVzr7Q/ODLcPEFztFnwjvCjmHw==" }, + .{ .seed = 0x8112b806d288d7b5, .hash = 0x628a1d4f40aa6ffd, .b64 = "Qa/hC2RPXhANSospe+gUaPfjdK/yhQvfm4cCV6/pdvCYWPv8p1kMtKOX3h5/8oZ31fsmx4Axphu5qXJokuhZKkBUJueuMpxRyXpwSWz2wELx5glxF7CM0Fn+OevnkhUn5jsPlG2r5jYlVn8=" }, + .{ .seed = 0xd52a18abb001cb46, .hash = 0xa87bdb7456340f90, .b64 = "kUw/0z4l3a89jTwN5jpG0SHY5km/IVhTjgM5xCiPRLncg40aqWrJ5vcF891AOq5hEpSq0bUCJUMFXgct7kvnys905HjerV7Vs1Gy84tgVJ70/2+pAZTsB/PzNOE/G6sOj4+GbTzkQu819OLB" }, + .{ .seed = 0xe12b76a2433a1236, .hash = 0x5960ef3ba982c801, .b64 = "VDdfSDbO8Tdj3T5W0XM3EI7iHh5xpIutiM6dvcJ/fhe23V/srFEkDy5iZf/VnA9kfi2C79ENnFnbOReeuZW1b3MUXB9lgC6U4pOTuC+jHK3Qnpyiqzj7h3ISJSuo2pob7vY6VHZo6Fn7exEqHg==" }, + .{ .seed = 0x175bf7319cf1fa00, .hash = 0x5026586df9a431ec, .b64 = "Ldfvy3ORdquM/R2fIkhH/ONi69mcP1AEJ6n/oropwecAsLJzQSgezSY8bEiEs0VnFTBBsW+RtZY6tDj03fnb3amNUOq1b7jbqyQkL9hpl+2Z2J8IaVSeownWl+bQcsR5/xRktIMckC5AtF4YHfU=" }, + .{ .seed = 0xd63d57b3f67525ae, .hash = 0xfe4b8a20fdf0840b, .b64 = "BrbNpb42+VzZAjJw6QLirXzhweCVRfwlczzZ0VX2xluskwBqyfnGovz5EuX79JJ31VNXa5hTkAyQat3lYKRADTdAdwE5PqM1N7YaMqqsqoAAAeuYVXuk5eWCykYmClNdSspegwgCuT+403JigBzi" }, + .{ .seed = 0x933faea858832b73, .hash = 0xdcb761867da7072f, .b64 = "gB3NGHJJvVcuPyF0ZSvHwnWSIfmaI7La24VMPQVoIIWF7Z74NltPZZpx2f+cocESM+ILzQW9p+BC8x5IWz7N4Str2WLGKMdgmaBfNkEhSHQDU0IJEOnpUt0HmjhFaBlx0/LTmhua+rQ6Wup8ezLwfg==" }, + .{ .seed = 0x53d061e5f8e7c04f, .hash = 0xc10d4653667275b7, .b64 = "hTKHlRxx6Pl4gjG+6ksvvj0CWFicUg3WrPdSJypDpq91LUWRni2KF6+81ZoHBFhEBrCdogKqeK+hy9bLDnx7g6rAFUjtn1+cWzQ2YjiOpz4+ROBB7lnwjyTGWzJD1rXtlso1g2qVH8XJVigC5M9AIxM=" }, + .{ .seed = 0xdb4124556dd515e0, .hash = 0x727720deec13110b, .b64 = "IWQBelSQnhrr0F3BhUpXUIDauhX6f95Qp+A0diFXiUK7irwPG1oqBiqHyK/SH/9S+rln9DlFROAmeFdH0OCJi2tFm4afxYzJTFR4HnR4cG4x12JqHaZLQx6iiu6CE3rtWBVz99oAwCZUOEXIsLU24o2Y" }, + .{ .seed = 0x4fb31a0dd681ee71, .hash = 0x710b009662858dc9, .b64 = "TKo+l+1dOXdLvIrFqeLaHdm0HZnbcdEgOoLVcGRiCbAMR0j5pIFw8D36tefckAS1RCFOH5IgP8yiFT0Gd0a2hI3+fTKA7iK96NekxWeoeqzJyctc6QsoiyBlkZerRxs5RplrxoeNg29kKDTM0K94mnhD9g==" }, + .{ .seed = 0x27cc72eefa138e4c, .hash = 0xfbf8f7a3ecac1eb7, .b64 = "YU4e7G6EfQYvxCFoCrrT0EFgVLHFfOWRTJQJ5gxM3G2b+1kJf9YPrpsxF6Xr6nYtS8reEEbDoZJYqnlk9lXSkVArm88Cqn6d25VCx3+49MqC0trIlXtb7SXUUhwpJK16T0hJUfPH7s5cMZXc6YmmbFuBNPE=" }, + .{ .seed = 0x44bc2dfba4bd3ced, .hash = 0xb6fc4fcd0722e3df, .b64 = "/I/eImMwPo1U6wekNFD1Jxjk9XQVi1D+FPdqcHifYXQuP5aScNQfxMAmaPR2XhuOQhADV5tTVbBKwCDCX4E3jcDNHzCiPvViZF1W27txaf2BbFQdwKrNCmrtzcluBFYu0XZfc7RU1RmxK/RtnF1qHsq/O4pp" }, + .{ .seed = 0x242da1e3a439bed8, .hash = 0x7cb86dcc55104aac, .b64 = "CJTT9WGcY2XykTdo8KodRIA29qsqY0iHzWZRjKHb9alwyJ7RZAE3V5Juv4MY3MeYEr1EPCCMxO7yFXqT8XA8YTjaMp3bafRt17Pw8JC4iKJ1zN+WWKOESrj+3aluGQqn8z1EzqY4PH7rLG575PYeWsP98BugdA==" }, + .{ .seed = 0xdc559c746e35c139, .hash = 0x19e71e9b45c3a51e, .b64 = "ZlhyQwLhXQyIUEnMH/AEW27vh9xrbNKJxpWGtrEmKhd+nFqAfbeNBQjW0SfG1YI0xQkQMHXjuTt4P/EpZRtA47ibZDVS8TtaxwyBjuIDwqcN09eCtpC+Ls+vWDTLmBeDM3u4hmzz4DQAYsLiZYSJcldg9Q3wszw=" }, + .{ .seed = 0xd0b0350275b9989, .hash = 0x51de38573c2bea48, .b64 = "v2KU8y0sCrBghmnm8lzGJlwo6D6ObccAxCf10heoDtYLosk4ztTpLlpSFEyu23MLA1tJkcgRko04h19QMG0mOw/wc93EXAweriBqXfvdaP85sZABwiKO+6rtS9pacRVpYYhHJeVTQ5NzrvBvi1huxAr+xswhVMfL" }, + .{ .seed = 0xb04489e41d17730c, .hash = 0xa73ab6996d6df158, .b64 = "QhKlnIS6BuVCTQsnoE67E/yrgogE8EwO7xLaEGei26m0gEU4OksefJgppDh3X0x0Cs78Dr9IHK5b977CmZlrTRmwhlP8pM+UzXPNRNIZuN3ntOum/QhUWP8SGpirheXENWsXMQ/nxtxakyEtrNkKk471Oov9juP8oQ==" }, + .{ .seed = 0x2217285eb4572156, .hash = 0x55ef2b8c930817b2, .b64 = "/ZRMgnoRt+Uo6fUPr9FqQvKX7syhgVqWu+WUSsiQ68UlN0efSP6Eced5gJZL6tg9gcYJIkhjuQNITU0Q3TjVAnAcobgbJikCn6qZ6pRxKBY4MTiAlfGD3T7R7hwJwx554MAy++Zb/YUFlnCaCJiwQMnowF7aQzwYFCo=" }, + .{ .seed = 0x12c2e8e68aede73b, .hash = 0xb2850bf5fae87157, .b64 = "NB7tU5fNE8nI+SXGfipc7sRkhnSkUF1krjeo6k+8FITaAtdyz+o7mONgXmGLulBPH9bEwyYhKNVY0L+njNQrZ9YC2aXsFD3PdZsxAFaBT3VXEzh+NGBTjDASNL3mXyS8Yv1iThGfHoY7T4aR0NYGJ+k+pR6f+KrPC96M" }, + .{ .seed = 0x4d612125bdc4fd00, .hash = 0xecf3de1acd04651f, .b64 = "8T6wrqCtEO6/rwxF6lvMeyuigVOLwPipX/FULvwyu+1wa5sQGav/2FsLHUVn6cGSi0LlFwLewGHPFJDLR0u4t7ZUyM//x6da0sWgOa5hzDqjsVGmjxEHXiaXKW3i4iSZNuxoNbMQkIbVML+DkYu9ND0O2swg4itGeVSzXA==" }, + .{ .seed = 0x81826b553954464e, .hash = 0xcc0a40552559ff32, .b64 = "Ntf1bMRdondtMv1CYr3G80iDJ4WSAlKy5H34XdGruQiCrnRGDBa+eUi7vKp4gp3BBcVGl8eYSasVQQjn7MLvb3BjtXx6c/bCL7JtpzQKaDnPr9GWRxpBXVxKREgMM7d8lm35EODv0w+hQLfVSh8OGs7fsBb68nNWPLeeSOo=" }, + .{ .seed = 0xc2e5d345dc0ddd2d, .hash = 0xc385c374f20315b1, .b64 = "VsSAw72Ro6xks02kaiLuiTEIWBC5bgqr4WDnmP8vglXzAhixk7td926rm9jNimL+kroPSygZ9gl63aF5DCPOACXmsbmhDrAQuUzoh9ZKhWgElLQsrqo1KIjWoZT5b5QfVUXY9lSIBg3U75SqORoTPq7HalxxoIT5diWOcJQi" }, + .{ .seed = 0x3da6830a9e32631e, .hash = 0xb90208a4c7234183, .b64 = "j+loZ+C87+bJxNVebg94gU0mSLeDulcHs84tQT7BZM2rzDSLiCNxUedHr1ZWJ9ejTiBa0dqy2I2ABc++xzOLcv+//YfibtjKtYggC6/3rv0XCc7xu6d/O6xO+XOBhOWAQ+IHJVHf7wZnDxIXB8AUHsnjEISKj7823biqXjyP3g==" }, + .{ .seed = 0xc9ae5c8759b4877a, .hash = 0x58aa1ca7a4c075d9, .b64 = "f3LlpcPElMkspNtDq5xXyWU62erEaKn7RWKlo540gR6mZsNpK1czV/sOmqaq8XAQLEn68LKj6/cFkJukxRzCa4OF1a7cCAXYFp9+wZDu0bw4y63qbpjhdCl8GO6Z2lkcXy7KOzbPE01ukg7+gN+7uKpoohgAhIwpAKQXmX5xtd0=" }, +}; diff --git a/ocam/src/stdx/testing/snaptest.zig b/ocam/src/stdx/testing/snaptest.zig new file mode 100644 index 00000000..292ed318 --- /dev/null +++ b/ocam/src/stdx/testing/snaptest.zig @@ -0,0 +1,361 @@ +//! A tiny pattern/library for testing with expectations ([1], [2]). +//! +//! On a high level, this is a replacement for `std.testing.expectEqual` which: +//! +//! - is less cumbersome to use for complex types, +//! - gives somewhat more useful feedback on a test failure without much investment, +//! - drastically reduces the time to update the tests after refactors, +//! - encourages creation of reusable visualizations for data structures. +//! +//! Implementation-wise, `snaptest` provides a `Snap` type, which can be thought of as a Zig string +//! literal which also remembers its location in the source file, can be diffed with other strings, +//! and, crucially, can _update its own source code_ to match the expected value. +//! +//! Example usage: +//! +//! ``` +//! const Snap = @import("snaptest.zig").Snap; +//! const snap = Snap.snap; +//! +//! fn check_addition(x: u32, y: u32, want: Snap) !void { +//! const got = x + y; +//! try want.diff_fmt("{}", .{got}); +//! } +//! +//! test "addition" { +//! try check_addition(2, 2, snap(@src(), +//! \\8 +//! )); +//! } +//! ``` +//! +//! Running this test fails, printing the diff between actual result (`4`) and what's specified in +//! the source code. +//! +//! Re-running the test with `SNAP_UPDATE=1` environmental variable auto-magically updates the +//! source code to say `\\4`. Alternatively, you can use `snap(...).update()` to auto-update just a +//! single test. +//! +//! Note the `@src()` argument passed to the `snap(...)` invocation --- that's how it knows which +//! lines to update. +//! +//! Snapshots can use `` marker to ignore part of input: +//! +//! ``` +//! test "time" { +//! var buf: [32]u8 = undefined; +//! const time = try std.fmt.bufPrint(&buf, "it's {}ms", .{ +//! std.time.milliTimestamp(), +//! }); +//! try Snap.snap(@src(), +//! \\it's ms +//! ).diff(time); +//! } +//! ``` +//! +//! TODO: +//! - This doesn't actually `diff` things yet :o) But running with `SNAP_UPDATE=1` and then using +//! `git diff` is a workable substitute. +//! - Only one test can be updated at a time. To update several, we need to return +//! `error.SkipZigTest` on mismatch and adjust offsets appropriately. +//! +//! [1]: https://blog.janestreet.com/using-ascii-waveforms-to-test-hardware-designs/ +//! [2]: https://ianthehenry.com/posts/my-kind-of-repl/ +const std = @import("std"); +const assert = std.debug.assert; +const builtin = @import("builtin"); +const SourceLocation = std.builtin.SourceLocation; + +const stdx = @import("../stdx.zig"); + +const MiB = stdx.MiB; + +pub const Snap = struct { + comptime { + assert(builtin.is_test); + } + + // Set to `true` to update all snapshots. + pub var update_all: bool = false; + + module_path: []const u8, + location: SourceLocation, + text: []const u8, + update_this: bool = false, + + const SnapFn = fn (location: SourceLocation, text: []const u8) Snap; + + /// Takes the path to the root source file of the current module and creates a snap function. + /// + /// ``` + /// const snap = Snap.snap_fn("src"); + /// const snap = Snap.snap_fn("src/stdx"); + /// ``` + /// + /// For the update logic to work, usage *must* be formatted as: + /// + /// ``` + /// snap(@src(), + /// \\Text of the snapshot. + /// ) + /// ``` + pub fn snap_fn(comptime module_path: []const u8) SnapFn { + return struct { + fn snap(location: SourceLocation, text: []const u8) Snap { + return init(module_path, location, text); + } + }.snap; + } + + /// Creates a new Snap. + fn init(module_path: []const u8, location: SourceLocation, text: []const u8) Snap { + return Snap{ .module_path = module_path, .location = location, .text = text }; + } + + /// Builder-lite method to update just this particular snapshot. + pub fn update(snapshot: *const Snap) Snap { + return Snap{ + .module_path = snapshot.module_path, + .location = snapshot.location, + .text = snapshot.text, + .update_this = true, + }; + } + + /// To update a snapshot, use whichever you prefer: + /// - `.update()` method on a particular snap, + /// - `update_all` const in this file, + /// - `SNAP_UPDATE` env var. + fn should_update(snapshot: *const Snap) bool { + return snapshot.update_this or update_all or + std.process.hasEnvVarConstant("SNAP_UPDATE"); + } + + // Compare the snapshot with a formatted string. + pub fn diff_fmt(snapshot: *const Snap, comptime fmt: []const u8, fmt_args: anytype) !void { + const got = try std.fmt.allocPrint(std.testing.allocator, fmt, fmt_args); + defer std.testing.allocator.free(got); + + try snapshot.diff(got); + } + + // Compare the snapshot with the zon json serialization of a `value`. + pub fn diff_zon( + snapshot: *const Snap, + value: anytype, + ) !void { + var got: std.ArrayListUnmanaged(u8) = .empty; + defer got.deinit(std.testing.allocator); + + try std.zon.stringify.serialize(value, .{}, got.writer(std.testing.allocator)); + try snapshot.diff(got.items); + } + + pub fn diff_hex(snapshot: *const Snap, value: []const u8) !void { + var buffer: std.ArrayListUnmanaged(u8) = .empty; + defer buffer.deinit(std.testing.allocator); + + try hexdump(value, buffer.writer(std.testing.allocator).any()); + try snapshot.diff(buffer.items); + } + + // Compare the snapshot with a given string. + pub fn diff(snapshot: *const Snap, got: []const u8) !void { + if (equal_excluding_ignored(got, snapshot.text)) return; + + std.debug.print( + \\Snapshot differs. + \\Want: + \\---- + \\{s} + \\---- + \\Got: + \\---- + \\{s} + \\---- + \\ + , + .{ + snapshot.text, + got, + }, + ); + + if (!snapshot.should_update()) { + std.debug.print( + "Rerun with SNAP_UPDATE=1 environmental variable to update the snapshot.\n", + .{}, + ); + return error.SnapDiff; + } + + var arena_instance = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_instance.deinit(); + + const arena = arena_instance.allocator(); + const file_path_relative = try std.fs.path.join( + arena, + // The file location is relative to the module root path. + &.{ snapshot.module_path, snapshot.location.file }, + ); + + const file_text = try std.fs.cwd().readFileAlloc(arena, file_path_relative, 1 * MiB); + var file_text_updated = try std.ArrayList(u8).initCapacity(arena, file_text.len); + + const line_zero_based = snapshot.location.line - 1; + const range = snap_range(file_text, line_zero_based); + + const snapshot_prefix = file_text[0..range.start]; + const snapshot_text = file_text[range.start..range.end]; + const snapshot_suffix = file_text[range.end..]; + + const indent = get_indent(snapshot_text); + + try file_text_updated.appendSlice(snapshot_prefix); + { + var lines = std.mem.splitScalar(u8, got, '\n'); + while (lines.next()) |line| { + try file_text_updated.writer().print("{s}\\\\{s}\n", .{ indent, line }); + } + } + try file_text_updated.appendSlice(snapshot_suffix); + + try std.fs.cwd().writeFile(.{ + .sub_path = file_path_relative, + .data = file_text_updated.items, + }); + + std.debug.print("Updated {s}\n", .{file_path_relative}); + return error.SnapUpdated; + } +}; + +fn equal_excluding_ignored(got: []const u8, snapshot: []const u8) bool { + // Don't allow ignoring suffixes and prefixes, as that makes it easy to miss trailing or leading + // data. + assert(!std.mem.startsWith(u8, snapshot, "")); + assert(!std.mem.endsWith(u8, snapshot, "")); + + var got_rest = got; + var snapshot_rest = snapshot; + for (0..10) |_| { + // Cut the part before the first ignore, it should be equal between two strings... + const common_prefix, snapshot_rest = stdx.cut(snapshot_rest, "") orelse break; + got_rest = stdx.cut_prefix(got_rest, common_prefix) orelse return false; + + // ...then find the next part that should match, and cut up to that. + const common_middle, _ = + stdx.cut(snapshot_rest, "") orelse .{ snapshot_rest, "" }; + assert(common_middle.len > 0); + snapshot_rest = stdx.cut_prefix(snapshot_rest, common_middle).?; + + const ignored, got_rest = stdx.cut(got_rest, common_middle) orelse return false; + // If matched an empty string, or several lines, report it as an error. + if (ignored.len == 0) return false; + if (std.mem.indexOfScalar(u8, ignored, '\n') != null) return false; + } else @panic("more than 10 ignores"); + + return std.mem.eql(u8, got_rest, snapshot_rest); +} + +test equal_excluding_ignored { + try equal_excluding_ignored_case("ABA", "ABA", true); + try equal_excluding_ignored_case("ABBA", "AA", true); + try equal_excluding_ignored_case("ABBACABA", "ABCAA", true); + + try equal_excluding_ignored_case("ABA", "ACA", false); + try equal_excluding_ignored_case("ABBA", "AC", false); + try equal_excluding_ignored_case("ABBACABA", "ABDABA", false); + try equal_excluding_ignored_case("ABBACABA", "ABBADA", false); + try equal_excluding_ignored_case("ABA", "ABA", false); + try equal_excluding_ignored_case("A\nB\nA", "AA", false); +} + +fn equal_excluding_ignored_case(got: []const u8, snapshot: []const u8, ok: bool) !void { + try std.testing.expectEqual(equal_excluding_ignored(got, snapshot), ok); +} + +const Range = struct { start: usize, end: usize }; + +/// Extracts the range of the snapshot. Assumes that the snapshot is formatted as +/// +/// ``` +/// snap(@src(), +/// \\first line +/// \\second line +/// ) +/// ``` +/// +/// We could make this more robust by using `std.zig.Ast`, but sticking to manual string processing +/// is simpler, and enforced consistent style of snapshots is a good thing. +/// +/// While we expect to find a snapshot after a given line, this is not guaranteed (the file could +/// have been modified between compilation and running the test), but should be rare enough to +/// just fail with an assertion. +fn snap_range(text: []const u8, src_line: u32) Range { + var offset: usize = 0; + var line_number: u32 = 0; + + var lines = std.mem.splitScalar(u8, text, '\n'); + const snap_start = while (lines.next()) |line| : (line_number += 1) { + if (line_number == src_line) { + assert(std.mem.indexOf(u8, line, "@src()") != null); + } + if (line_number == src_line + 1) { + assert(is_multiline_string(line)); + break offset; + } + offset += line.len + 1; // 1 for \n + } else unreachable; + + lines = std.mem.splitScalar(u8, text[snap_start..], '\n'); + const snap_end = while (lines.next()) |line| { + if (!is_multiline_string(line)) { + break offset; + } + offset += line.len + 1; // 1 for \n + } else unreachable; + + return Range{ .start = snap_start, .end = snap_end }; +} + +fn is_multiline_string(line: []const u8) bool { + for (line, 0..) |c, i| { + switch (c) { + ' ' => {}, + '\\' => return (i + 1 < line.len and line[i + 1] == '\\'), + else => return false, + } + } + return false; +} + +fn get_indent(line: []const u8) []const u8 { + for (line, 0..) |c, i| { + if (c != ' ') return line[0..i]; + } + return line; +} + +fn hexdump(bytes: []const u8, writer: std.io.AnyWriter) !void { + for (bytes, 0..) |byte, index| { + if (index > 0) { + const space = if (index % 16 == 0) "\n" else if (index % 8 == 0) " " else " "; + try writer.writeAll(space); + } + try writer.print("{x:02}", .{byte}); + } +} + +test hexdump { + const snap = Snap.snap_fn("./src/stdx"); + + try snap(@src(), + \\68 65 6c 6c 6f 2c 20 77 6f 72 6c 64 0a 00 01 02 + \\03 fd fe ff + ).diff_hex("hello, world\n" ++ .{ 0, 1, 2, 3, 253, 254, 255 }); +} + +test "Snap update disabled" { + assert(!Snap.update_all); // Forgot to flip this back to false after updating snapshots? +} diff --git a/ocam/src/stdx/testing/time.zig b/ocam/src/stdx/testing/time.zig new file mode 100644 index 00000000..1f2b003d --- /dev/null +++ b/ocam/src/stdx/testing/time.zig @@ -0,0 +1,106 @@ +/// In microbenchmarks, we often measure both time and other performance counters +/// such as how many cycles were used, how many branch misses were incurred, and more. +/// These only count cycles in the current process, and not, for exampole, sleep time. +/// The closest matching clock implementation semantics are provided by CLOCK_MONOTONIC, +/// which is what we use here. To distinguish towards vsr.time.Time.monotonic(), +/// we call this `benchmark_monotonic()` +/// https://github.com/ziglang/zig/pull/933#discussion_r656021295. +const std = @import("std"); +const builtin = @import("builtin"); + +const stdx = @import("../stdx.zig"); + +const os = std.os; +const posix = std.posix; +const system = posix.system; +const assert = std.debug.assert; +const is_darwin = builtin.target.os.tag.isDarwin(); +const is_windows = builtin.target.os.tag == .windows; +const is_linux = builtin.target.os.tag == .linux; +const Instant = stdx.Instant; + +const BenchmarkTime = @This(); + +// BenchmarkTime is used to test algorithm runtime and is not critical for safety. +// We still guard against non-monotonicity bugs in OS time sources +// to fail fast and keep our sanity when debugging test outputs. +monotonic_guard: u64 = 0, + +// TODO use different name to differentiate to vsr time +pub fn benchmark_monotonic(self: *BenchmarkTime) Instant { + // Since we do not currently optimize for macOS and windows, + // (especially the I/O interface), one could also argue for + // failing early here and only allowing measurements on linux. + const monotonic_timestamp = blk: { + if (is_windows) break :blk benchmark_monotonic_windows(); + if (is_darwin) break :blk benchmark_monotonic_darwin(); + if (is_linux) break :blk benchmark_monotonic_linux(); + @compileError("unsupported OS"); + }; + + // "Oops!...I Did It Again" + if (monotonic_timestamp < self.monotonic_guard) { + @panic("a hardware/kernel bug regressed the monotonic clock"); + } + self.monotonic_guard = monotonic_timestamp; + return .{ .ns = monotonic_timestamp }; +} + +fn benchmark_monotonic_windows() u64 { + assert(is_windows); + // Uses QueryPerformanceCounter() on windows due to it being the highest precision timer + // available while also accounting for time spent suspended by default: + // + // https://docs.microsoft.com/en-us/windows/win32/api/realtimeapiset/nf-realtimeapiset-queryunbiasedinterrupttime#remarks + + // QPF need not be globally cached either as it ends up being a load from read-only memory + // mapped to all processed by the kernel called KUSER_SHARED_DATA (See "QpcFrequency") + // + // https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-kuser_shared_data + // https://www.geoffchappell.com/studies/windows/km/ntoskrnl/inc/api/ntexapi_x/kuser_shared_data/index.htm + const counter = os.windows.QueryPerformanceCounter(); + const frequency = os.windows.QueryPerformanceFrequency(); + + // 10Mhz (1 qpc tick every 100ns) is a common QPF on modern systems. + // We can optimize towards this by converting to ns via a single multiply. + // + // https://github.com/microsoft/STL/blob/785143a0c73f030238ef618890fd4d6ae2b3a3a0/stl/inc/chrono#L694-L701 + const common_frequency = 10_000_000; + if (frequency == common_frequency) return counter * (std.time.ns_per_s / common_frequency); + + // Convert qpc to nanos using fixed point to avoid expensive extra divs and + // overflow. + const scale = (std.time.ns_per_s << 32) / frequency; + return @as(u64, @truncate((@as(u96, counter) * scale) >> 32)); +} + +fn benchmark_monotonic_darwin() u64 { + assert(is_darwin); + // Uses mach_absolute_time() instead of mach_continuous_time() because + // we do *NOT* want to count while suspended here. + // + // https://developer.apple.com/documentation/kernel/1646199-mach_continuous_time + // https://opensource.apple.com/source/Libc/Libc-1158.1.2/gen/clock_gettime.c.auto.html + const darwin = struct { + const mach_timebase_info_t = system.mach_timebase_info_data; + extern "c" fn mach_timebase_info(info: *mach_timebase_info_t) system.kern_return_t; + extern "c" fn mach_absolute_time() u64; + }; + + // mach_timebase_info() called through libc already does global caching for us + // + // https://opensource.apple.com/source/xnu/xnu-7195.81.3/libsyscall/wrappers/mach_timebase_info.c.auto.html + var info: darwin.mach_timebase_info_t = undefined; + if (darwin.mach_timebase_info(&info) != 0) @panic("mach_timebase_info() failed"); + + const now = darwin.mach_absolute_time(); + return (now * info.numer) / info.denom; +} + +fn benchmark_monotonic_linux() u64 { + assert(is_linux); + const ts: posix.timespec = posix.clock_gettime(posix.CLOCK.MONOTONIC) catch { + @panic("CLOCK_BOOTTIME required"); + }; + return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec)); +} diff --git a/ocam/src/stdx/time_units.zig b/ocam/src/stdx/time_units.zig new file mode 100644 index 00000000..20119fa4 --- /dev/null +++ b/ocam/src/stdx/time_units.zig @@ -0,0 +1,291 @@ +const std = @import("std"); +const assert = std.debug.assert; +const stdx = @import("stdx.zig"); + +/// A moment in monotonic time not anchored to any particular epoch. +/// +/// The absolute value of `ns` is meaningless, but it is possible to compute `Duration` between +/// two `Instant`s sourced from the same clock. +/// +/// See also `InstantUnix`. +pub const Instant = struct { + ns: u64, + + pub fn add(now: Instant, duration: Duration) Instant { + return .{ .ns = now.ns + duration.ns }; + } + + pub fn elapsed(earlier: Instant, now: Instant) Duration { + assert(now.ns >= earlier.ns); + const elapsed_ns = now.ns - earlier.ns; + return .{ .ns = elapsed_ns }; + } +}; + +/// Non-negative time difference between two `Instant`s. +pub const Duration = struct { + ns: u64, + + pub fn us(amount_us: u64) Duration { + return .{ .ns = amount_us * std.time.ns_per_us }; + } + + pub fn ms(amount_ms: u64) Duration { + return .{ .ns = amount_ms * std.time.ns_per_ms }; + } + + pub fn seconds(amount_seconds: u64) Duration { + return .{ .ns = amount_seconds * std.time.ns_per_s }; + } + + pub fn minutes(amount_minutes: u64) Duration { + return .{ .ns = amount_minutes * std.time.ns_per_min }; + } + + // Duration in microseconds, μs, 1/1_000_000 of a second. + pub fn to_us(duration: Duration) u64 { + return @divFloor(duration.ns, std.time.ns_per_us); + } + + // Duration in milliseconds, ms, 1/1_000 of a second. + pub fn to_ms(duration: Duration) u64 { + return @divFloor(duration.ns, std.time.ns_per_ms); + } + + pub fn min(lhs: Duration, rhs: Duration) Duration { + return .{ .ns = @min(lhs.ns, rhs.ns) }; + } + + pub fn max(lhs: Duration, rhs: Duration) Duration { + return .{ .ns = @max(lhs.ns, rhs.ns) }; + } + + pub fn clamp(duration: Duration, clamp_min: Duration, clamp_max: Duration) Duration { + assert(clamp_min.ns <= clamp_max.ns); + if (duration.ns < clamp_min.ns) return clamp_min; + if (duration.ns > clamp_max.ns) return clamp_max; + return duration; + } + + pub const sort = struct { + pub fn asc(ctx: void, lhs: Duration, rhs: Duration) bool { + return std.sort.asc(u64)(ctx, lhs.ns, rhs.ns); + } + }; + + // Human readable format like `1.123s`. + // NB: this is a lossy operation, durations are rounded to look nice. + pub fn format( + duration: Duration, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + try std.fmt.fmtDuration(duration.ns).format(fmt, options, writer); + } + + pub fn parse_flag_value( + string: []const u8, + static_diagnostic: *?[]const u8, + ) error{InvalidFlagValue}!Duration { + assert(string.len > 0); + var string_remaining = string; + + var result: Duration = .{ .ns = 0 }; + while (string_remaining.len > 0) { + string_remaining, const component = + try parse_flag_value_component(string_remaining, static_diagnostic); + result.ns +|= component.ns; + } + + if (result.ns >= 1_000 * std.time.ns_per_day) { + static_diagnostic.* = "duration too large:"; + return error.InvalidFlagValue; + } + return result; + } + + fn parse_flag_value_component( + string: []const u8, + static_diagnostic: *?[]const u8, + ) error{InvalidFlagValue}!struct { []const u8, Duration } { + const split_index = for (string, 0..) |c, index| { + if (std.ascii.isDigit(c)) { + // Numeric part continues. + } else break index; + } else { + static_diagnostic.* = "missing unit; must be one of: d/h/m/s/ms/us/ns:"; + return error.InvalidFlagValue; + }; + + if (split_index == 0) { + static_diagnostic.* = "missing value:"; + return error.InvalidFlagValue; + } + + const string_amount = string[0..split_index]; + const string_remaining = string[split_index..]; + assert(string_amount.len > 0); + assert(string_remaining.len > 0); + + const amount = stdx.parse_int(u64, string_amount, .{ + .base = 10, + .allow_separators = true, + }) catch |err| switch (err) { + error.Overflow => { + static_diagnostic.* = "integer overflow:"; + return error.InvalidFlagValue; + }, + error.LeadingZero => { + static_diagnostic.* = "leading zero disallowed:"; + return error.InvalidFlagValue; + }, + error.InvalidCharacter => unreachable, + }; + + const Unit = enum(u64) { + ns = 1, + us = std.time.ns_per_us, + ms = std.time.ns_per_ms, + s = std.time.ns_per_s, + m = std.time.ns_per_min, + h = std.time.ns_per_hour, + d = std.time.ns_per_day, + }; + + inline for (comptime std.enums.values(Unit)) |unit| { + if (stdx.cut_prefix(string_remaining, @tagName(unit))) |suffix| { + return .{ suffix, .{ .ns = amount *| @intFromEnum(unit) } }; + } + } else { + static_diagnostic.* = "unknown unit; must be one of: d/h/m/s/ms/us/ns:"; + return error.InvalidFlagValue; + } + } +}; + +test "Instant/Duration" { + const instant_1: Instant = .{ .ns = 100 * std.time.ns_per_day }; + const instant_2: Instant = .{ .ns = 100 * std.time.ns_per_day + std.time.ns_per_s }; + assert(instant_1.elapsed(instant_1).ns == 0); + assert(instant_1.elapsed(instant_2).ns == std.time.ns_per_s); + + const duration = instant_1.elapsed(instant_2); + assert(duration.ns == 1_000_000_000); + assert(duration.to_us() == 1_000_000); + assert(duration.to_ms() == 1_000); + + assert(Duration.ms(1).ns == std.time.ns_per_ms); + assert(Duration.seconds(1).ns == std.time.ns_per_s); + assert(Duration.minutes(1).ns == std.time.ns_per_min); +} + +test "Duration.parse_flag_value" { + try stdx.Flags.parse_flag_value_fuzz(Duration, Duration.parse_flag_value, .{ + .ok = &.{ + .{ "1h", .{ .ns = std.time.ns_per_hour } }, + .{ "1m", .{ .ns = std.time.ns_per_min } }, + .{ "1h2m", .{ .ns = std.time.ns_per_hour + 2 * std.time.ns_per_min } }, + .{ "1ms2us3ns", .{ .ns = std.time.ns_per_ms + 2 * std.time.ns_per_us + 3 } }, + }, + .err = &.{ + .{ "h", "missing value" }, + .{ "1", "missing unit" }, + .{ "h1", "missing value" }, + .{ "1H", "unknown unit; must be one of: d/h/m/s/ms/us/ns" }, + .{ "1h2x", "unknown unit" }, + .{ "1_0h", "unknown unit" }, + .{ "1h 2m", "missing value" }, + .{ "18446744073709551616ns", "integer overflow" }, + .{ "1844674407370955161s", "duration too large" }, + .{ "0024h", "leading zero disallowed" }, + }, + }); +} + +/// A moment in non-monotonic Unix time. +/// Timestamp is relative to epoch 1970-01-1. +/// +/// See also `Instant`. +pub const InstantUnix = struct { + ns: u64, + + pub fn add(instant: InstantUnix, duration: Duration) InstantUnix { + return .{ .ns = instant.ns + duration.ns }; + } + + pub fn now() InstantUnix { + const timestamp_ns = std.time.nanoTimestamp(); + assert(timestamp_ns > 0); + assert(timestamp_ns <= std.math.maxInt(u64)); + return .{ .ns = @intCast(timestamp_ns) }; + } + + pub fn from_timestamp_s(timestamp_s: u64) InstantUnix { + return InstantUnix{ .ns = timestamp_s * std.time.ms_per_s * std.time.ns_per_ms }; + } + + pub fn date_time(instant: InstantUnix) struct { + year: u16, + month: u8, + day: u8, + hour: u8, + minute: u8, + second: u8, + millisecond: u16, + } { + const timestamp_ms = @divTrunc(instant.ns, std.time.ns_per_ms); + const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @divTrunc(timestamp_ms, 1000) }; + const year_day = epoch_seconds.getEpochDay().calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + const time = epoch_seconds.getDaySeconds(); + + return .{ + .year = year_day.year, + .month = month_day.month.numeric(), + .day = month_day.day_index + 1, + .hour = time.getHoursIntoDay(), + .minute = time.getMinutesIntoHour(), + .second = time.getSecondsIntoMinute(), + .millisecond = @intCast(@mod(timestamp_ms, 1000)), + }; + } + + pub fn format( + instant: InstantUnix, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + const datetime = instant.date_time(); + try writer.print("{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}.{d:0>3}Z", .{ + datetime.year, + datetime.month, + datetime.day, + datetime.hour, + datetime.minute, + datetime.second, + datetime.millisecond, + }); + } + + pub fn to_seconds(instant: InstantUnix) u64 { + return @divFloor(instant.ns, std.time.ns_per_s); + } +}; + +test "InstantUnix format" { + const instant_min = InstantUnix{ .ns = 0 }; + var buffer: [24]u8 = undefined; + try std.testing.expectEqualStrings( + "1970-01-01 00:00:00.000Z", + try std.fmt.bufPrint(&buffer, "{}", .{instant_min}), + ); + const instant_max = InstantUnix{ .ns = std.math.maxInt(u64) }; + try std.testing.expectEqualStrings( + "2554-07-21 23:34:33.709Z", + try std.fmt.bufPrint(&buffer, "{}", .{instant_max}), + ); +} diff --git a/ocam/src/stdx/unshare.zig b/ocam/src/stdx/unshare.zig new file mode 100644 index 00000000..2d0e9420 --- /dev/null +++ b/ocam/src/stdx/unshare.zig @@ -0,0 +1,295 @@ +//! Some tools for working with Linux `unshare` and namespaces. +//! +//! We use user, pid, and network namespaces for two purposes: +//! +//! - Processes namespaces enable all processes in the namespace +//! to be killed when the namespace's init process is. +//! - Network namespaces allow us to create an isolated loopback network. +//! +//! This code uses the Linux `unshare` syscall to create new +//! namespaces. +//! +//! The main tool here is `maybe_unshare_and_relaunch`, which provides +//! a pattern for forking a new process that is an init process in +//! its own process namespace. + +const std = @import("std"); +const builtin = @import("builtin"); +const linux = std.os.linux; +const log = std.log.scoped(.unshare); +const assert = std.debug.assert; + +// The external pid of the init process of the unshare pid namespace. +// (Its pid within the namespace is always 1.) +var child_pid: ?std.process.Child.Id = null; + +// On receiving SIGTERM, the parent must explicitly kill the pid namespace child process, since +// it would otherwise keep running. Since the child is the init process, that automatically kills +// all of its descendants too. +const trap_action = std.posix.Sigaction{ + .handler = .{ .handler = trap_handler }, + .mask = std.posix.empty_sigset, + .flags = 0, +}; + +fn trap_handler(signal: i32) callconv(.c) void { + if (child_pid) |child| { + std.posix.kill(child, std.posix.SIG.KILL) catch |err| { + log.err("error killing sandboxed process: {}", .{err}); + }; + } + std.posix.exit(@intCast(@as(i32, 128) + signal)); +} + +/// Relaunch this process with new namespaces. +/// +/// If the current process is already running with the namespaces configured as +/// requested then this function does nothing. Otherwise it configures the +/// namespaces and with them spawns a new process with the same arguments as +/// the current process, waits for it, then exits the process directly (not +/// returning from this function). +/// +/// This should generally be called immediately from `main`. +/// +/// If the `pid` option is provided then the spawned process will be the init +/// process in a new pid namespace. When it is terminated all subprocesses +/// transitively will also be terminated. +/// +/// If the `network` option is provided then the spawned process and its +/// subprocesses will have loopback network access only. +pub fn maybe_unshare_and_relaunch( + gpa: std.mem.Allocator, + options: struct { + pid: bool, + network: bool, + }, +) !void { + comptime assert(builtin.os.tag == .linux); + + if (std.os.linux.getpid() != 1) { + try linux_unshare(.{ + .pid = options.pid, + .network = options.network, + }); + if (options.network) { + try linux_ip_link_loopback(); + } + if (options.pid) { + std.posix.sigaction(std.posix.SIG.TERM, &trap_action, null); + try fork_and_exit(gpa); + } + } else { + // We are within the pid namespace. + assert(options.pid); + assert(std.os.linux.getpid() == 1); + assert(child_pid == null); + } +} + +/// Implementation of `unshare` somewhat like +/// +/// ``` +/// unshare --user --net --pid +/// ``` +/// +/// We're trying to accomplish two main things: +/// +/// - creating a new pid namespace so that all subprocesses +/// are automatically terminated when pid 1 (the forked +/// vortex supervisor) is terminated. +/// - creating a network sandbox +/// +/// Note that on recent Ubuntu's this only works if AppArmour +/// rules have been relaxed: +/// +/// ``` +/// sudo sysctl -w kernel.apparmor_restrict_unprivileged_unconfined=0 +/// sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 +/// ``` +pub fn linux_unshare(options: struct { + pid: bool, + network: bool, +}) !void { + comptime assert(builtin.os.tag == .linux); + + // Create user namespace first. + const unshare_user_result = std.os.linux.unshare(linux.CLONE.NEWUSER); + const unshare_user_errno = std.os.linux.E.init(unshare_user_result); + if (unshare_user_errno != .SUCCESS) { + log.err("Failed to create user namespace: {}", .{unshare_user_errno}); + return error.UnshareFailure; + } + + // Create PID namespace. + if (options.pid) { + const unshare_pid_result = std.os.linux.unshare(linux.CLONE.NEWPID); + const unshare_pid_errno = std.os.linux.E.init(unshare_pid_result); + if (unshare_pid_errno != .SUCCESS) { + log.err("Failed to create pid namespace: {}", .{unshare_pid_errno}); + return error.UnshareFailure; + } + } + + // Create network namespace. + if (options.network) { + const unshare_net_result = std.os.linux.unshare(linux.CLONE.NEWNET); + const unshare_net_errno = std.os.linux.E.init(unshare_net_result); + if (unshare_net_errno != .SUCCESS) { + log.err("Failed to create net namespace: {}", .{unshare_net_errno}); + return error.UnshareFailure; + } + } +} + +/// Implementation of `ip link` equivalent to +/// +/// ``` +/// ip link set up dev lo +/// ``` +/// +/// This brings up the loopback device so that networking +/// over 127.0.0.1 works. +pub fn linux_ip_link_loopback() !void { + comptime assert(builtin.os.tag == .linux); + + // Open a netlink socket with the NETLINK.ROUTE protocol. + const sock = std.posix.socket( + linux.AF.NETLINK, + std.posix.SOCK.RAW, + linux.NETLINK.ROUTE, + ) catch |err| { + log.err("failed to create netlink socket: {}", .{err}); + return error.IpLink; + }; + defer std.posix.close(sock); + + const addr = linux.sockaddr.nl{ + .family = linux.AF.NETLINK, + .pid = 0, + .groups = 0, + }; + std.posix.bind(sock, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) catch |err| { + log.err("failed to bind netlink socket: {}", .{err}); + return error.IpLink; + }; + + // Netlink definitions. + const nlmsghdr = linux.nlmsghdr; + const ifinfomsg = linux.ifinfomsg; + + const nlmsgerr = extern struct { + @"error": c_int, + msg: nlmsghdr, + }; + + const IFF_UP = 0x1; + + // Our message to the kernel - header plus interface info. + const Message = extern struct { + hdr: nlmsghdr, + ifi: ifinfomsg, + + comptime { + assert(@sizeOf(@This()) == @sizeOf(nlmsghdr) + @sizeOf(ifinfomsg)); + } + }; + + // Kernel's message to us - header plus error info. + const Response = extern struct { + hdr: nlmsghdr, + err: nlmsgerr, + + comptime { + assert(@sizeOf(@This()) == @sizeOf(nlmsghdr) + @sizeOf(nlmsgerr)); + } + }; + + var msg: Message = .{ + .hdr = .{ + .len = @sizeOf(nlmsghdr) + @sizeOf(ifinfomsg), + .type = .RTM_NEWLINK, + // ACK says to always send a response, even on success. + .flags = linux.NLM_F_REQUEST | linux.NLM_F_ACK, + .seq = 0, + .pid = 0, + }, + .ifi = .{ + .family = linux.AF.UNSPEC, + .type = 0, + // Seems to be the loopback device, not sure how + // to find this value the correct way. + .index = 1, + .flags = IFF_UP, + // man pages say use this value. + .change = 0xFFFFFFFF, + }, + }; + + const msg_buf = std.mem.asBytes(&msg); + const sent_len = std.posix.sendto(sock, msg_buf, 0, null, 0) catch |err| { + log.err("failed to send netlink message: {}", .{err}); + return error.IpLink; + }; + assert(sent_len == msg.hdr.len); + + var ack: Response = undefined; + const ack_buf = std.mem.asBytes(&ack); + const ack_len = std.posix.recv(sock, ack_buf, 0) catch |err| { + log.err("failed to receive netlink ack: {}", .{err}); + return error.IpLink; + }; + + assert(ack_len == @sizeOf(Response)); + assert(ack.hdr.type == .ERROR); + assert(ack.err.msg.pid == msg.hdr.pid); + + if (ack.err.@"error" != 0) { + log.err("netlink operation failed with errno: {}", .{-ack.err.@"error"}); + return error.IpLink; + } +} + +fn fork_and_exit(gpa: std.mem.Allocator) !void { + const args_ours = std.os.argv; + + // We get a fresh path to the exe instead of using the original + // first argument so that the exe path will be correct even if + // this process's cwd has changed relative to the original exe. + var exe_path_buffer: [std.fs.max_path_bytes]u8 = undefined; + const exe_path = try std.fs.selfExePath(&exe_path_buffer); + + const args_new = try gpa.alloc([]const u8, args_ours.len); + defer gpa.free(args_new); + + args_new[0] = exe_path; + + for (1..args_ours.len) |arg_index| { + args_new[arg_index] = std.mem.span(args_ours[arg_index]); + } + + var child = std.process.Child.init(args_new, gpa); + child.stdin_behavior = .Inherit; + child.stdout_behavior = .Inherit; + child.stderr_behavior = .Inherit; + + try child.spawn(); + + // Set the global pid so that we can kill it if we receive a SIGTERM. + assert(child_pid == null); + child_pid = child.id; + + const result = try child.wait(); + switch (result) { + .Exited => |code| { + std.process.exit(code); + }, + .Signal => |signal| { + log.info("sandboxed subprocesses exited with signal {}", .{signal}); + std.process.exit(1); + }, + else => { + log.err("sandboxed subprocesses exited abnormally", .{}); + std.process.exit(2); + }, + } +} diff --git a/ocam/src/stdx/vendored/aegis.zig b/ocam/src/stdx/vendored/aegis.zig new file mode 100644 index 00000000..934235b6 --- /dev/null +++ b/ocam/src/stdx/vendored/aegis.zig @@ -0,0 +1,436 @@ +//! Vendored from Zig's 0.13.0 standard library to maintain hash stability. +//! Source: https://github.com/ziglang/zig/blob/0.13.0/lib/std/crypto/aegis.zig + +const std = @import("std"); +const crypto = std.crypto; +const mem = std.mem; +const assert = std.debug.assert; +const AesBlock = crypto.core.aes.Block; +const AuthenticationError = crypto.errors.AuthenticationError; + +/// AEGIS-128L with a 128-bit authentication tag. +const Aegis128L = Aegis128LGenericType(128); + +/// AEGIS-128L with a 256-bit authentication tag. +const Aegis128L_256 = Aegis128LGenericType(256); + +const State128L = struct { + blocks: [8]AesBlock, + + fn init(key: [16]u8, nonce: [16]u8) State128L { + const c1 = AesBlock.fromBytes(&[16]u8{ + 0xdb, 0x3d, 0x18, 0x55, 0x6d, 0xc2, 0x2f, 0xf1, + 0x20, 0x11, 0x31, 0x42, 0x73, 0xb5, 0x28, 0xdd, + }); + const c2 = AesBlock.fromBytes(&[16]u8{ + 0x0, 0x1, 0x01, 0x02, 0x03, 0x05, 0x08, 0x0d, + 0x15, 0x22, 0x37, 0x59, 0x90, 0xe9, 0x79, 0x62, + }); + const key_block = AesBlock.fromBytes(&key); + const nonce_block = AesBlock.fromBytes(&nonce); + const blocks = [8]AesBlock{ + key_block.xorBlocks(nonce_block), + c1, + c2, + c1, + key_block.xorBlocks(nonce_block), + key_block.xorBlocks(c2), + key_block.xorBlocks(c1), + key_block.xorBlocks(c2), + }; + var state = State128L{ .blocks = blocks }; + var i: usize = 0; + while (i < 10) : (i += 1) { + state.update(nonce_block, key_block); + } + return state; + } + + inline fn update(state: *State128L, d1: AesBlock, d2: AesBlock) void { + comptime assert(state.blocks.len == 8); + + // Hoist lanes; this keeps the blocks in registers (see #3201). + var blocks: [8]AesBlock = state.blocks; + const tmp = blocks[7]; + + inline for ([_]usize{ 7, 6, 5, 4, 3, 2, 1 }) |i| { + blocks[i] = blocks[i - 1].encrypt(blocks[i]); + } + + blocks[0] = tmp.encrypt(blocks[0]); + blocks[0] = blocks[0].xorBlocks(d1); + blocks[4] = blocks[4].xorBlocks(d2); + + // Single spill at the end. + state.blocks = blocks; + } + + fn absorb(state: *State128L, src: *const [32]u8) void { + const msg0 = AesBlock.fromBytes(src[0..16]); + const msg1 = AesBlock.fromBytes(src[16..32]); + state.update(msg0, msg1); + } + + fn enc(state: *State128L, dst: *[32]u8, src: *const [32]u8) void { + const blocks = &state.blocks; + const msg0 = AesBlock.fromBytes(src[0..16]); + const msg1 = AesBlock.fromBytes(src[16..32]); + var tmp0 = msg0.xorBlocks(blocks[6]).xorBlocks(blocks[1]); + var tmp1 = msg1.xorBlocks(blocks[2]).xorBlocks(blocks[5]); + tmp0 = tmp0.xorBlocks(blocks[2].andBlocks(blocks[3])); + tmp1 = tmp1.xorBlocks(blocks[6].andBlocks(blocks[7])); + dst[0..16].* = tmp0.toBytes(); + dst[16..32].* = tmp1.toBytes(); + state.update(msg0, msg1); + } + + fn dec(state: *State128L, dst: *[32]u8, src: *const [32]u8) void { + const blocks = &state.blocks; + var msg0 = AesBlock.fromBytes(src[0..16]).xorBlocks(blocks[6]).xorBlocks(blocks[1]); + var msg1 = AesBlock.fromBytes(src[16..32]).xorBlocks(blocks[2]).xorBlocks(blocks[5]); + msg0 = msg0.xorBlocks(blocks[2].andBlocks(blocks[3])); + msg1 = msg1.xorBlocks(blocks[6].andBlocks(blocks[7])); + dst[0..16].* = msg0.toBytes(); + dst[16..32].* = msg1.toBytes(); + state.update(msg0, msg1); + } + + fn mac(state: *State128L, comptime tag_bits: u9, adlen: usize, mlen: usize) [tag_bits / 8]u8 { + const blocks = &state.blocks; + var sizes: [16]u8 = undefined; + mem.writeInt(u64, sizes[0..8], @as(u64, adlen) * 8, .little); + mem.writeInt(u64, sizes[8..16], @as(u64, mlen) * 8, .little); + const tmp = AesBlock.fromBytes(&sizes).xorBlocks(blocks[2]); + var i: usize = 0; + while (i < 7) : (i += 1) { + state.update(tmp, tmp); + } + return switch (tag_bits) { + 128 => blocks[0].xorBlocks(blocks[1]).xorBlocks(blocks[2]).xorBlocks(blocks[3]) + .xorBlocks(blocks[4]).xorBlocks(blocks[5]).xorBlocks(blocks[6]).toBytes(), + 256 => tag: { + const t1 = blocks[0].xorBlocks(blocks[1]).xorBlocks(blocks[2]).xorBlocks(blocks[3]); + const t2 = blocks[4].xorBlocks(blocks[5]).xorBlocks(blocks[6]).xorBlocks(blocks[7]); + break :tag t1.toBytes() ++ t2.toBytes(); + }, + else => unreachable, + }; + } +}; + +/// The `Aegis128LMac` message authentication function outputs 256 bit tags. +/// In addition to being extremely fast, its large state, non-linearity +/// and non-invertibility provides the following properties: +/// - 128 bit security, stronger than GHash/Polyval/Poly1305. +/// - Recovering the secret key from the state would require ~2^128 attempts, +/// which is infeasible for any practical adversary. +/// - It has a large security margin against internal collisions. +pub const Aegis128LMac = AegisMacType(Aegis128L_256); + +/// Aegis128L MAC with a 128-bit output. +/// A MAC with a 128-bit output is not safe unless the number of messages +/// authenticated with the same key remains small. +/// After 2^48 messages, the probability of a collision is already ~ 2^-33. +/// If unsure, use the Aegis128LMac type, that has a 256 bit output. +pub const Aegis128LMac_128 = AegisMacType(Aegis128L); + +fn Aegis128LGenericType(comptime tag_bits: u9) type { + comptime assert(tag_bits == 128 or tag_bits == 256); // tag must be 128 or 256 bits + + return struct { + pub const tag_length = tag_bits / 8; + pub const nonce_length = 16; + pub const key_length = 16; + pub const block_length = 32; + + const State = State128L; + + /// c: ciphertext: output buffer should be of size m.len + /// tag: authentication tag: output MAC + /// m: message + /// ad: Associated Data + /// npub: public nonce + /// k: private key + pub fn encrypt( + c: []u8, + tag: *[tag_length]u8, + m: []const u8, + ad: []const u8, + npub: [nonce_length]u8, + key: [key_length]u8, + ) void { + assert(c.len == m.len); + var state = State128L.init(key, npub); + var src: [32]u8 align(16) = undefined; + var dst: [32]u8 align(16) = undefined; + var i: usize = 0; + while (i + 32 <= ad.len) : (i += 32) { + state.absorb(ad[i..][0..32]); + } + if (ad.len % 32 != 0) { + @memset(src[0..], 0); + @memcpy(src[0 .. ad.len % 32], ad[i..][0 .. ad.len % 32]); + state.absorb(&src); + } + i = 0; + while (i + 32 <= m.len) : (i += 32) { + state.enc(c[i..][0..32], m[i..][0..32]); + } + if (m.len % 32 != 0) { + @memset(src[0..], 0); + @memcpy(src[0 .. m.len % 32], m[i..][0 .. m.len % 32]); + state.enc(&dst, &src); + @memcpy(c[i..][0 .. m.len % 32], dst[0 .. m.len % 32]); + } + tag.* = state.mac(tag_bits, ad.len, m.len); + } + + /// `m`: Message + /// `c`: Ciphertext + /// `tag`: Authentication tag + /// `ad`: Associated data + /// `npub`: Public nonce + /// `k`: Private key + /// Asserts `c.len == m.len`. + /// + /// Contents of `m` are undefined if an error is returned. + pub fn decrypt( + m: []u8, + c: []const u8, + tag: [tag_length]u8, + ad: []const u8, + npub: [nonce_length]u8, + key: [key_length]u8, + ) AuthenticationError!void { + assert(c.len == m.len); + var state = State128L.init(key, npub); + var src: [32]u8 align(16) = undefined; + var dst: [32]u8 align(16) = undefined; + var i: usize = 0; + while (i + 32 <= ad.len) : (i += 32) { + state.absorb(ad[i..][0..32]); + } + if (ad.len % 32 != 0) { + @memset(src[0..], 0); + @memcpy(src[0 .. ad.len % 32], ad[i..][0 .. ad.len % 32]); + state.absorb(&src); + } + i = 0; + while (i + 32 <= m.len) : (i += 32) { + state.dec(m[i..][0..32], c[i..][0..32]); + } + if (m.len % 32 != 0) { + @memset(src[0..], 0); + @memcpy(src[0 .. m.len % 32], c[i..][0 .. m.len % 32]); + state.dec(&dst, &src); + @memcpy(m[i..][0 .. m.len % 32], dst[0 .. m.len % 32]); + @memset(dst[0 .. m.len % 32], 0); + const blocks = &state.blocks; + blocks[0] = blocks[0].xorBlocks(AesBlock.fromBytes(dst[0..16])); + blocks[4] = blocks[4].xorBlocks(AesBlock.fromBytes(dst[16..32])); + } + var computed_tag = state.mac(tag_bits, ad.len, m.len); + const verify = crypto.utils.timingSafeEql([tag_length]u8, computed_tag, tag); + if (!verify) { + crypto.utils.secureZero(u8, &computed_tag); + @memset(m, undefined); + return error.AuthenticationFailed; + } + } + }; +} + +fn AegisMacType(comptime T: type) type { + return struct { + const AegisMac = @This(); + + pub const mac_length = T.tag_length; + pub const key_length = T.key_length; + pub const block_length = T.block_length; + + state: T.State, + buf: [block_length]u8 = undefined, + off: usize = 0, + msg_len: usize = 0, + + /// Initialize a state for the MAC function + pub fn init(key: *const [key_length]u8) AegisMac { + const nonce = [_]u8{0} ** T.nonce_length; + return AegisMac{ + .state = T.State.init(key.*, nonce), + }; + } + + /// Add data to the state + pub fn update(self: *AegisMac, b: []const u8) void { + self.msg_len += b.len; + + const len_partial = @min(b.len, block_length - self.off); + @memcpy(self.buf[self.off..][0..len_partial], b[0..len_partial]); + self.off += len_partial; + if (self.off < block_length) { + return; + } + self.state.absorb(&self.buf); + + var i = len_partial; + self.off = 0; + while (i + block_length <= b.len) : (i += block_length) { + self.state.absorb(b[i..][0..block_length]); + } + if (i != b.len) { + self.off = b.len - i; + @memcpy(self.buf[0..self.off], b[i..]); + } + } + + /// Return an authentication tag for the current state + pub fn final(self: *AegisMac, out: *[mac_length]u8) void { + if (self.off > 0) { + var pad = [_]u8{0} ** block_length; + @memcpy(pad[0..self.off], self.buf[0..self.off]); + self.state.absorb(&pad); + } + out.* = self.state.mac(T.tag_length * 8, self.msg_len, 0); + } + + /// Return an authentication tag for a message and a key + pub fn create(out: *[mac_length]u8, msg: []const u8, key: *const [key_length]u8) void { + var ctx = AegisMac.init(key); + ctx.update(msg); + ctx.final(out); + } + + pub const Error = error{}; + pub const Writer = std.io.Writer(*AegisMac, Error, write); + + fn write(self: *AegisMac, bytes: []const u8) Error!usize { + self.update(bytes); + return bytes.len; + } + + pub fn writer(self: *AegisMac) Writer { + return .{ .context = self }; + } + }; +} + +const testing = std.testing; +const fmt = std.fmt; + +test "Aegis128L test vector 1" { + const key: [Aegis128L.key_length]u8 = [_]u8{ 0x10, 0x01 } ++ [_]u8{0x00} ** 14; + const nonce: [Aegis128L.nonce_length]u8 = [_]u8{ 0x10, 0x00, 0x02 } ++ [_]u8{0x00} ** 13; + const ad = [8]u8{ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07 }; + const m = [32]u8{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + }; + var c: [m.len]u8 = undefined; + var m2: [m.len]u8 = undefined; + var tag: [Aegis128L.tag_length]u8 = undefined; + + Aegis128L.encrypt(&c, &tag, &m, &ad, nonce, key); + try Aegis128L.decrypt(&m2, &c, tag, &ad, nonce, key); + try testing.expectEqualSlices(u8, &m, &m2); + + try assertEqual("79d94593d8c2119d7e8fd9b8fc77845c5c077a05b2528b6ac54b563aed8efe84", &c); + try assertEqual("cc6f3372f6aa1bb82388d695c3962d9a", &tag); + + c[0] +%= 1; + try testing.expectError( + error.AuthenticationFailed, + Aegis128L.decrypt(&m2, &c, tag, &ad, nonce, key), + ); + c[0] -%= 1; + tag[0] +%= 1; + try testing.expectError( + error.AuthenticationFailed, + Aegis128L.decrypt(&m2, &c, tag, &ad, nonce, key), + ); +} + +test "Aegis128L test vector 2" { + const key: [Aegis128L.key_length]u8 = [_]u8{0x00} ** 16; + const nonce: [Aegis128L.nonce_length]u8 = [_]u8{0x00} ** 16; + const ad = [_]u8{}; + const m = [_]u8{0x00} ** 16; + var c: [m.len]u8 = undefined; + var m2: [m.len]u8 = undefined; + var tag: [Aegis128L.tag_length]u8 = undefined; + + Aegis128L.encrypt(&c, &tag, &m, &ad, nonce, key); + try Aegis128L.decrypt(&m2, &c, tag, &ad, nonce, key); + try testing.expectEqualSlices(u8, &m, &m2); + + try assertEqual("41de9000a7b5e40e2d68bb64d99ebb19", &c); + try assertEqual("f4d997cc9b94227ada4fe4165422b1c8", &tag); +} + +test "Aegis128L test vector 3" { + const key: [Aegis128L.key_length]u8 = [_]u8{0x00} ** 16; + const nonce: [Aegis128L.nonce_length]u8 = [_]u8{0x00} ** 16; + const ad = [_]u8{}; + const m = [_]u8{}; + var c: [m.len]u8 = undefined; + var m2: [m.len]u8 = undefined; + var tag: [Aegis128L.tag_length]u8 = undefined; + + Aegis128L.encrypt(&c, &tag, &m, &ad, nonce, key); + try Aegis128L.decrypt(&m2, &c, tag, &ad, nonce, key); + try testing.expectEqualSlices(u8, &m, &m2); + + try assertEqual("83cc600dc4e3e7e62d4055826174f149", &tag); +} + +test "Aegis MAC" { + const key = [_]u8{0x00} ** Aegis128LMac.key_length; + var msg: [64]u8 = undefined; + for (&msg, 0..) |*m, i| { + m.* = @as(u8, @truncate(i)); + } + const st_init = Aegis128LMac.init(&key); + var st = st_init; + var tag: [Aegis128LMac.mac_length]u8 = undefined; + + st.update(msg[0..32]); + st.update(msg[32..]); + st.final(&tag); + try assertEqual("f8840849602738d81037cbaa0f584ea95759e2ac60263ce77346bcdc79fe4319", &tag); + + st = st_init; + st.update(msg[0..31]); + st.update(msg[31..]); + st.final(&tag); + try assertEqual("f8840849602738d81037cbaa0f584ea95759e2ac60263ce77346bcdc79fe4319", &tag); + + st = st_init; + st.update(msg[0..14]); + st.update(msg[14..30]); + st.update(msg[30..]); + st.final(&tag); + try assertEqual("f8840849602738d81037cbaa0f584ea95759e2ac60263ce77346bcdc79fe4319", &tag); + + var empty: [0]u8 = undefined; + const nonce = [_]u8{0x00} ** Aegis128L_256.nonce_length; + Aegis128L_256.encrypt(&empty, &tag, &empty, &msg, nonce, key); + try assertEqual("f8840849602738d81037cbaa0f584ea95759e2ac60263ce77346bcdc79fe4319", &tag); + + // An update whose size is not a multiple of the block size + st = st_init; + st.update(msg[0..33]); + st.final(&tag); + try assertEqual("c7cf649a844c1a6676cf6d91b1658e0aee54a4da330b0a8d3bc7ea4067551d1b", &tag); +} + +// Assert `expected` == hex(`input`) where `input` is a bytestring +fn assertEqual(comptime expected_hex: [:0]const u8, input: []const u8) !void { + var expected_bytes: [expected_hex.len / 2]u8 = undefined; + for (&expected_bytes, 0..) |*r, i| { + r.* = fmt.parseInt(u8, expected_hex[2 * i .. 2 * i + 2], 16) catch unreachable; + } + + try testing.expectEqualSlices(u8, &expected_bytes, input); +} diff --git a/ocam/src/stdx/windows.zig b/ocam/src/stdx/windows.zig new file mode 100644 index 00000000..a121cf91 --- /dev/null +++ b/ocam/src/stdx/windows.zig @@ -0,0 +1,48 @@ +const std = @import("std"); +const windows = std.os.windows; + +pub extern "kernel32" fn GetSystemTimePreciseAsFileTime( + lpFileTime: *windows.FILETIME, +) callconv(.winapi) void; + +pub extern "kernel32" fn GetCommandLineW() callconv(.winapi) windows.LPWSTR; + +pub extern "kernel32" fn GetProcessTimes( + in_hProcess: windows.HANDLE, + out_lpCreationTime: *windows.FILETIME, + out_lpExitTime: *windows.FILETIME, + out_lpKernelTime: *windows.FILETIME, + out_lpUserTime: *windows.FILETIME, +) callconv(.winapi) windows.BOOL; + +pub extern "kernel32" fn SetProcessWorkingSetSize( + hProcess: windows.HANDLE, + dwMinimumWorkingSetSize: windows.SIZE_T, + dwMaximumWorkingSetSize: windows.SIZE_T, +) callconv(.winapi) windows.BOOL; + +pub extern "kernel32" fn GetProcessWorkingSetSize( + hProcess: windows.HANDLE, + lpMinimumWorkingSetSize: *windows.SIZE_T, + lpMaximumWorkingSetSize: *windows.SIZE_T, +) callconv(.winapi) windows.BOOL; + +pub const LOCKFILE_EXCLUSIVE_LOCK = 0x2; +pub const LOCKFILE_FAIL_IMMEDIATELY = 0x1; +pub extern "kernel32" fn LockFileEx( + hFile: windows.HANDLE, + dwFlags: windows.DWORD, + dwReserved: windows.DWORD, + nNumberOfBytesToLockLow: windows.DWORD, + nNumberOfBytesToLockHigh: windows.DWORD, + lpOverlapped: ?*windows.OVERLAPPED, +) callconv(.winapi) windows.BOOL; + +pub extern "kernel32" fn SetEndOfFile( + hFile: windows.HANDLE, +) callconv(.winapi) windows.BOOL; + +pub extern "kernel32" fn ConnectNamedPipe( + hNamedPipe: windows.HANDLE, + lpOverlapped: ?*windows.OVERLAPPED, +) callconv(.winapi) windows.BOOL; diff --git a/ocam/src/stdx/zipfian.zig b/ocam/src/stdx/zipfian.zig new file mode 100644 index 00000000..9badda79 --- /dev/null +++ b/ocam/src/stdx/zipfian.zig @@ -0,0 +1,402 @@ +//! Zipfian-distributed random number generation. +//! +//! In the Zipfian distribution a small percentage of candidate +//! items have a high probability of being selected, while most items +//! have a very low probability of being selected. +//! It is commonly understood to model the "80-20" Pareto principle, +//! and to be a discreet version of the Pareto distribution, +//! and terminology related to both are often used interchangeably. +//! +//! Zipfian numbers follow an inverse power law, where the 1st item +//! is selected with high probability, and subsequent items +//! quickly fall off in probability. The rate of the fall off +//! is tunable by the _skew_, also called `s`, or `theta`, +//! depending on the source. +//! +//! Reference: +//! +//! - https://en.wikipedia.org/wiki/Zipf's_law#Formal_definition +//! +//! Note that it is not actually possible to select a value for +//! theta that literally follows the "80-20" rule for arbitrary set sizes; +//! the proportion of items that cumulatively make up 80% probability will +//! change as the set grows. +//! A zipfian generator that can adaptively follow the 80-20 rule is left for future work. +//! +//! In practice these probabilities often need to be spread across e.g. a +//! table's keyspace, which involves some kind of mapping step from index to index. +//! Because that mapping is non-trivial to optimize, it is also provided here. +//! +//! The algorithm here is based on +//! "Quickly Generating Billion-Record Synthetic Databases", Jim Gray et al, SIGMOD 1994. +//! Per the paper it is adapted from Knuth vol 3. +//! This is also the algorithm used by YCSB's ZipfianGenerator.java. +//! Note that the code listing in the paper contains obvious errors, +//! corrected here and in YCSB. +//! +//! There are two generators here, +//! both of which generate random keys from 0 to a specified maximum. +//! In the basic `ZipfianGenerator`, key 0 has the highest probability, +//! 1 the next highest, etc. +//! The `ZipfianShuffled` generator instead spreads the distribution out +//! across the key space as if it were a shuffled deck. +//! +//! The `ZipfianGenerator` allows the key space to grow, +//! but the `ZipfianShuffled` does not - maintaining the illusion of a shuffled +//! deck while growing the keyspace involves tradeoffs in the quality +//! of the distribution. A previous revision of `ZipfianShuffled` _was_ growable, +//! at the cost of not preserving a true Zipfian distribution for the long tail +//! of unlikely items. Dig that out of commit history if it's ever needed. +//! +//! Both should pass a 2-sample Kolmogorov–Smirnov test. + +const std = @import("std"); +const stdx = @import("stdx.zig"); +const assert = std.debug.assert; +const Random = std.Random; +const math = std.math; +const Snap = stdx.Snap; +const module_path = "src/stdx"; +const snap = Snap.snap_fn(module_path); + +/// The default "skew" of the distribution. +const theta_default = 0.99; // per YCSB + +/// Generates Zipfian-distributed numbers from 0 to a specified maximum. +/// +/// Many internal variables here are the same is in the paper, which I think +/// should reduce confusion if this subject needs to be revisited; the external +/// intended to be more understandable to the user and follow TigerStyle. +pub const ZipfianGenerator = struct { + theta: f64, + + /// The number of items in the set. + n: u64, + /// The Riemann zeta function calculated up to `n`, + /// aka the "generalized harmonic number" of order `theta` for `n`. + /// This is a pre-calculated factor in the probability of any particular item + /// being selected. + /// It is expensive to calculate for large but useful values of `n`, + /// but can be calculated incrementally as `n` grows. + zetan: f64, + + /// Create a generator from `[0, items)` with `theta` equal to 0.99. + pub fn init(items: u64) ZipfianGenerator { + return ZipfianGenerator.init_theta(items, theta_default); + } + + /// Create a generator from `[0, items)` with given `theta`. + /// + /// `theta` is the "skew" and is usually specified to be greater than 0 and less than 1, + /// with YCSB using 0.99, though values greater than 1 also seem to generate reasonable + /// distributions. `theta = 1` isn't allowed since it does not behave reasonably. + pub fn init_theta(items: u64, theta: f64) ZipfianGenerator { + assert(theta > 0.0); + assert(theta != 1.0); + return ZipfianGenerator{ + .theta = theta, + .n = items, + .zetan = zeta(items, theta), + }; + } + + /// Note that the variables in this function are mostly named + /// as in the reference paper and do not follow TigerStyle. + pub fn next(self: *const ZipfianGenerator, prng: *stdx.PRNG) u64 { + assert(self.n > 0); + + // Math voodoo, copied from the paper, + // which doesn't explain it, but claims it is from Knuth volume 3. + + // NB: These depend only on zetan and could be cached for a minor speedup. + const alpha = 1.0 / (1.0 - self.theta); + const eta = (1.0 - math.pow( + f64, + 2.0 / @as(f64, @floatFromInt(self.n)), + 1.0 - self.theta, + )) / + (1.0 - zeta(2.0, self.theta) / self.zetan); + + const u = random_f64(prng); + const uz = u * self.zetan; + + if (uz < 1.0) { + return 0; + } + + if (uz < 1.0 + math.pow(f64, 0.5, self.theta)) { + return 1; + } + + return @as(u64, @intFromFloat( + @as(f64, @floatFromInt(self.n)) * + math.pow(f64, (eta * u) - eta + 1.0, alpha), + )); + } + + /// Grow the size of the random set. + pub fn grow(self: *ZipfianGenerator, new_items: u64) void { + const items = self.n + new_items; + const zetan_new = zeta_incremental(self.n, new_items, self.zetan, self.theta); + self.* = .{ + .theta = self.theta, + .n = items, + .zetan = zetan_new, + }; + } +}; + +/// The Riemann zeta function up to `n`, +/// aka the "generalized harmonic number" of order 'theta' for `n`. +fn zeta(n: u64, theta: f64) f64 { + var i: u64 = 1; + var zeta_sum: f64 = 0.0; + while (i <= n) : (i += 1) { + zeta_sum += math.pow(f64, 1.0 / @as(f64, @floatFromInt(i)), theta); + } + return zeta_sum; +} + +/// Incremental calculation of zeta. +fn zeta_incremental( + n_previous: u64, + n_additional: u64, + zetan_previous: f64, + theta: f64, +) f64 { + const n_new = n_previous + n_additional; + var i = n_previous + 1; + var zeta_sum = zetan_previous; + while (i <= n_new) : (i += 1) { + zeta_sum += math.pow(f64, 1.0 / @as(f64, @floatFromInt(i)), theta); + } + return zeta_sum; +} + +/// Generates Zipfian-distributed numbers from 0 to maximum, +/// but the probabilities of each number are "shuffled", +/// not clustered around 0. +/// +/// This is used to simulate typical data access patterns in +/// some keyspace, where a few keys are hot and most are cold. +/// +/// This behaves as if it maintains a shuffled mapping +/// from every index to a different index. Internally, it is implemented +/// with a bijective "hash" function (modular‑multiplication permutation) +/// f(i) = (a * i) mod N +/// with gcd(a, N) = 1, so every original (Zipfian) index i +/// maps to a unique “shuffled” index without collisions. +/// Refer to PR #3070 for further details: https://github.com/tigerbeetle/tigerbeetle/pull/3070 +pub const ZipfianShuffled = struct { + gen: ZipfianGenerator, + a: u64, + + pub fn init(items: u64, prng: *stdx.PRNG) ZipfianShuffled { + return ZipfianShuffled.init_theta(items, theta_default, prng); + } + + pub fn init_theta(items: u64, theta: f64, prng: *stdx.PRNG) ZipfianShuffled { + var zipf = ZipfianShuffled{ + .gen = ZipfianGenerator.init_theta(0, theta), + .a = 0, // Correct a is determined in grow. + }; + + zipf.choose_shuffle_function(items, prng); + + return zipf; + } + + fn transform(self: *const ZipfianShuffled, zipf_standard: u64) u64 { + return (zipf_standard * self.a) % self.gen.n; + } + + pub fn next(self: *const ZipfianShuffled, prng: *stdx.PRNG) u64 { + const zipf_standard = self.gen.next(prng); + const zipf_shuffled = self.transform(zipf_standard); + return zipf_shuffled; + } + + fn choose_shuffle_function(self: *ZipfianShuffled, new_items: u64, prng: *stdx.PRNG) void { + if (new_items == 0) { + return; + } + + const old_n = self.gen.n; + const new_n = old_n + new_items; + + self.gen.grow(new_items); + + assert(self.gen.n == new_n); + + // We try to find an `a` so that it satisifies gcd(a,N) == 1. + // This allows us to generate a permutation with (a*zipf_standard) mod N. + // This permutation maps one index to another without holes, i.e. is bijective. + self.a = random_coprime(prng, self.gen.n); + } + + fn random_coprime(prng: *stdx.PRNG, n: u64) u64 { + // The bound is arbitrary but should be large enough to find a number that satisifies + // the requirement (see https://en.wikipedia.org/wiki/Euler%27s_totient_function). + for (0..100_000) |_| { + const a = prng.range_inclusive(u64, 1, n); + if (std.math.gcd(a, n) == 1) { + return a; + } + } else { + @panic("Did not find a random coprime (probabilistic)"); + } + } +}; + +/// stdx.PRNG intentionally doesn't support generating floats, to ensure determinism. For +/// benchmarking purposes, using floats is OK though, so we fall back to std implementation here. +fn random_f64(prng: *stdx.PRNG) f64 { + return std.Random.init(prng, stdx.PRNG.fill).float(f64); +} + +test "zeta_incremental" { + const Case = struct { + n_start: u64, + n_incremental: u64, + theta: f64, + }; + const cases = [_]Case{ + .{ + .n_start = 0, + .n_incremental = 10, + .theta = 0.99, + }, + .{ + .n_start = 0, + .n_incremental = 10, + .theta = 1.01, + }, + .{ + .n_start = 100, + .n_incremental = 100, + .theta = 0.99, + }, + }; + + for (cases) |case| { + const n = case.n_start + case.n_incremental; + const zeta_expected = zeta(n, case.theta); + const zeta_actual_start = zeta(case.n_start, case.theta); + const zeta_actual = zeta_incremental( + case.n_start, + case.n_incremental, + zeta_actual_start, + case.theta, + ); + assert(zeta_expected == zeta_actual); + } +} + +// Testing that the grow function correctly calculates zeta incrementally. +test "zipfian-grow" { + // Need to try multiple times to ensure they don't both coincidentally + // pick the likely 0 value. + var i: u64 = 10; + while (i < 100) : (i += 1) { + const expected = brk: { + var prng = stdx.PRNG.from_seed(0); + var zipf = ZipfianGenerator.init_theta(i, 0.9); + break :brk zipf.next(&prng); + }; + const actual = brk: { + var prng = stdx.PRNG.from_seed(0); + var zipf = ZipfianGenerator.init_theta(1, 0.9); + zipf.grow(i - 1); + break :brk zipf.next(&prng); + }; + assert(expected == actual); + } +} + +// Test that ctors are all doing the same thing. +test "zipfian-ctors" { + var prng = stdx.PRNG.from_seed(0); + + for ([_]u64{ 0, 1, 10, 999 }) |i| { + { + const zipf1 = ZipfianGenerator.init(i); + const zipf2 = ZipfianGenerator.init_theta(i, theta_default); + const szipf1 = ZipfianShuffled.init(i, &prng); + const szipf2 = ZipfianShuffled.init_theta(i, theta_default, &prng); + + assert(zipf1.n == zipf2.n); + assert(zipf1.n == szipf1.gen.n); + assert(zipf1.n == szipf2.gen.n); + + assert(zipf1.zetan == zipf2.zetan); + assert(zipf1.zetan == szipf1.gen.zetan); + assert(zipf1.zetan == szipf2.gen.zetan); + } + + { + const zipf1 = ZipfianGenerator.init_theta(i, 0.89); + const szipf1 = ZipfianShuffled.init_theta(i, 0.89, &prng); + + assert(zipf1.n == szipf1.gen.n); + assert(zipf1.zetan == szipf1.gen.zetan); + } + } +} + +test "zipfian-distribution" { + const max_number = 10; + + var prng = stdx.PRNG.from_seed(42); + const zipf = ZipfianGenerator.init(max_number); + + var distribution: [max_number]u32 = @splat(0); + + for (0..1000) |_| { + const n = zipf.next(&prng); + distribution[n] += 1; + } + + try snap(@src(), + \\{ 333, 170, 125, 90, 59, 61, 43, 47, 38, 34 } + ).diff_fmt("{d}", .{distribution}); +} + +test "shuffled-zipfian-distribution" { + const max_number = 10; + + var prng = stdx.PRNG.from_seed(42); + const zipf_shuffled = ZipfianShuffled.init(max_number, &prng); + + var distribution: [max_number]u32 = @splat(0); + + for (0..1000) |_| { + const n = zipf_shuffled.next(&prng); + distribution[n] += 1; + } + + try snap(@src(), + \\{ 333, 34, 38, 47, 43, 61, 60, 89, 125, 170 } + ).diff_fmt("{d}", .{distribution}); +} + +// Non-statistical smoke tests related to the shuffled hot items optimization. +// These could fail if that optimization is tweaked or if the prng changes. +// The standard zipf generator is tested, here we test the mapping of the shuffled one. +test "zipfian-shuffled" { + const max = 100; + var prng = stdx.PRNG.from_seed(0); + const allocator = std.testing.allocator; + var found = try allocator.alloc(bool, max); + defer allocator.free(found); + + for (1..max) |items| { + @memset(found, false); + var zipf = ZipfianShuffled.init(items, &prng); + + for (0..items) |i| { + const zipf_shuffled = zipf.transform(i); + try std.testing.expect(!found[zipf_shuffled]); + found[zipf_shuffled] = true; + } + } +} diff --git a/ocam/src/storage.zig b/ocam/src/storage.zig new file mode 100644 index 00000000..165adab1 --- /dev/null +++ b/ocam/src/storage.zig @@ -0,0 +1,451 @@ +const std = @import("std"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const log = std.log.scoped(.storage); + +const vsr = @import("vsr.zig"); +const stdx = vsr.stdx; +const constants = vsr.constants; +const Tracer = vsr.trace.Tracer; + +pub fn StorageType(comptime IO: type) type { + return struct { + const Storage = @This(); + + /// See usage in Journal.write_sectors() for details. + pub const synchronicity: enum { + always_synchronous, + always_asynchronous, + } = .always_asynchronous; + + pub const Read = struct { + completion: IO.Completion, + callback: *const fn (read: *Storage.Read) void, + + /// The buffer to read into, re-sliced and re-assigned + /// as we go, e.g. after partial reads. + buffer: []u8, + + /// The position into the file descriptor from where + /// we should read, also adjusted as we go. + offset: u64, + + /// The maximum amount of bytes to read per syscall. We use this to subdivide + /// troublesome reads into smaller reads to work around latent sector errors (LSEs). + target_max: u64, + + zone: vsr.Zone, + start: ?stdx.Instant, + + /// Returns a target slice into `buffer` to read into, capped by `target_max`. + /// If the previous read was a partial read of physical sectors (e.g. 512 bytes) less + /// than our logical sector size (e.g. 4 KiB), so that the remainder of the buffer is + /// no longer aligned to a logical sector, then we further cap the slice to get back + /// onto a logical sector boundary. + fn target(read: *Read) []u8 { + // A worked example of a partial read that leaves the rest of the buffer unaligned: + // This could happen for non-Advanced Format disks with a physical + // sector of 512 bytes. + // + // We want to read 8 KiB: + // buffer.ptr = 0 + // buffer.len = 8192 + // ... and then experience a partial read of only 512 bytes: + // buffer.ptr = 512 + // buffer.len = 7680 + // + // We can now see that `buffer.len` is no longer a sector multiple of 4 KiB and + // further that we have 3584 bytes left of the partial sector read. + // If we subtract this amount from our logical sector size of 4 KiB we get + // 512 bytes, which is the alignment error that we need to subtract from + // `target_max` to get back onto the boundary. + var max = read.target_max; + + const partial_sector_read_remainder = read.buffer.len % constants.sector_size; + if (partial_sector_read_remainder != 0) { + // TODO log.debug() because this is interesting, + // and to ensure fuzz test coverage. + const partial_sector_read = + constants.sector_size - + partial_sector_read_remainder; + max -= partial_sector_read; + } + + return read.buffer[0..@min(read.buffer.len, max)]; + } + }; + + pub const Write = struct { + completion: IO.Completion, + callback: *const fn (write: *Storage.Write) void, + buffer: []const u8, + offset: u64, + + zone: vsr.Zone, + start: ?stdx.Instant, + }; + + pub const NextTick = IO.Completion; + + pub const NextTickSource = IO.NextTickSource; + + io: *IO, + tracer: *Tracer, + dir_fd: IO.fd_t, + fd: IO.fd_t, + + pub fn init(io: *IO, tracer: *Tracer, options: struct { + path: []const u8, + size_min: u64, + purpose: IO.OpenDataFilePurpose, + direct_io: vsr.io.DirectIO, + }) !Storage { + // TODO Resolve the parent directory properly in the presence of .. and symlinks. + // TODO Handle physical volumes where there is no directory to fsync. + const dirname = std.fs.path.dirname(options.path) orelse "."; + const basename = std.fs.path.basename(options.path); + + const dir_fd = try IO.open_dir(dirname); + errdefer std.posix.close(dir_fd); + + const fd = try io.open_data_file( + dir_fd, + basename, + options.size_min, + options.purpose, + options.direct_io, + ); + errdefer std.posix.close(fd); + + return .{ + .io = io, + .tracer = tracer, + .dir_fd = dir_fd, + .fd = fd, + }; + } + + pub fn deinit(storage: *Storage) void { + assert(storage.fd != IO.INVALID_FILE); + assert(storage.dir_fd != IO.INVALID_FILE); + + std.posix.close(storage.fd); + storage.fd = IO.INVALID_FILE; + + std.posix.close(storage.dir_fd); + storage.dir_fd = IO.INVALID_FILE; + } + + pub fn run(storage: *Storage) void { + storage.io.run() catch |err| { + log.warn("tick: {}", .{err}); + std.debug.panic("io.tick(): {}", .{err}); + }; + } + + pub fn on_next_tick( + storage: *Storage, + source: NextTickSource, + callback: *const fn (*Storage.NextTick) void, + next_tick: *Storage.NextTick, + ) void { + // Do a bit of pointer trickery to keep the grid on_next_tick interface the same for + // now. + storage.io.next_tick( + *anyopaque, + @constCast(callback), + struct { + fn adapter( + ctx: *anyopaque, + completion: *IO.Completion, + _: IO.NextTickResult, + ) void { + const callback_original: *const fn (*NextTick) void = + @ptrCast(@alignCast(ctx)); + callback_original(completion); + } + }.adapter, + next_tick, + source, + ); + } + + pub fn reset_next_tick_lsm(storage: *Storage) void { + storage.io.reset_next_tick(.lsm); + } + + pub fn read_sectors( + self: *Storage, + callback: *const fn (read: *Storage.Read) void, + read: *Storage.Read, + buffer: []u8, + zone: vsr.Zone, + offset_in_zone: u64, + ) void { + zone.verify_iop(buffer, offset_in_zone); + assert(zone != .grid_padding); + + const offset_in_storage = zone.offset(offset_in_zone); + read.* = .{ + .completion = undefined, + .callback = callback, + .buffer = buffer, + .offset = offset_in_storage, + .target_max = buffer.len, + .zone = zone, + .start = self.tracer.time.monotonic(), + }; + + self.start_read(read, null); + assert(read.target().len > 0); + } + + fn start_read(self: *Storage, read: *Storage.Read, bytes_read: ?usize) void { + assert(read.offset % constants.sector_size == 0); + maybe(bytes_read == 0); // Retrying erroneous read; same offset with smaller window. + + const bytes = bytes_read orelse 0; + assert(bytes <= read.target().len); + + read.offset += bytes; + read.buffer = read.buffer[bytes..]; + + const target = read.target(); + if (target.len == 0) { + // Resolving the read inline means start_read() must not have been called from + // read_sectors(). If it was, this is a synchronous callback resolution and should + // be reported. + assert(bytes_read != null); + + self.tracer.timing( + .{ .storage_read = .{ .zone = read.zone } }, + read.start.?.elapsed(self.tracer.time.monotonic()), + ); + + read.callback(read); + return; + } + + self.assert_bounds(target, read.offset); + self.io.read( + *Storage, + self, + on_read, + &read.completion, + self.fd, + target, + read.offset, + ); + } + + fn on_read(self: *Storage, completion: *IO.Completion, result: IO.ReadError!usize) void { + const read: *Storage.Read = @fieldParentPtr("completion", completion); + + const bytes_read = result catch |err| switch (err) { + error.InputOutput => { + // The disk was unable to read some sectors (an internal CRC or + // hardware failure): We may also have already experienced a partial + // unaligned read, reading less physical sectors than the logical sector size, + // so we cannot expect `target.len` to be an exact logical sector multiple. + const target = read.target(); + if (target.len > constants.sector_size) { + // We tried to read more than a logical sector and failed. + log.warn("latent sector error: offset={}, subdividing read...", .{ + read.offset, + }); + + // Divide the buffer in half and try to read each half separately: + // This creates a recursive binary search for the sector(s) + // causing the error. This is considerably slower than doing a single + // bulk read and by now we might also have experienced the disk's + // read retry timeout (in seconds). TODO Our docs must instruct on why + // and how to reduce disk firmware timeouts. + + // These lines both implement ceiling division e.g. + // `((3 - 1) / 2) + 1 == 2` and require that the numerator + // is always greater than zero: + assert(target.len > 0); + const target_sectors = @divFloor(target.len - 1, constants.sector_size) + 1; + assert(target_sectors > 0); + read.target_max = + (@divFloor(target_sectors - 1, 2) + 1) * constants.sector_size; + assert(read.target_max >= constants.sector_size); + + // Pass 0 for `bytes_read` to retry the read with smaller `target_max`: + self.start_read(read, 0); + return; + } else { + // We tried to read at (or less than) logical sector granularity and failed. + log.warn("latent sector error: offset={}, zeroing sector...", .{ + read.offset, + }); + + // Zero this logical sector which can't be read: + // We will treat these EIO errors the same as a checksum failure. + // TODO This could be an interesting avenue to explore further, whether + // temporary or permanent EIO errors should be conflated + // with checksum failures. + assert(target.len > 0); + @memset(target, 0); + + // We could set `read.target_max` to `vsr.sector_ceil(read.buffer.len)` here + // in order to restart our pseudo-binary search on the rest of the sectors + // to be read, optimistically assuming that this is the last failing sector. + // However, data corruption that causes EIO errors often has spatial + // locality. Therefore, restarting our pseudo-binary search here might give + // us abysmal performance in the (not uncommon) case of many successive + // failing sectors. + self.start_read(read, target.len); + return; + } + }, + + error.WouldBlock, + error.NotOpenForReading, + error.ConnectionResetByPeer, + error.Alignment, + error.IsDir, + error.SystemResources, + error.Unseekable, + error.ConnectionTimedOut, + error.Unexpected, + => { + log.err( + "impossible read: offset={} buffer.len={} error={s}", + .{ read.offset, read.buffer.len, @errorName(err) }, + ); + @panic("impossible read"); + }, + }; + + // We tried to read more than there really is available to read. + // In other words, we thought we could read beyond the end of the file descriptor. + // + // Some possible causes: + // - The data file inode `size` was truncated or corrupted. + // - We are reading the last grid block in the data file, (block_size bytes), but the + // block in question is smaller (e.g. only 1 sector). + // - Another replica requested a block, but we are lagging far behind, and the block + // address requested is beyond the end of our data file. + if (bytes_read == 0) { + @memset(read.buffer, 0); + self.start_read(read, read.buffer.len); + return; + } + + // If our target was limited to a single sector, perhaps because of a latent sector + // error, then increase `target_max` according to AIMD now that we have read + // successfully and hopefully cleared the faulty zone. + // We assume that `target_max` may exceed `read.buffer.len` at any time. + if (read.target_max == constants.sector_size) { + // TODO Add log.debug because this is interesting. + read.target_max += constants.sector_size; + } + + self.start_read(read, bytes_read); + } + + pub fn write_sectors( + self: *Storage, + callback: *const fn (write: *Storage.Write) void, + write: *Storage.Write, + buffer: []const u8, + zone: vsr.Zone, + offset_in_zone: u64, + ) void { + zone.verify_iop(buffer, offset_in_zone); + assert(zone != .grid_padding); // Padding is never touched. + + const offset_in_storage = zone.offset(offset_in_zone); + write.* = .{ + .completion = undefined, + .callback = callback, + .buffer = buffer, + .offset = offset_in_storage, + .zone = zone, + .start = self.tracer.time.monotonic(), + }; + + self.start_write(write); + // Assert that the callback is called asynchronously. + assert(write.buffer.len > 0); + } + + fn start_write(self: *Storage, write: *Storage.Write) void { + assert(write.offset % constants.sector_size == 0); + self.assert_bounds(write.buffer, write.offset); + + self.io.write( + *Storage, + self, + on_write, + &write.completion, + self.fd, + write.buffer, + write.offset, + ); + } + + fn on_write(self: *Storage, completion: *IO.Completion, result: IO.WriteError!usize) void { + const write: *Storage.Write = @fieldParentPtr("completion", completion); + + const bytes_written = result catch |err| switch (err) { + // We assume that the disk will attempt to reallocate a spare sector for any LSE. + // TODO What if we receive a temporary EIO error because of a faulty cable? + error.InputOutput => @panic("latent sector error: no spare sectors to reallocate"), + // TODO: It seems like it might be possible for some filesystems to return ETIMEDOUT + // here. Consider handling this without panicking. + error.NoSpaceLeft => { + // NB: Intentionally crash on physical space exhaustion. + // Low space condition is handled logically, via `--limit-storage` argument. + vsr.fatal( + .no_space_left, + "write failed: no space left on device (offset={} size={})", + .{ write.offset, write.buffer.len }, + ); + }, + else => { + log.err( + "impossible write: offset={} buffer.len={} error={s}", + .{ write.offset, write.buffer.len, @errorName(err) }, + ); + @panic("impossible write"); + }, + }; + + if (bytes_written == 0) { + // This should never happen if the kernel and filesystem are well behaved. + // However, block devices are known to exhibit this behavior in the wild. + // TODO: Consider retrying with a timeout if this panic proves problematic, and be + // careful to avoid logging in a busy loop. Perhaps a better approach might be to + // return wrote = null here and let the protocol retry at a higher layer where + // there is more context available to decide on how important this is or whether + // to cancel. + @panic("write operation returned 0 bytes written"); + } + + write.offset += bytes_written; + write.buffer = write.buffer[bytes_written..]; + + if (write.buffer.len == 0) { + self.tracer.timing( + .{ .storage_write = .{ .zone = write.zone } }, + write.start.?.elapsed(self.tracer.time.monotonic()), + ); + + write.callback(write); + return; + } + + self.start_write(write); + } + + /// Ensures that the read or write is within bounds and intends to read or write some bytes. + fn assert_bounds(self: *Storage, buffer: []const u8, offset: u64) void { + _ = self; + _ = offset; + + assert(buffer.len > 0); + } + }; +} diff --git a/ocam/src/storage_fuzz.zig b/ocam/src/storage_fuzz.zig new file mode 100644 index 00000000..3cda68b5 --- /dev/null +++ b/ocam/src/storage_fuzz.zig @@ -0,0 +1,180 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const vsr = @import("vsr.zig"); +const stdx = vsr.stdx; +const constants = @import("constants.zig"); +const IO = @import("testing/io.zig").IO; +const Storage = @import("storage.zig").StorageType(IO); +const fixtures = @import("testing/fixtures.zig"); +const fuzz = @import("testing/fuzz.zig"); +const ratio = stdx.PRNG.ratio; + +pub fn main(gpa: std.mem.Allocator, args: fuzz.FuzzArgs) !void { + const zones: []const vsr.Zone = &.{ + .superblock, + .wal_headers, + .wal_prepares, + .client_replies, + }; + + const sector_size = constants.sector_size; + const sector_count = 64; + const storage_size = sector_count * sector_size; + const iterations = args.events_max orelse 10_000; + + var time_os: vsr.time.TimeOS = .{}; + const time = time_os.time(); + + var prng = stdx.PRNG.from_seed(args.seed); + for (0..iterations) |_| { + var fault_map = std.bit_set.ArrayBitSet(u8, sector_count).initEmpty(); + + const failed_sector_cluster_count = prng.range_inclusive(usize, 1, 10); + const failed_sector_cluster_minimum_length = prng.range_inclusive(usize, 1, 3); + const failed_sector_cluster_maximum_length = + failed_sector_cluster_minimum_length + prng.range_inclusive(usize, 1, 3); + + for (0..failed_sector_cluster_count) |_| { + const start = prng.range_inclusive( + usize, + 0, + sector_count - failed_sector_cluster_maximum_length, + ); + const end = start + prng.range_inclusive( + usize, + failed_sector_cluster_minimum_length, + failed_sector_cluster_maximum_length, + ); + + fault_map.setRangeValue(.{ .start = start, .end = @min(end, sector_count) }, true); + } + + var storage_data_written: [storage_size]u8 align(sector_size) = undefined; + @memset(&storage_data_written, 0); + + for (0..sector_count) |sector| { + if (!fault_map.isSet(sector)) { + prng.fill( + storage_data_written[sector * sector_size ..][0..sector_size], + ); + } + } + + var storage_data_stored: [storage_size]u8 align(sector_size) = undefined; + @memset(&storage_data_stored, 0); + var storage_data_read: [storage_size]u8 align(sector_size) = undefined; + @memset(&storage_data_read, 0); + + var files: [1]IO.File = .{ + .{ + .buffer = &storage_data_stored, + .fault_map = &fault_map.masks, + }, + }; + + var io = try IO.init(&files, .{ + .seed = args.seed, + .larger_than_logical_sector_read_fault_probability = ratio(10, 100), + }); + + var tracer = try fixtures.init_tracer(gpa, time, .{}); + defer tracer.deinit(gpa); + + var storage: Storage = .{ + .io = &io, + .tracer = &tracer, + .dir_fd = 0, + .fd = 0, + }; + // NB: Intentionally skipping deinit to avoid closing stdin. + + var write_completion: Storage.Write = undefined; + + for (zones) |zone| { + storage.write_sectors( + struct { + fn callback(completion: *Storage.Write) void { + _ = completion; + } + }.callback, + &write_completion, + storage_data_written[zone.start()..][0..zone.size().?], + zone, + 0, + ); + + storage.run(); + } + + for (zones) |zone| { + const ReadDetail = struct { + offset_in_zone: u64, + read_length: u64, + }; + + var read_details: [32]ReadDetail = undefined; + + const zone_sector_count: u64 = @divExact(zone.size().?, sector_size); + assert(zone_sector_count <= read_details.len); + + var index: u64 = 0; + var read_detail_length: usize = 0; + + while (index < zone_sector_count) : (read_detail_length += 1) { + const n_sectors = prng.range_inclusive( + u64, + 1, + @min(4, zone_sector_count - index), + ); + + read_details[read_detail_length] = .{ + .offset_in_zone = index * sector_size, + .read_length = n_sectors * sector_size, + }; + + index += n_sectors; + } + + prng.shuffle(ReadDetail, read_details[0..read_detail_length]); + + for (read_details[0..read_detail_length]) |read_detail| { + const sector_offset = read_detail.offset_in_zone; + const read_length = read_detail.read_length; + const read_buffer = + storage_data_read[zone.start() + sector_offset ..][0..read_length]; + + var read_completion: Storage.Read = undefined; + storage.read_sectors( + struct { + fn callback(completion: *Storage.Read) void { + _ = completion; + } + }.callback, + &read_completion, + read_buffer, + zone, + sector_offset, + ); + + storage.run(); + } + } + + for (zones) |zone| { + const start = zone.start(); + const end = start + zone.size().?; + + try std.testing.expectEqualSlices( + u8, + storage_data_stored[start..end], + storage_data_written[start..end], + ); + try std.testing.expectEqualSlices( + u8, + storage_data_stored[start..end], + storage_data_read[start..end], + ); + } + } +} diff --git a/ocam/src/testing/bench.zig b/ocam/src/testing/bench.zig new file mode 100644 index 00000000..e69de29b diff --git a/ocam/src/testing/cluster.zig b/ocam/src/testing/cluster.zig new file mode 100644 index 00000000..7ce8fb7f --- /dev/null +++ b/ocam/src/testing/cluster.zig @@ -0,0 +1,1196 @@ +const std = @import("std"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const mem = std.mem; +const log = std.log.scoped(.cluster); + +const stdx = @import("stdx"); +const Ratio = stdx.PRNG.Ratio; + +const constants = @import("../constants.zig"); +const message_pool = @import("../message_pool.zig"); +const ratio = stdx.PRNG.ratio; +const MessagePool = message_pool.MessagePool; +const Message = MessagePool.Message; +const IO = @import("io.zig").IO; + +const AOF = @import("../aof.zig").AOFType(IO); +const TimeSim = @import("time.zig").TimeSim; +const Multiversion = vsr.multiversion.Multiversion; +const IdPermutation = @import("id.zig").IdPermutation; + +const StateCheckerType = @import("cluster/state_checker.zig").StateCheckerType; +const StorageChecker = @import("cluster/storage_checker.zig").StorageChecker; +const GridChecker = @import("cluster/grid_checker.zig").GridChecker; +const ManifestCheckerType = @import("cluster/manifest_checker.zig").ManifestCheckerType; +const JournalCheckerType = @import("cluster/journal_checker.zig").JournalCheckerType; + +const vsr = @import("../vsr.zig"); +const format_writes_max = @import("../vsr/replica_format.zig").writes_max; + +const MiB = stdx.MiB; + +pub const ReplicaHealth = union(enum) { + up: struct { paused: bool }, + down, + reformatting, +}; + +pub const Release = struct { + release: vsr.Release, + release_client_min: vsr.Release, +}; + +/// Integer values represent exit codes. +// TODO This doesn't really belong in Cluster, but it is needed here so that StateChecker failures +// use the particular exit code. +pub const Failure = enum(u8) { + /// Any assertion crash will be given an exit code of 127 by default. + crash = 127, + liveness = 128, + correctness = 129, +}; + +/// Shift the id-generating index because the simulator network expects client ids to never collide +/// with a replica index. +const client_id_permutation_shift = constants.members_max; + +pub fn ClusterType(comptime StateMachineType: anytype) type { + return struct { + const Cluster = @This(); + + pub const Network = @import("cluster/network.zig").Network; + pub const NetworkOptions = @import("cluster/network.zig").NetworkOptions; + pub const Storage = @import("storage.zig").Storage; + pub const StorageFaultAtlas = @import("storage.zig").ClusterFaultAtlas; + pub const Tracer = Storage.Tracer; + pub const SuperBlock = vsr.SuperBlockType(Storage); + pub const MessageBus = @import("cluster/message_bus.zig").MessageBus; + pub const StateMachine = StateMachineType(Storage); + pub const Replica = vsr.ReplicaType(StateMachine, MessageBus, Storage, AOF); + pub const ReplicaReformat = + vsr.ReplicaReformatType(StateMachine, MessageBus, Storage); + pub const Client = vsr.ClientType(StateMachine.Operation, MessageBus); + pub const StateChecker = StateCheckerType(Client, Replica); + pub const ManifestChecker = ManifestCheckerType(StateMachine.Forest); + pub const JournalChecker = JournalCheckerType(Replica); + + pub const Options = struct { + cluster_id: u128, + replica_count: u8, + standby_count: u8, + client_count: u8, + storage_size_limit: u64, + reformats_max: u8, + seed: u64, + /// A monotonically-increasing list of releases. + /// Initially: + /// - All replicas are formatted and started with releases[0]. + /// - Only releases[0] is "bundled" in each replica. (Use `replica_restart()` to add + /// more). + releases: []const Release, + client_release: vsr.Release, + state_machine: StateMachine.Options, + }; + + pub const Callbacks = struct { + /// Invoked when a replica produces a reply. + /// Includes operation=register messages. + /// `client` is null when the prepare does not originate from a client. + on_cluster_reply: ?*const fn ( + cluster: *Cluster, + client: ?usize, + prepare: *const Message.Prepare, + reply: *const Message.Reply, + ) void = null, + + /// Invoked when a client receives a reply. + /// Includes operation=register messages. + on_client_reply: ?*const fn ( + cluster: *Cluster, + client: usize, + request: *const Message.Request, + reply: *const Message.Reply, + ) void = null, + }; + + allocator: mem.Allocator, + prng: stdx.PRNG, + options: Options, + callbacks: Callbacks, + + network: *Network, + storages: []Storage, + storage_fault_atlas: *StorageFaultAtlas, + + aofs: []AOF, + aof_ios: []IO, + aof_io_files: [][1]IO.File, + + /// NB: includes both active replicas and standbys. + replicas: []Replica, + replica_pools: []MessagePool, + replica_times: []TimeSim, + replica_tracers: []Tracer, + replica_health: []ReplicaHealth, + replica_upgrades: []?vsr.Release, + replica_reformats: []?ReplicaReformat, + replica_releases_bundled: []vsr.ReleaseList, + replica_pipeline_requests_limit: u32, + replica_count: u8, + standby_count: u8, + reformat_count: u32 = 0, + + clients: []?Client, + client_pools: []MessagePool, + client_times: []TimeSim, + /// Updated when the *client* is informed of the eviction. + /// (Which may be some time after the client is actually evicted by the cluster.) + client_eviction_reasons: []?vsr.Header.Eviction.Reason, + client_eviction_requests_cancelled: u32 = 0, + + client_id_permutation: IdPermutation, + + state_checker: StateChecker, + storage_checker: StorageChecker, + grid_checker: *GridChecker, + manifest_checker: ManifestChecker, + + context: ?*anyopaque = null, + + pub fn init( + allocator: mem.Allocator, + options: struct { + cluster: Options, + network: NetworkOptions, + storage: Storage.Options, + storage_fault_atlas: StorageFaultAtlas.Options, + callbacks: Callbacks, + }, + ) !*Cluster { + assert(options.cluster.replica_count >= 1); + assert(options.cluster.replica_count <= 6); + assert(options.cluster.client_count > 0); + assert(options.cluster.storage_size_limit % constants.sector_size == 0); + assert(options.cluster.storage_size_limit <= constants.storage_size_limit_max); + assert(options.cluster.releases.len > 0); + assert(options.storage.replica_index == null); + assert(options.storage.fault_atlas == null); + + for ( + options.cluster.releases[0 .. options.cluster.releases.len - 1], + options.cluster.releases[1..], + ) |release_a, release_b| { + assert(release_a.release.value < release_b.release.value); + assert(release_a.release_client_min.value <= release_b.release.value); + assert(release_a.release_client_min.value <= release_b.release_client_min.value); + } + + const client_count_total = options.cluster.client_count + options.cluster.reformats_max; + const node_count = options.cluster.replica_count + options.cluster.standby_count; + + var prng = stdx.PRNG.from_seed(options.cluster.seed); + + // TODO(Zig) Client.init()'s MessagePool.Options require a reference to the network. + // Use @returnAddress() instead. + var network = try allocator.create(Network); + errdefer allocator.destroy(network); + + var network_options = options.network; + network_options.client_count += options.cluster.reformats_max; + network.* = try Network.init(allocator, network_options); + errdefer network.deinit(); + + const storage_fault_atlas = try allocator.create(StorageFaultAtlas); + errdefer allocator.destroy(storage_fault_atlas); + + storage_fault_atlas.* = try StorageFaultAtlas.init( + allocator, + options.cluster.replica_count, + &prng, + options.storage_fault_atlas, + ); + errdefer storage_fault_atlas.deinit(allocator); + + var grid_checker = try allocator.create(GridChecker); + errdefer allocator.destroy(grid_checker); + + grid_checker.* = GridChecker.init(allocator); + errdefer grid_checker.deinit(); + + const storages = try allocator.alloc(Storage, node_count); + errdefer allocator.free(storages); + + for (storages, 0..) |*storage, replica_index| { + errdefer for (storages[0..replica_index]) |*s| s.deinit(allocator); + var storage_options = options.storage; + storage_options.replica_index = @intCast(replica_index); + storage_options.fault_atlas = storage_fault_atlas; + storage_options.grid_checker = grid_checker; + storage_options.iops_write_max = @max(format_writes_max, constants.iops_write_max); + storage.* = try Storage.init(allocator, storage_options); + // Disable most faults at startup, + // so that the replicas don't get stuck recovering_head. + storage.faulty = + replica_index >= vsr.quorums(options.cluster.replica_count).view_change; + } + errdefer for (storages) |*storage| storage.deinit(allocator); + + var replica_pools = try allocator.alloc(MessagePool, node_count); + errdefer allocator.free(replica_pools); + + // There may be more clients than `clients_max` (to test session eviction). + // +1 is for pulse which uses client_id = 0. + const pipeline_requests_limit = + (@min(options.cluster.client_count, constants.clients_max) + @as(u8, 1)) -| + constants.pipeline_prepare_queue_max; + + for (replica_pools, 0..) |*pool, i| { + errdefer for (replica_pools[0..i]) |*p| p.deinit(allocator); + pool.* = try MessagePool.init(allocator, .{ .replica = .{ + .members_count = options.cluster.replica_count + options.cluster.standby_count, + .pipeline_requests_limit = pipeline_requests_limit, + .message_bus = .testing, + } }); + } + errdefer for (replica_pools) |*pool| pool.deinit(allocator); + + const replica_times = try allocator.alloc(TimeSim, node_count); + errdefer allocator.free(replica_times); + @memset(replica_times, .{ + .resolution = constants.tick_ms * std.time.ns_per_ms, + .offset_type = .linear, + .offset_coefficient_A = 0, + .offset_coefficient_B = 0, + }); + + const replica_tracers = try allocator.alloc(Tracer, node_count); + errdefer allocator.free(replica_tracers); + + for (replica_tracers, 0..) |*tracer, replica_index| { + errdefer for (replica_tracers[0..replica_index]) |*t| t.deinit(allocator); + const time = replica_times[replica_index].time(); + tracer.* = try Tracer.init(allocator, time, .{ .replica = .{ + .cluster = options.cluster.cluster_id, + .replica = @intCast(replica_index), + } }, .{}); + } + errdefer for (replica_tracers) |*tracer| tracer.deinit(allocator); + + const replicas = try allocator.alloc(Replica, node_count); + errdefer allocator.free(replicas); + + const replica_health = try allocator.alloc(ReplicaHealth, node_count); + errdefer allocator.free(replica_health); + @memset(replica_health, .{ .up = .{ .paused = false } }); + + const replica_upgrades = try allocator.alloc(?vsr.Release, node_count); + errdefer allocator.free(replica_upgrades); + @memset(replica_upgrades, null); + + const replica_reformats = + try allocator.alloc(?ReplicaReformat, options.cluster.replica_count); + errdefer allocator.free(replica_reformats); + @memset(replica_reformats, null); + + var client_pools = try allocator.alloc(MessagePool, client_count_total); + errdefer allocator.free(client_pools); + + for (client_pools, 0..) |*pool, i| { + errdefer for (client_pools[0..i]) |*p| p.deinit(allocator); + pool.* = try MessagePool.init(allocator, .client); + } + errdefer for (client_pools) |*pool| pool.deinit(allocator); + + const client_eviction_reasons = + try allocator.alloc(?vsr.Header.Eviction.Reason, client_count_total); + errdefer allocator.free(client_eviction_reasons); + @memset(client_eviction_reasons, null); + + const client_times = try allocator.alloc(TimeSim, client_count_total); + errdefer allocator.free(client_times); + @memset(client_times, .{ + .resolution = constants.tick_ms * std.time.ns_per_ms, + .offset_type = .linear, + .offset_coefficient_A = 0, + .offset_coefficient_B = 0, + }); + + const client_id_permutation = IdPermutation.generate(&prng); + var clients = try allocator.alloc(?Client, client_count_total); + errdefer allocator.free(clients); + + for (clients, 0..) |*client, i| { + errdefer for (clients[0..i]) |*c| c.*.?.deinit(allocator); + client.* = try Client.init( + allocator, + client_times[i].time(), + &client_pools[i], + .{ + .id = client_id_permutation.encode(i + client_id_permutation_shift), + .cluster = options.cluster.cluster_id, + .replica_count = options.cluster.replica_count, + .aof_recovery = false, + .message_bus_options = .{ .network = network }, + .eviction_callback = client_on_eviction, + }, + ); + client.*.?.release = options.cluster.client_release; + } + errdefer for (clients) |*client| client.*.?.deinit(allocator); + + var state_checker = try StateChecker.init(allocator, .{ + .cluster_id = options.cluster.cluster_id, + .replicas = replicas, + .replica_count = options.cluster.replica_count, + .clients = clients, + }); + errdefer state_checker.deinit(); + + var storage_checker = try StorageChecker.init(allocator); + errdefer storage_checker.deinit(allocator); + + var manifest_checker = ManifestChecker.init(allocator); + errdefer manifest_checker.deinit(); + + // Format each replica's storage (equivalent to "tigerbeetle format ..."). + for (storages, 0..) |*storage, replica_index| { + try vsr.format( + Storage, + allocator, + storage, + .{ + .cluster = options.cluster.cluster_id, + .release = options.cluster.releases[0].release, + .replica = @intCast(replica_index), + .replica_count = options.cluster.replica_count, + .view = null, + }, + ); + } + + const replica_releases_bundled = try allocator.alloc(vsr.ReleaseList, node_count); + errdefer allocator.free(replica_releases_bundled); + + // We must heap-allocate the cluster since its pointer will be attached to the replica. + // TODO(Zig) @returnAddress(). + var cluster = try allocator.create(Cluster); + errdefer allocator.destroy(cluster); + + cluster.aofs = try allocator.alloc(AOF, node_count); + errdefer allocator.free(cluster.aofs); + + cluster.aof_io_files = try allocator.alloc([1]IO.File, node_count); + errdefer allocator.free(cluster.aof_io_files); + + cluster.aof_ios = try allocator.alloc(IO, node_count); + errdefer allocator.free(cluster.aof_ios); + + for ( + cluster.aofs, + cluster.aof_ios, + cluster.aof_io_files, + 0.., + ) |*aof, *aof_io, *aof_io_file, i| { + const buffer = try allocator.alignedAlloc( + u8, + constants.sector_size, + // Arbitrary value. + 32 * MiB, + ); + errdefer allocator.free(buffer); + + aof_io_file[0] = .{ .buffer = buffer }; + aof_io.* = try IO.init(aof_io_file, .{ + .seed = options.cluster.seed, + .larger_than_logical_sector_read_fault_probability = Ratio.zero(), + }); + errdefer for (cluster.aof_ios[0..i]) |*io| io.deinit(); + + aof.* = AOF{ + .io = aof_io, + .path = "test.aof", + .fd = 0, + }; + errdefer for (cluster.aofs[0..i]) |*aof_| aof_.deinit(allocator); + } + + cluster.* = Cluster{ + .allocator = allocator, + .prng = prng, + .options = options.cluster, + .callbacks = options.callbacks, + .network = network, + .storages = storages, + .aofs = cluster.aofs, + .aof_ios = cluster.aof_ios, + .aof_io_files = cluster.aof_io_files, + .storage_fault_atlas = storage_fault_atlas, + .replicas = replicas, + .replica_pools = replica_pools, + .replica_times = replica_times, + .replica_tracers = replica_tracers, + .replica_health = replica_health, + .replica_upgrades = replica_upgrades, + .replica_reformats = replica_reformats, + .replica_pipeline_requests_limit = pipeline_requests_limit, + .replica_releases_bundled = replica_releases_bundled, + .replica_count = options.cluster.replica_count, + .standby_count = options.cluster.standby_count, + .clients = clients, + .client_pools = client_pools, + .client_times = client_times, + .client_eviction_reasons = client_eviction_reasons, + .client_id_permutation = client_id_permutation, + .state_checker = state_checker, + .storage_checker = storage_checker, + .grid_checker = grid_checker, + .manifest_checker = manifest_checker, + }; + + for (cluster.replicas, 0..) |_, replica_index| { + errdefer for (replicas[0..replica_index]) |*r| r.deinit(allocator); + + cluster.replica_releases_bundled[replica_index] = .empty; + cluster.replica_releases_bundled[replica_index].push( + options.cluster.releases[0].release, + ); + + // Nonces are incremented on restart, so spread them out across 128 bit space + // to avoid collisions. + const nonce = (@as(u128, replica_index) << 64) + 1; + try cluster.replica_open(@intCast(replica_index), .{ + .nonce = nonce, + .release = options.cluster.releases[0].release, + }); + } + errdefer for (cluster.replicas) |*replica| replica.deinit(allocator); + + for (clients) |*client| { + client.*.?.on_reply_context = cluster; + client.*.?.on_reply_callback = client_on_reply; + network.link(client.*.?.message_bus.process, &client.*.?.message_bus); + } + + return cluster; + } + + pub fn deinit(cluster: *Cluster) void { + cluster.manifest_checker.deinit(); + cluster.storage_checker.deinit(cluster.allocator); + cluster.state_checker.deinit(); + cluster.network.deinit(); + + for (cluster.clients) |*client_maybe| { + if (client_maybe.*) |*client| { + client.deinit(cluster.allocator); + } + } + + for (cluster.client_pools) |*pool| pool.deinit(cluster.allocator); + for (cluster.replicas, 0..) |*replica, i| { + switch (cluster.replica_health[i]) { + .up => replica.deinit(cluster.allocator), + .down => {}, + .reformatting => cluster.replica_reformats[i].?.deinit(cluster.allocator), + } + } + for (cluster.replica_tracers) |*tracer| tracer.deinit(cluster.allocator); + for (cluster.replica_pools) |*pool| pool.deinit(cluster.allocator); + for (cluster.storages) |*storage| storage.deinit(cluster.allocator); + + for (cluster.aofs) |*aof| aof.close(); + + for (cluster.aof_ios) |*io| io.deinit(); + cluster.allocator.free(cluster.aof_ios); + + for (cluster.aof_io_files) |*io_file| { + for (io_file) |file| cluster.allocator.free(file.buffer); + } + cluster.allocator.free(cluster.aof_io_files); + + cluster.storage_fault_atlas.deinit(cluster.allocator); + cluster.grid_checker.deinit(); // (Storage references this.) + + cluster.allocator.free(cluster.clients); + cluster.allocator.free(cluster.client_times); + cluster.allocator.free(cluster.client_eviction_reasons); + cluster.allocator.free(cluster.client_pools); + cluster.allocator.free(cluster.replicas); + cluster.allocator.free(cluster.replica_reformats); + cluster.allocator.free(cluster.replica_upgrades); + cluster.allocator.free(cluster.replica_health); + cluster.allocator.free(cluster.replica_times); + cluster.allocator.free(cluster.replica_tracers); + cluster.allocator.free(cluster.replica_pools); + cluster.allocator.free(cluster.storages); + cluster.allocator.free(cluster.aofs); + cluster.allocator.free(cluster.replica_releases_bundled); + cluster.allocator.destroy(cluster.grid_checker); + cluster.allocator.destroy(cluster.storage_fault_atlas); + cluster.allocator.destroy(cluster.network); + cluster.allocator.destroy(cluster); + } + + pub fn tick(cluster: *Cluster) void { + // Interleave storage and network steps, to allow for faster-than-a-tick IO. + while (true) { + var advanced = false; + advanced = cluster.network.step() or advanced; + + for (cluster.clients, cluster.client_eviction_reasons) |*client, eviction_reason| { + if (client.* != null and eviction_reason != null) { + client.*.?.deinit(cluster.allocator); + client.* = null; + } + } + + for ( + cluster.storages, + cluster.replica_health, + cluster.replica_upgrades, + 0.., + ) |*storage, *health, *upgrade, i| { + if (health.* == .up and health.*.up.paused) continue; + // Upgrades immediately follow storage.step(), since upgrades occur at + // checkpoint completion. (Downgrades are triggered separately – see + // replica_restart()). + advanced = storage.step() or advanced; + if (upgrade.*) |_| cluster.replica_release_execute(@intCast(i)); + assert(upgrade.* == null); + } + + if (!advanced) break; + } + + cluster.network.tick(); + + for (cluster.clients) |*client_maybe| { + if (client_maybe.*) |*client| client.tick(); + } + + for ( + cluster.storages, + cluster.replicas, + cluster.aof_ios, + cluster.replica_times, + cluster.replica_health, + 0.., + ) |*storage, *replica, *aof_io, *time_sim, *health, replica_index| { + const time = time_sim.time(); + + if (health.* == .up and health.*.up.paused) { + // Tick the time even in a paused state, to simulate VM migration. + time.tick(); + } else { + storage.tick(); + switch (health.*) { + .reformatting => { + cluster.tick_reformat(@intCast(replica_index)); + time.tick(); + }, + .up => |up| { + assert(!up.paused); + + replica.tick(); + aof_io.run() catch |err| { + std.debug.panic("{}: io.run() failed: error={}", .{ + replica.replica, + err, + }); + }; + + // For performance, don't run every tick. + if (cluster.prng.chance(ratio(1, 100))) { + JournalChecker.check(replica); + } + + cluster.state_checker.check_state(replica.replica) catch |err| { + fatal(.correctness, "state checker error: {}", .{err}); + }; + }, + .down => { + // Keep ticking the time so that it won't have diverged too far to + // synchronize when the replica restarts. + time.tick(); + }, + } + } + } + } + + fn tick_reformat(cluster: *Cluster, replica_index: u8) void { + assert(cluster.replica_health[replica_index] == .reformatting); + + const reformat = &cluster.replica_reformats[replica_index].?; + if (reformat.pending()) return; + + reformat.format() catch |err| fatal(.correctness, "reformat: {}", .{err}); + + reformat.deinit(cluster.allocator); + cluster.replica_reformats[replica_index] = null; + cluster.replica_health[replica_index] = .down; + cluster.replica_restart(replica_index) catch unreachable; + cluster.state_checker.reformat(replica_index); + } + + pub fn replica_set_releases( + cluster: *Cluster, + replica_index: u8, + releases: *const vsr.ReleaseList, + ) void { + cluster.replica_releases_bundled[replica_index] = releases.*; + } + + pub fn replica_pause(cluster: *Cluster, replica_index: u8) void { + assert(cluster.replica_health[replica_index] == .up); + assert(!cluster.replica_health[replica_index].up.paused); + cluster.replica_health[replica_index].up.paused = true; + } + + pub fn replica_unpause(cluster: *Cluster, replica_index: u8) void { + assert(cluster.replica_health[replica_index] == .up); + assert(cluster.replica_health[replica_index].up.paused); + cluster.replica_health[replica_index].up.paused = false; + } + + /// Returns an error when the replica was unable to recover (open). + pub fn replica_restart( + cluster: *Cluster, + replica_index: u8, + ) !void { + assert(cluster.replica_health[replica_index] == .down); + assert(cluster.replica_upgrades[replica_index] == null); + + defer maybe(cluster.replica_health[replica_index] == .up); + defer assert(cluster.replica_upgrades[replica_index] == null); + + try cluster.replica_open(replica_index, .{ + .nonce = cluster.replicas[replica_index].nonce + 1, + .release = cluster.replica_releases_bundled[replica_index].last(), + }); + cluster.replica_enable(replica_index); + + if (cluster.replica_upgrades[replica_index]) |_| { + // Upgrade the replica promptly, rather than waiting until the next tick(). + // This ensures that the restart completes synchronously, as the caller expects. + cluster.replica_release_execute(replica_index); + } + } + + /// Reset a replica to its initial state, simulating a random crash/panic. + /// Leave the persistent storage untouched, and leave any currently + /// inflight messages to/from the replica in the network. + pub fn replica_crash(cluster: *Cluster, replica_index: u8) void { + assert(cluster.replica_health[replica_index] == .up); + + // Reset the storage before the replica so that pending writes can (partially) finish. + cluster.storages[replica_index].reset(); + + cluster.replicas[replica_index].deinit(cluster.allocator); + cluster.network.process_disable(.{ .replica = replica_index }); + cluster.replica_health[replica_index] = .down; + cluster.log_replica(.crash, replica_index); + + // Ensure that none of the replica's messages leaked when it was deinitialized. + const message_bus = cluster.network.get_message_bus(.{ .replica = replica_index }); + assert(message_bus.pool.free_list.count() == message_bus.pool.messages_max); + } + + fn replica_enable(cluster: *Cluster, replica_index: u8) void { + assert(cluster.replica_health[replica_index] == .down); + + cluster.network.process_enable(.{ .replica = replica_index }); + cluster.replica_health[replica_index] = .{ .up = .{ .paused = false } }; + cluster.log_replica(.recover, replica_index); + } + + fn replica_open(cluster: *Cluster, replica_index: u8, options: struct { + nonce: u128, + release: vsr.Release, + }) !void { + const release_client_min = for (cluster.options.releases) |release| { + if (release.release.value == options.release.value) { + break release.release_client_min; + } + } else unreachable; + + // Re-initialize the trace to get a clean state. + cluster.replica_tracers[replica_index].deinit(cluster.allocator); + cluster.replica_tracers[replica_index] = try Tracer.init( + cluster.allocator, + cluster.replica_times[replica_index].time(), + .{ .replica = .{ + .cluster = cluster.replicas[replica_index].cluster, + .replica = @intCast(replica_index), + } }, + .{}, + ); + + cluster.aofs[replica_index].reset(); + cluster.aof_ios[replica_index].reset(); + var replica = &cluster.replicas[replica_index]; + try replica.open( + cluster.allocator, + cluster.replica_times[replica_index].time(), + &cluster.storages[replica_index], + &cluster.replica_pools[replica_index], + .{ + .node_count = cluster.options.replica_count + cluster.options.standby_count, + .pipeline_requests_limit = cluster.replica_pipeline_requests_limit, + .aof = &cluster.aofs[replica_index], + .aof_recovery = false, + // TODO Test restarting with a higher storage limit. + .storage_size_limit = cluster.options.storage_size_limit, + .nonce = options.nonce, + .state_machine_options = cluster.options.state_machine, + .message_bus_options = .{ .network = cluster.network }, + .release = options.release, + .release_client_min = release_client_min, + .multiversion = replica_multiversion(replica), + .test_context = cluster, + .tracer = &cluster.replica_tracers[replica_index], + }, + ); + assert(replica.cluster == cluster.options.cluster_id); + assert(replica.replica == replica_index); + assert(replica.replica_count == cluster.replica_count); + assert(replica.standby_count == cluster.standby_count); + + replica.event_callback = on_replica_event; + cluster.network.link(replica.message_bus.process, &replica.message_bus); + } + + fn replica_multiversion(replica_context: *Replica) Multiversion { + const vtable = struct { + fn releases_bundled(context: *anyopaque) vsr.ReleaseList { + const replica: *Replica = @ptrCast(@alignCast(context)); + const cluster: *Cluster = @ptrCast(@alignCast(replica.test_context.?)); + return cluster.replica_releases_bundled[replica.replica]; + } + fn release_execute(context: *anyopaque, release_next: vsr.Release) void { + const replica: *Replica = @ptrCast(@alignCast(context)); + const cluster: *Cluster = @ptrCast(@alignCast(replica.test_context.?)); + cluster.replica_release_execute_soon(replica, release_next); + } + fn tick(_: *anyopaque) void {} + }; + + return .{ + .context = replica_context, + .vtable = &.{ + .releases_bundled = vtable.releases_bundled, + .release_execute = vtable.release_execute, + .tick = vtable.tick, + }, + }; + } + + fn replica_release_execute_soon( + cluster: *Cluster, + replica: *Replica, + release: vsr.Release, + ) void { + assert(replica.release.value != release.value); + assert(cluster.replica_upgrades[replica.replica] == null); + + log.debug("{}: release_execute_soon: release={}..{}", .{ + replica.replica, + replica.release, + release, + }); + + if (cluster.replica_health[replica.replica] == .up) { + // The replica is trying to upgrade to a newer release at runtime. + assert(replica.journal.status != .init); + assert(replica.release.value < release.value); + } else { + assert(replica.journal.status == .init); + maybe(replica.release.value < release.value); + } + + cluster.storages[replica.replica].reset(); + cluster.replica_upgrades[replica.replica] = release; + } + + /// `replica_upgrades` defers upgrades to the next tick (rather than executing it + /// immediately in replica_release_execute_soon()). Since we don't actually exec() to a new + /// version, this allows the replica to clean up properly (e.g. release Message's via + /// `defer`). + fn replica_release_execute(cluster: *Cluster, replica_index: u8) void { + const replica = &cluster.replicas[replica_index]; + assert(cluster.replica_health[replica_index] == .up); + + const release = cluster.replica_upgrades[replica_index].?; + defer cluster.replica_upgrades[replica_index] = null; + + log.debug("{}: release_execute: release={}..{}", .{ + replica_index, + replica.release, + release, + }); + + cluster.replica_crash(replica_index); + + if (replica.multiversion.releases_bundled().contains(release)) { + // Disable faults while restarting to ensure that the cluster doesn't get stuck due + // to too many replicas in status=recovering_head. + const faulty = cluster.storages[replica_index].faulty; + cluster.storages[replica_index].faulty = false; + defer cluster.storages[replica_index].faulty = faulty; + + cluster.replica_open(replica_index, .{ + .nonce = cluster.replicas[replica_index].nonce + 1, + .release = release, + }) catch |err| { + log.err("{}: release_execute failed: error={}", .{ replica_index, err }); + @panic("release_execute failed"); + }; + cluster.replica_enable(replica_index); + } else { + // The cluster has upgraded to `release`, but this replica does not have that + // release available yet. + log.debug("{}: release_execute: target version not available", .{replica_index}); + assert(cluster.replica_health[replica_index] == .down); + } + } + + pub fn replica_reformat( + cluster: *Cluster, + replica_index: u8, + ) !void { + assert(cluster.reformat_count < cluster.options.reformats_max); + assert(cluster.replica_health[replica_index] == .down); + assert(cluster.replica_reformats[replica_index] == null); + assert(replica_index < cluster.options.replica_count); + + cluster.replica_health[replica_index] = .reformatting; + cluster.log_replica(.reformat, replica_index); + + const storage = &cluster.storages[replica_index]; + const storage_options = storage.options; + storage.deinit(cluster.allocator); + storage.* = try Storage.init(cluster.allocator, storage_options); + + const client_index = cluster.options.client_count + cluster.reformat_count; + cluster.reformat_count += 1; + cluster.replica_reformats[replica_index] = try ReplicaReformat.init( + cluster.allocator, + &cluster.clients[client_index].?, + storage, + .{ + .cluster = cluster.options.cluster_id, + .release = cluster.options.releases[0].release, + .replica = @intCast(replica_index), + .replica_count = cluster.options.replica_count, + .view = null, + }, + ); + cluster.replica_reformats[replica_index].?.start(); + } + + pub fn register(cluster: *Cluster, client_index: usize) void { + const client = &cluster.clients[client_index].?; + client.register(register_callback, undefined); + } + + /// See request_callback(). + fn register_callback( + user_data: u128, + result: *const vsr.RegisterResult, + ) void { + _ = user_data; + _ = result; + } + + pub fn request( + cluster: *Cluster, + client_index: usize, + request_operation: StateMachine.Operation, + request_message: *Message, + request_body_size: usize, + ) void { + assert(cluster.client_eviction_reasons[client_index] == null); + + const client = &cluster.clients[client_index].?; + const message = request_message.build(.request); + + message.header.* = .{ + .release = client.release, + .client = client.id, + .request = 0, // Set by client.raw_request. + .cluster = client.cluster, + .command = .request, + .operation = request_operation.to_vsr(), + .size = @intCast(@sizeOf(vsr.Header) + request_body_size), + .previous_request_latency = cluster.prng.int(u32), + }; + + client.raw_request( + request_callback, + undefined, + message, + ); + assert(message.header.request != 0); + } + + /// The `request_callback` is not used — Cluster uses `Client.on_reply_{context,callback}` + /// instead because: + /// - Cluster needs access to the request + /// - Cluster needs access to the reply message (not just the body) + /// + /// See `on_reply`. + fn request_callback( + user_data: u128, + operation: vsr.Operation, + timestamp: u64, + result: []align(constants.cache_line_size) const u8, + ) void { + _ = user_data; + _ = operation; + _ = timestamp; + _ = result; + } + + fn client_on_reply( + client: *Client, + request_message: *Message.Request, + reply_message: *Message.Reply, + ) void { + const cluster: *Cluster = @ptrCast(@alignCast(client.on_reply_context.?)); + assert(reply_message.header.invalid() == null); + assert(reply_message.header.cluster == cluster.options.cluster_id); + assert(reply_message.header.client == client.id); + assert(reply_message.header.request == request_message.header.request); + assert(reply_message.header.command == .reply); + assert(reply_message.header.operation == request_message.header.operation); + + const client_index = + cluster.client_id_permutation.decode(client.id) - client_id_permutation_shift; + assert(&cluster.clients[client_index].? == client); + assert(cluster.client_eviction_reasons[client_index] == null); + + if (cluster.callbacks.on_client_reply) |on_client_reply| { + on_client_reply(cluster, client_index, request_message, reply_message); + } + } + + fn cluster_on_eviction(cluster: *Cluster, client_id: u128) void { + cluster.state_checker.on_client_eviction(client_id); + } + + fn client_on_eviction(client: *Client, eviction: *const Message.Eviction) void { + const cluster: *Cluster = @ptrCast(@alignCast(client.on_reply_context.?)); + assert(eviction.header.invalid() == null); + assert(eviction.header.cluster == cluster.options.cluster_id); + assert(eviction.header.client == client.id); + assert(eviction.header.command == .eviction); + + const client_index = + cluster.client_id_permutation.decode(client.id) - client_id_permutation_shift; + assert(&cluster.clients[client_index].? == client); + assert(cluster.client_eviction_reasons[client_index] == null); + + cluster.client_eviction_reasons[client_index] = eviction.header.reason; + cluster.network.process_disable(.{ .client = client.id }); + + cluster.client_eviction_requests_cancelled += + @intFromBool(client.request_inflight != null and + client.request_inflight.?.message.header.operation != .register and + client.request_inflight.?.message.header.operation != .noop); + } + + fn on_replica_event(replica: *const Replica, event: vsr.ReplicaEvent) void { + const cluster: *Cluster = @ptrCast(@alignCast(replica.test_context.?)); + assert(cluster.replica_health[replica.replica] == .up); + + switch (event) { + .message_sent => |message| { + cluster.state_checker.on_message(message); + }, + .state_machine_opened => { + cluster.manifest_checker.forest_open(&replica.state_machine.forest); + }, + .committed => |data| { + assert(data.reply.header.client == data.prepare.header.client); + + cluster.log_replica(.commit, replica.replica); + cluster.state_checker.check_state(replica.replica) catch |err| { + fatal(.correctness, "state checker error: {}", .{err}); + }; + + if (cluster.callbacks.on_cluster_reply) |on_cluster_reply| { + const client_index = if (data.prepare.header.client == 0) + null + else + cluster.client_id_permutation.decode(data.prepare.header.client) - + client_id_permutation_shift; + on_cluster_reply(cluster, client_index, data.prepare, data.reply); + } + }, + .compaction_completed => { + cluster.storage_checker.replica_compact(Replica, replica) catch |err| { + fatal(.correctness, "storage checker error: {}", .{err}); + }; + }, + .checkpoint_commenced => { + cluster.log_replica(.checkpoint_commenced, replica.replica); + }, + .checkpoint_completed => { + cluster.log_replica(.checkpoint_completed, replica.replica); + cluster.manifest_checker.forest_checkpoint(&replica.state_machine.forest); + cluster.storage_checker.replica_checkpoint(Replica, replica) catch |err| { + fatal(.correctness, "storage checker error: {}", .{err}); + }; + }, + .sync_stage_changed => switch (replica.syncing) { + .idle => cluster.log_replica(.sync, replica.replica), + .updating_checkpoint => { + cluster.state_checker.check_state(replica.replica) catch |err| { + fatal(.correctness, "state checker error: {}", .{err}); + }; + }, + else => {}, + }, + .client_evicted => |client_id| cluster.cluster_on_eviction(client_id), + } + } + + /// Print an error message and then exit with an exit code. + fn fatal(failure: Failure, comptime fmt_string: []const u8, args: anytype) noreturn { + std.log.scoped(.state_checker).err(fmt_string, args); + std.posix.exit(@intFromEnum(failure)); + } + + /// Print the current state of the cluster, intended for printf debugging. + pub fn log_cluster(cluster: *const Cluster) void { + var replica: u8 = 0; + while (replica < cluster.replicas.len) : (replica += 1) { + cluster.log_replica(.commit, replica); + } + } + + fn log_replica( + cluster: *const Cluster, + event: enum(u8) { + crash = '!', + recover = '^', + reformat = 'X', + commit = ' ', + sync = '$', + checkpoint_commenced = '[', + checkpoint_completed = ']', + }, + replica_index: u8, + ) void { + const replica = &cluster.replicas[replica_index]; + + var statuses: [constants.members_max]u8 = @splat(' '); + statuses[replica_index] = switch (cluster.replica_health[replica_index]) { + .reformatting => ' ', + .down => '#', + .up => switch (replica.status) { + .normal => @as(u8, '.'), + .view_change => @as(u8, 'v'), + .recovering => @as(u8, 'r'), + .recovering_head => @as(u8, 'h'), + }, + }; + + const role: u8 = role: { + if (cluster.replica_health[replica_index] == .down) break :role '#'; + if (cluster.replica_health[replica_index] == .reformatting) break :role 'F'; + if (replica.syncing != .idle) break :role '~'; + if (replica.standby()) break :role '|'; + if (replica.primary_index(replica.view) == replica.replica) break :role '/'; + break :role '\\'; + }; + + var info_buffer: [128]u8 = undefined; + var info: []u8 = ""; + var pipeline_buffer: [16]u8 = undefined; + var pipeline: []u8 = ""; + + if (cluster.replica_health[replica_index] == .up) { + var journal_op_min: u64 = std.math.maxInt(u64); + var journal_op_max: u64 = 0; + if (replica.journal.status == .init) { + // `journal.headers` is junk data when we are upgrading from Replica.open(). + assert(event == .recover); + assert(cluster.replica_upgrades[replica_index] != null); + journal_op_min = 0; + } else { + for (replica.journal.headers) |*header| { + if (header.operation != .reserved) { + if (journal_op_min > header.op) journal_op_min = header.op; + if (journal_op_max < header.op) journal_op_max = header.op; + } + } + } + + var wal_op_min: u64 = std.math.maxInt(u64); + var wal_op_max: u64 = 0; + for (cluster.storages[replica_index].wal_prepares()) |*prepare| { + if (prepare.header.valid_checksum() and + prepare.header.command == .prepare) + { + if (wal_op_min > prepare.header.op) wal_op_min = prepare.header.op; + if (wal_op_max < prepare.header.op) wal_op_max = prepare.header.op; + } + } + + info = std.fmt.bufPrint(&info_buffer, "" ++ + "{[view]:>4}V " ++ + "{[op_checkpoint]:>3}/{[commit_min]:_>3}/{[commit_max]:_>3}C " ++ + "{[journal_op_min]:>3}:{[journal_op_max]:_>3}Jo " ++ + "{[journal_faulty]:>2}/{[journal_dirty]:_>2}J! " ++ + "{[wal_op_min]:>3}:{[wal_op_max]:_>3}Wo " ++ + "<{[sync_op_min]:_>3}:{[sync_op_max]:_>3}> " ++ + "v{[release]}:{[release_max]} " ++ + "{[grid_blocks_acquired]?:>5}Ga " ++ + "{[grid_blocks_global]:>2}G! " ++ + "{[grid_blocks_repair]:>3}G?", .{ + .view = replica.view, + .op_checkpoint = replica.op_checkpoint(), + .commit_min = replica.commit_min, + .commit_max = replica.commit_max, + .journal_op_min = journal_op_min, + .journal_op_max = journal_op_max, + .journal_dirty = replica.journal.dirty.count, + .journal_faulty = replica.journal.faulty.count, + .wal_op_min = wal_op_min, + .wal_op_max = wal_op_max, + .sync_op_min = replica.superblock.working.vsr_state.sync_op_min, + .sync_op_max = replica.superblock.working.vsr_state.sync_op_max, + .release = replica.release.triple().patch, + .release_max = replica.multiversion.releases_bundled().last().triple().patch, + .grid_blocks_acquired = if (replica.grid.free_set.opened) + replica.grid.free_set.count_acquired() + else + null, + .grid_blocks_global = replica.grid.read_global_queue.count(), + .grid_blocks_repair = replica.grid.blocks_missing.faulty_blocks.count(), + }) catch unreachable; + + if (replica.pipeline == .queue) { + pipeline = std.fmt.bufPrint(&pipeline_buffer, " {:>2}/{}Pp {:>2}/{}Rq", .{ + replica.pipeline.queue.prepare_queue.count, + constants.pipeline_prepare_queue_max, + replica.pipeline.queue.request_queue.count, + constants.pipeline_request_queue_max, + }) catch unreachable; + } + } + + log.info("{[replica]: >2} {[event]c} {[role]c} {[statuses]s}" ++ + " {[info]s}{[pipeline]s}", .{ + .replica = replica.replica, + .event = @intFromEnum(event), + .role = role, + .statuses = statuses[0 .. cluster.replica_count + cluster.standby_count], + .info = info, + .pipeline = pipeline, + }); + } + }; +} diff --git a/ocam/src/testing/cluster/grid_checker.zig b/ocam/src/testing/cluster/grid_checker.zig new file mode 100644 index 00000000..8397b7cc --- /dev/null +++ b/ocam/src/testing/cluster/grid_checker.zig @@ -0,0 +1,53 @@ +const std = @import("std"); +const assert = std.debug.assert; +const vsr = @import("../../vsr.zig"); + +pub const GridChecker = struct { + const Blocks = std.AutoHashMap(struct { + checkpoint_id: u128, + block_address: u64, + checkpoint_durable: bool, + }, u128); + + blocks: Blocks, + + pub fn init(allocator: std.mem.Allocator) GridChecker { + return .{ .blocks = Blocks.init(allocator) }; + } + + pub fn deinit(checker: *GridChecker) void { + checker.blocks.deinit(); + } + + pub fn assert_coherent( + checker: *GridChecker, + checkpoint: *vsr.CheckpointState, + checkpoint_durable: bool, + block_address: u64, + block_checksum: u128, + ) void { + const result = checker.blocks.getOrPut(.{ + .checkpoint_id = vsr.checksum(std.mem.asBytes(checkpoint)), + .block_address = block_address, + .checkpoint_durable = checkpoint_durable, + }) catch unreachable; + + if (result.found_existing) { + assert(result.value_ptr.* == block_checksum); + } else { + result.value_ptr.* = block_checksum; + } + + // Assert that the same version of the block must exist while the current checkpoint is + // not durable and while the previous checkpoint is durable. + if (!checkpoint_durable) { + if (checker.blocks.get(.{ + .checkpoint_id = checkpoint.parent_checkpoint_id, + .block_address = block_address, + .checkpoint_durable = true, + })) |checksum| { + assert(checksum == block_checksum); + } + } + } +}; diff --git a/ocam/src/testing/cluster/journal_checker.zig b/ocam/src/testing/cluster/journal_checker.zig new file mode 100644 index 00000000..6fce75bd --- /dev/null +++ b/ocam/src/testing/cluster/journal_checker.zig @@ -0,0 +1,61 @@ +//! Verify Journal/WAL properties. +const std = @import("std"); +const assert = std.debug.assert; +const log = std.log.scoped(.journal_checker); + +const constants = @import("../../constants.zig"); +const stdx = @import("stdx"); +const vsr = @import("../../vsr.zig"); +const TestStorage = @import("../storage.zig").Storage; + +pub fn JournalCheckerType(comptime Replica: type) type { + return struct { + pub fn check(replica: *const Replica) void { + const replica_index = replica.replica; + const replica_storage = replica.superblock.storage; + comptime assert(@TypeOf(replica_storage) == *TestStorage); + + if (replica.journal.writes.executing() == 0) { + // Sanity-check: Where the journal is clean and not being written to: + // - Redundant headers exactly match their corresponding prepares. + // - The WAL's content matches `journal.headers`. + // - There are no zeroed entries (representing faulty journal entries) in the + // redundant WAL headers. + var wal_header_errors: u32 = 0; + for ( + replica_storage.wal_headers(), + replica_storage.wal_prepares(), + replica.journal.headers, + 0.., + ) |*wal_header, *wal_prepare, *journal_header, slot| { + if (!replica.journal.dirty.bit(.{ .index = slot })) { + if (journal_header.operation == .reserved) { + // Ignore reserved headers -- when Journal.remove_entries_from() + // truncates the log, it cleans the in-memory journal without writing to + // the WAL. + } else { + if (wal_header.checksum == 0) { + log.err("{}: check: slot={} checksum=0", .{ replica_index, slot }); + wal_header_errors += 1; + } else { + assert(wal_header.checksum == wal_prepare.header.checksum); + assert(wal_header.checksum == journal_header.checksum); + } + } + } + } + assert(wal_header_errors == 0); + + // Verify that prepares' trailing sector padding is zeroed. + for (0..constants.journal_slot_count) |slot| { + const prepare = + replica_storage.area_memory(.{ .wal_prepares = .{ .slot = slot } }); + const header = + std.mem.bytesAsValue(vsr.Header, prepare[0..@sizeOf(vsr.Header)]); + const prepare_padding = prepare[header.size..vsr.sector_ceil(header.size)]; + assert(stdx.zeroed(prepare_padding)); + } + } + } + }; +} diff --git a/ocam/src/testing/cluster/manifest_checker.zig b/ocam/src/testing/cluster/manifest_checker.zig new file mode 100644 index 00000000..8d0e60ae --- /dev/null +++ b/ocam/src/testing/cluster/manifest_checker.zig @@ -0,0 +1,76 @@ +//! Verify that the ManifestLevels tables are constructed consistently across replicas and after +//! recovering from a restart. +const std = @import("std"); +const assert = std.debug.assert; + +const vsr = @import("../../vsr.zig"); +const constants = @import("../../constants.zig"); + +pub fn ManifestCheckerType(comptime Forest: type) type { + return struct { + const ManifestChecker = @This(); + + /// Maps checkpoint op to the cumulative checksum of all trees/levels checksum. + const Checkpoints = std.AutoHashMap(u64, u128); + + checkpoints: Checkpoints, + + pub fn init(allocator: std.mem.Allocator) ManifestChecker { + return .{ .checkpoints = Checkpoints.init(allocator) }; + } + + pub fn deinit(checker: *ManifestChecker) void { + checker.checkpoints.deinit(); + } + + pub fn forest_open(checker: *ManifestChecker, forest: *const Forest) void { + checker.check(forest); + } + + pub fn forest_checkpoint(checker: *ManifestChecker, forest: *const Forest) void { + checker.check(forest); + } + + fn check(checker: *ManifestChecker, forest: *const Forest) void { + assert(forest.grid.superblock.opened); + assert(forest.manifest_log.opened); + + const checkpoint_op = forest.grid.superblock.working.vsr_state.checkpoint.header.op; + const checksum_stored = checker.checkpoints.getOrPut(checkpoint_op) catch @panic("oom"); + const checksum_current = manifest_levels_checksum(forest); + + // On open, we will usually have already have a checksum to compare against from a prior + // checkpoint. But not always: it is possible that we are recovering from a checkpoint + // that wrote e.g. 3/4 superblock copies and then crashed. + if (checksum_stored.found_existing) { + assert(checksum_stored.value_ptr.* == checksum_current); + } else { + checksum_stored.value_ptr.* = checksum_current; + } + } + + fn manifest_levels_checksum(forest: *const Forest) u128 { + var checksum_stream = vsr.ChecksumStream.init(); + for (0..constants.lsm_levels) |level| { + checksum_stream.add(std.mem.asBytes(&level)); + + inline for (Forest.tree_id_range.min..Forest.tree_id_range.max + 1) |tree_id_u16| { + const tree_id: Forest.TreeID = @enumFromInt(tree_id_u16); + const tree_level = forest.tree_for_id_const(tree_id).manifest.levels[level]; + var tree_tables = tree_level.tables.iterator_from_index(0, .ascending); + + checksum_stream.add(std.mem.asBytes(&tree_id)); + checksum_stream.add(std.mem.asBytes(&tree_level.table_count_visible)); + while (tree_tables.next()) |tree_table| { + checksum_stream.add(std.mem.asBytes(&tree_table.encode(.{ + .tree_id = tree_id_u16, + .event = .insert, // (Placeholder event). + .level = @intCast(level), + }))); + } + } + } + return checksum_stream.checksum(); + } + }; +} diff --git a/ocam/src/testing/cluster/message_bus.zig b/ocam/src/testing/cluster/message_bus.zig new file mode 100644 index 00000000..f644ef84 --- /dev/null +++ b/ocam/src/testing/cluster/message_bus.zig @@ -0,0 +1,110 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const MessagePool = @import("../../message_pool.zig").MessagePool; +const Message = MessagePool.Message; +const MessageBuffer = @import("../../message_buffer.zig").MessageBuffer; +const vsr = @import("../../vsr.zig"); +const ProcessType = vsr.ProcessType; + +const Network = @import("network.zig").Network; + +pub const Process = union(ProcessType) { + replica: u8, + client: u128, +}; + +pub const MessageBus = struct { + network: *Network, + pool: *MessagePool, + + process: Process, + + buffer: ?MessageBuffer, + suspended: bool = false, + resume_scheduled: bool = false, + /// The callback to be called when a message is received. + on_messages_callback: *const fn (message_bus: *MessageBus, buffer: *MessageBuffer) void, + + pub const Options = struct { + network: *Network, + }; + + pub fn init( + _: std.mem.Allocator, + process: Process, + message_pool: *MessagePool, + on_messages_callback: *const fn (message_bus: *MessageBus, buffer: *MessageBuffer) void, + options: Options, + ) !MessageBus { + return MessageBus{ + .network = options.network, + .pool = message_pool, + .process = process, + .buffer = MessageBuffer.init(message_pool), + .on_messages_callback = on_messages_callback, + }; + } + + pub fn deinit(bus: *MessageBus, _: std.mem.Allocator) void { + bus.buffer.?.deinit(bus.pool); + bus.buffer = null; + bus.resume_scheduled = false; + // NB: Network keeps a reference to a message bus even when a replica is de-initialized, + // so we don't assign bus.* to undefined here. + } + + pub fn trace_gauge(_: *MessageBus) void {} + + pub fn listen(_: *MessageBus) !void {} + + pub fn tick(_: *MessageBus) void {} + + pub fn tick_client(bus: *MessageBus) void { + bus.tick(); + } + + pub fn get_message( + bus: *MessageBus, + comptime command: ?vsr.Command, + ) MessagePool.GetMessageType(command) { + return bus.pool.get_message(command); + } + + /// `@TypeOf(message)` is one of: + /// - `*Message` + /// - `MessageType(command)` for any `command`. + pub fn unref(bus: *MessageBus, message: anytype) void { + bus.pool.unref(message); + } + + pub fn resume_needed(bus: *MessageBus) bool { + return bus.suspended; + } + + pub fn resume_receive(bus: *MessageBus) void { + bus.suspended = false; + bus.resume_scheduled = true; + } + + pub fn send_message_to_replica(bus: *MessageBus, replica: u8, message: *Message) void { + // Messages sent by a process to itself should never be passed to the message bus + if (bus.process == .replica) assert(replica != bus.process.replica); + + bus.network.send_message(message, .{ + .source = bus.process, + .target = .{ .replica = replica }, + }); + } + + /// Try to send the message to the client with the given id. + /// If the client is not currently connected, the message is silently dropped. + pub fn send_message_to_client(bus: *MessageBus, client_id: u128, message: *Message) void { + assert(bus.process == .replica); + + bus.network.send_message(message, .{ + .source = bus.process, + .target = .{ .client = client_id }, + }); + } +}; diff --git a/ocam/src/testing/cluster/network.zig b/ocam/src/testing/cluster/network.zig new file mode 100644 index 00000000..21470f57 --- /dev/null +++ b/ocam/src/testing/cluster/network.zig @@ -0,0 +1,412 @@ +const std = @import("std"); +const mem = std.mem; +const assert = std.debug.assert; +const maybe = stdx.maybe; + +const constants = @import("../../constants.zig"); +const vsr = @import("../../vsr.zig"); +const stdx = @import("stdx"); +const Ratio = stdx.PRNG.Ratio; + +const MessagePool = @import("../../message_pool.zig").MessagePool; +const Message = MessagePool.Message; + +const MessageBus = @import("message_bus.zig").MessageBus; +const Process = @import("message_bus.zig").Process; + +const PacketSimulatorType = @import("../packet_simulator.zig").PacketSimulatorType; +const PacketSimulatorOptions = @import("../packet_simulator.zig").PacketSimulatorOptions; +const PacketSimulatorPath = @import("../packet_simulator.zig").Path; + +const log = std.log.scoped(.network); + +pub const NetworkOptions = PacketSimulatorOptions; +pub const LinkFilter = @import("../packet_simulator.zig").LinkFilter; + +pub const Network = struct { + const PacketSimulator = PacketSimulatorType(*Message); + + pub const Path = struct { + source: Process, + target: Process, + }; + + /// Core is a strongly-connected component of replicas containing a view change quorum. + /// It is used to define and check liveness --- if a core exists, it should converge + /// to normal status in a bounded number of ticks. + /// + /// At the moment, we require core members to have direct bidirectional connectivity, but this + /// could be relaxed in the future to indirect connectivity. + pub const Core = stdx.BitSetType(constants.members_max); + + allocator: std.mem.Allocator, + + options: NetworkOptions, + packet_simulator: PacketSimulator, + + buses: std.ArrayListUnmanaged(*MessageBus), + buses_enabled: std.ArrayListUnmanaged(bool), + processes: std.ArrayListUnmanaged(u128), + /// A pool of messages that are in the network (sent, but not yet delivered). + message_pool: MessagePool, + message_summary: MessageSummary, + + pub fn init( + allocator: std.mem.Allocator, + options: NetworkOptions, + ) !Network { + const process_count = options.client_count + options.node_count; + + var buses = try std.ArrayListUnmanaged(*MessageBus).initCapacity(allocator, process_count); + errdefer buses.deinit(allocator); + + var buses_enabled = try std.ArrayListUnmanaged(bool).initCapacity(allocator, process_count); + errdefer buses_enabled.deinit(allocator); + + var processes = try std.ArrayListUnmanaged(u128).initCapacity(allocator, process_count); + errdefer processes.deinit(allocator); + + var packet_simulator = try PacketSimulator.init(allocator, options, .{ + .packet_command = &packet_command, + .packet_clone = &packet_clone, + .packet_deinit = &packet_deinit, + .packet_deliver = &packet_deliver, + }); + errdefer packet_simulator.deinit(allocator); + + // Count: + // - replica → replica paths (excluding self-loops) + // - replica → client paths + // - client → replica paths + // but not client→client paths; clients never message one another. + const node_count: u32 = options.node_count; + const client_count: u32 = options.client_count; + const path_count: u32 = node_count * (node_count - 1) + 2 * node_count * client_count; + const message_pool = try MessagePool.init_capacity( + allocator, + // +1 so we can allocate an extra packet when all packet queues are at capacity, + // so that `PacketSimulator.submit_packet` can choose which packet to drop. + 1 + options.path_maximum_capacity * path_count + options.recorded_count_max, + ); + errdefer message_pool.deinit(allocator); + + return Network{ + .allocator = allocator, + .options = options, + .packet_simulator = packet_simulator, + .buses = buses, + .buses_enabled = buses_enabled, + .processes = processes, + .message_pool = message_pool, + .message_summary = .{}, + }; + } + + pub fn deinit(network: *Network) void { + network.buses.deinit(network.allocator); + network.buses_enabled.deinit(network.allocator); + network.processes.deinit(network.allocator); + network.packet_simulator.deinit(network.allocator); + network.message_pool.deinit(network.allocator); + } + + pub fn step(network: *Network) bool { + var advanced = false; + for (network.buses.items) |bus| { + if (bus.resume_scheduled) { + bus.resume_scheduled = false; + bus.on_messages_callback(bus, &bus.buffer.?); + if (bus.buffer.?.has_message()) { + bus.suspended = true; + } + advanced = true; + } + } + return network.packet_simulator.step() or advanced; + } + + pub fn tick(network: *Network) void { + network.packet_simulator.tick(); + } + + pub fn transition_to_liveness_mode(network: *Network, core: Core) void { + assert(core.count() > 0); + + network.packet_simulator.options.one_way_delay_min = .ms(1); + network.packet_simulator.options.one_way_delay_mean = .ms(1); + network.packet_simulator.options.packet_loss_probability = Ratio.zero(); + network.packet_simulator.options.packet_replay_probability = Ratio.zero(); + network.packet_simulator.options.partition_probability = Ratio.zero(); + network.packet_simulator.options.unpartition_probability = Ratio.zero(); + + var it_source = core.iterate(); + while (it_source.next()) |replica_source| { + var it_target = core.iterate(); + while (it_target.next()) |replica_target| { + if (replica_target != replica_source) { + const path = Path{ + .source = .{ .replica = @intCast(replica_source) }, + .target = .{ .replica = @intCast(replica_target) }, + }; + + // The Simulator doesn't use link_drop_packet_fn(), and replica_test.zig doesn't + // use transition_to_liveness_mode(). + assert(network.link_drop_packet_fn(path).* == null); + network.link_filter(path).* = LinkFilter.initFull(); + } + } + } + } + + pub fn link(network: *Network, process: Process, message_bus: *MessageBus) void { + const raw_process = switch (process) { + .replica => |replica| replica, + .client => |client| blk: { + assert(client >= constants.members_max); + break :blk client; + }, + }; + + for (network.processes.items, 0..) |existing_process, i| { + if (existing_process == raw_process) { + network.buses.items[i] = message_bus; + break; + } + } else { + // PacketSimulator assumes that replicas go first. + switch (process) { + .replica => assert(network.processes.items.len < network.options.node_count), + .client => assert(network.processes.items.len >= network.options.node_count), + } + network.processes.appendAssumeCapacity(raw_process); + network.buses.appendAssumeCapacity(message_bus); + network.buses_enabled.appendAssumeCapacity(true); + } + assert(network.processes.items.len == network.buses.items.len); + } + + pub fn process_enable(network: *Network, process: Process) void { + assert(!network.buses_enabled.items[network.process_to_address(process)]); + network.buses_enabled.items[network.process_to_address(process)] = true; + } + + pub fn process_disable(network: *Network, process: Process) void { + assert(network.buses_enabled.items[network.process_to_address(process)]); + network.buses_enabled.items[network.process_to_address(process)] = false; + } + + pub fn link_clear(network: *Network, path: Path) void { + network.packet_simulator.link_clear(.{ + .source = network.process_to_address(path.source), + .target = network.process_to_address(path.target), + }); + } + + pub fn link_filter(network: *Network, path: Path) *LinkFilter { + return network.packet_simulator.link_filter(.{ + .source = network.process_to_address(path.source), + .target = network.process_to_address(path.target), + }); + } + + pub fn link_drop_packet_fn(network: *Network, path: Path) *?PacketSimulator.LinkDropPacketFn { + return network.packet_simulator.link_drop_packet_fn(.{ + .source = network.process_to_address(path.source), + .target = network.process_to_address(path.target), + }); + } + + pub fn link_record(network: *Network, path: Path) *LinkFilter { + return network.packet_simulator.link_record(.{ + .source = network.process_to_address(path.source), + .target = network.process_to_address(path.target), + }); + } + + pub fn replay_recorded(network: *Network) void { + return network.packet_simulator.replay_recorded(); + } + + pub fn send_message(network: *Network, message: *Message, path: Path) void { + network.message_summary.add(message.header); + log.debug("send_message: {} > {}: {}", .{ + path.source, + path.target, + message.header.command, + }); + + switch (message.header.peer_type()) { + .unknown => {}, + .client_likely => |client_id| { + // Requests may be forwarded by replicas, but peer_type always returns client ID, + // as it is useful for the production MessageBus. Specifically, a replica that + // receives a request from a client can immediately cache the connection in the + // client map, instead of waiting for an infrequent PingClient message to do so. + assert(message.header.command == .request); + if (path.source == .client) assert(path.source.client == client_id); + }, + .client => |client_id| assert(std.meta.eql(path.source, .{ .client = client_id })), + .replica => |index| assert(std.meta.eql(path.source, .{ .replica = index })), + } + + const network_message = network.message_pool.get_message(null); + defer network.message_pool.unref(network_message); + + stdx.copy_disjoint(.exact, u8, network_message.buffer, message.buffer); + + network.packet_simulator.submit_packet( + network_message.ref(), + .{ + .source = network.process_to_address(path.source), + .target = network.process_to_address(path.target), + }, + ); + } + + fn process_to_address(network: *const Network, process: Process) u8 { + for (network.processes.items, 0..) |p, i| { + if (std.meta.eql(raw_process_to_process(p), process)) { + switch (process) { + .replica => assert(i < network.options.node_count), + .client => assert(i >= network.options.node_count), + } + return @intCast(i); + } + } + log.err("no such process: {} (have {any})", .{ process, network.processes.items }); + unreachable; + } + + pub fn get_message_bus(network: *Network, process: Process) *MessageBus { + return network.buses.items[network.process_to_address(process)]; + } + + fn packet_command(_: *PacketSimulator, message: *Message) vsr.Command { + return message.header.command; + } + + fn packet_clone(_: *PacketSimulator, message: *Message) *Message { + return message.ref(); + } + + fn packet_deinit(packet_simulator: *PacketSimulator, message: *Message) void { + const network: *Network = @fieldParentPtr("packet_simulator", packet_simulator); + network.message_pool.unref(message); + } + + fn packet_deliver( + packet_simulator: *PacketSimulator, + message: *Message, + path: PacketSimulatorPath, + ) void { + const network: *Network = @fieldParentPtr("packet_simulator", packet_simulator); + const process_path = .{ + .source = raw_process_to_process(network.processes.items[path.source]), + .target = raw_process_to_process(network.processes.items[path.target]), + }; + + if (!network.buses_enabled.items[path.target]) { + log.debug("deliver_message: {} > {}: {} (dropped; target is down)", .{ + process_path.source, + process_path.target, + message.header.command, + }); + return; + } + + log.debug("deliver_message: {} > {}: {}", .{ + process_path.source, + process_path.target, + message.header.command, + }); + + const target_bus = network.buses.items[path.target]; + assert(target_bus.buffer != null); + + if (target_bus.buffer.?.receive_size + message.header.size > constants.message_size_max) { + log.debug("deliver_message: {} > {}: {} (dropped; buffer is full)", .{ + process_path.source, + process_path.target, + message.header.command, + }); + return; + } + + stdx.copy_disjoint( + .inexact, + u8, + target_bus.buffer.?.recv_slice(), + message.buffer[0..message.header.size], + ); + target_bus.buffer.?.recv_advance(message.header.size); + target_bus.on_messages_callback(target_bus, &target_bus.buffer.?); + assert(target_bus.buffer != null); + assert(target_bus.buffer.?.invalid == null); + maybe(target_bus.buffer.?.receive_size > 0); + maybe(target_bus.buffer.?.process_size > 0); + if (target_bus.buffer.?.has_message()) { + target_bus.suspended = true; + } + } + + fn raw_process_to_process(raw: u128) Process { + switch (raw) { + 0...(constants.members_max - 1) => return .{ .replica = @intCast(raw) }, + else => { + assert(raw >= constants.members_max); + return .{ .client = raw }; + }, + } + } +}; + +pub const MessageSummary = struct { + map: Map = Map.initFill(.{ .count = 0, .size = 0 }), + + const Map = std.EnumArray(vsr.Command, struct { count: u32, size: u64 }); + + pub fn add(summary: *MessageSummary, header: *const vsr.Header) void { + const entry = summary.map.getPtr(header.command); + entry.count += 1; + entry.size += header.size; + } + + pub fn format( + summary: MessageSummary, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + + const slice = comptime std.enums.values(vsr.Command); + var commands = slice[0..slice.len].*; + std.mem.sort(vsr.Command, &commands, summary.map, greater_than); + + var total_count: u32 = 0; + var total_size: u64 = 0; + + for (commands) |command| { + const message_summary = summary.map.get(command); + total_count += message_summary.count; + total_size += message_summary.size; + if (message_summary.count > 0) { + try writer.print("{s:<24} {d:>7} {:>10.2}\n", .{ + @tagName(command), + message_summary.count, + std.fmt.fmtIntSizeBin(message_summary.size), + }); + } + } + try writer.print("{s:<24} {d:>7} {:>10.2}\n", .{ + "total", + total_count, + std.fmt.fmtIntSizeBin(total_size), + }); + } + + fn greater_than(map: Map, lhs: vsr.Command, rhs: vsr.Command) bool { + return map.get(lhs).count > map.get(rhs).count; + } +}; diff --git a/ocam/src/testing/cluster/state_checker.zig b/ocam/src/testing/cluster/state_checker.zig new file mode 100644 index 00000000..6d5133ac --- /dev/null +++ b/ocam/src/testing/cluster/state_checker.zig @@ -0,0 +1,331 @@ +const std = @import("std"); +const assert = std.debug.assert; +const mem = std.mem; + +const constants = @import("../../constants.zig"); +const vsr = @import("../../vsr.zig"); +const stdx = @import("stdx"); +const maybe = stdx.maybe; + +const message_pool = @import("../../message_pool.zig"); +const MessagePool = message_pool.MessagePool; +const Message = MessagePool.Message; + +const ReplicaSet = stdx.BitSetType(constants.members_max); +const Commits = std.ArrayList(struct { + header: vsr.Header.Prepare, + // null for operation=root and operation=upgrade + release: ?vsr.Release, + replicas: ReplicaSet = .{}, +}); + +const ReplicaHead = struct { + view: u32, + op: u64, +}; + +pub fn StateCheckerType(comptime Client: type, comptime Replica: type) type { + return struct { + const StateChecker = @This(); + + node_count: u8, + replica_count: u8, + + commits: Commits, + commit_mins: [constants.members_max]u64 = @splat(0), + + replicas: []const Replica, + clients: []const ?Client, + /// Tracks the latest reply for every non-evicted client. + client_replies: std.AutoArrayHashMapUnmanaged(u128, vsr.Header.Reply), + clients_exhaustive: bool = true, + clients_register_op_latest: u64 = 0, + + /// The number of times the canonical state has been advanced. + requests_committed: u64 = 0, + + /// Tracks the latest op acked by a replica across restarts. + replica_head_max: []ReplicaHead, + + pub fn init(allocator: mem.Allocator, options: struct { + cluster_id: u128, + replica_count: u8, + replicas: []const Replica, + clients: []const ?Client, + }) !StateChecker { + const root_prepare = vsr.Header.Prepare.root(options.cluster_id); + + var commits = Commits.init(allocator); + errdefer commits.deinit(); + + var commit_replicas: ReplicaSet = .{}; + for (options.replicas, 0..) |_, i| commit_replicas.set(i); + try commits.append(.{ + .header = root_prepare, + .release = null, + .replicas = commit_replicas, + }); + + var client_replies: std.AutoArrayHashMapUnmanaged(u128, vsr.Header.Reply) = .{}; + try client_replies.ensureTotalCapacity(allocator, constants.clients_max); + errdefer client_replies.deinit(allocator); + + const replica_head_max = try allocator.alloc(ReplicaHead, options.replicas.len); + errdefer allocator.free(replica_head_max); + for (replica_head_max) |*head| head.* = .{ .view = 0, .op = 0 }; + + return StateChecker{ + .node_count = @intCast(options.replicas.len), + .replica_count = options.replica_count, + .commits = commits, + .replicas = options.replicas, + .clients = options.clients, + .client_replies = client_replies, + .replica_head_max = replica_head_max, + }; + } + + pub fn deinit(state_checker: *StateChecker) void { + const allocator = state_checker.commits.allocator; + + allocator.free(state_checker.replica_head_max); + state_checker.client_replies.deinit(allocator); + state_checker.commits.deinit(); + } + + pub fn on_client_eviction(state_checker: *StateChecker, client_id: u128) void { + const removed = state_checker.client_replies.swapRemove(client_id); + maybe(removed); + // Disable checking of `Client.request_inflight`, to guard against the following panic: + // 1. Client `A` sends an `operation=register` to a fresh cluster. (`A₁`) + // 2. Cluster prepares + commits `A₁`, and sends the reply to `A`. + // 4. `A` receives the reply to `A₁`, and issues a second request (`A₂`). + // 5. `clients_max` other clients register, evicting `A`'s session. + // 6. An old retry (or replay) of `A₁` arrives at the cluster. + // 7. `A₁` is committed (for a second time, as a different op). + // If `StateChecker` were to check `Client.request_inflight`, it would see that `A₁` + // is not actually in-flight, despite being committed for the "first time" by a + // replica. + state_checker.clients_exhaustive = false; + } + + pub fn on_message(state_checker: *StateChecker, message: *const Message) void { + switch (message.header.into_any()) { + .prepare_ok => |header| { + const head = &state_checker.replica_head_max[header.replica]; + if (header.view > head.view or + (header.view == head.view and header.op > head.op)) + { + head.view = header.view; + head.op = header.op; + } + }, + .reply => |header| { + if (header.operation == .register and + header.op > state_checker.clients_register_op_latest) + { + state_checker.client_replies + .putAssumeCapacityNoClobber(header.client, header.*); + state_checker.clients_register_op_latest = header.op; + } else { + if (state_checker.client_replies.getEntry(header.client)) |entry| { + if (entry.value_ptr.op < header.op) { + entry.value_ptr.* = header.*; + } else { + // An old message is replayed. + } + } else { + // Client was evicted, an old message is replayed. + } + } + }, + else => {}, + } + } + + /// Verify that the cluster has advanced since the replica was lost. + /// Then forget about the given replica's progress, since its data file has been "lost". + pub fn reformat(state_checker: *StateChecker, replica_index: u8) void { + const reformat_state = state_checker.replica_head_max[replica_index]; + var commit_advanced: bool = false; + for ( + state_checker.commit_mins[0..state_checker.replica_head_max.len], + 0.., + ) |commit_min, i| { + if (i != replica_index) { + commit_advanced = commit_advanced or reformat_state.op < commit_min; + } + } + assert(commit_advanced); + + state_checker.replica_head_max[replica_index] = .{ .view = 0, .op = 0 }; + state_checker.commit_mins[replica_index] = 0; + } + + /// Returns whether the replica's state changed since the last check_state(). + pub fn check_state(state_checker: *StateChecker, replica_index: u8) !void { + const replica = &state_checker.replicas[replica_index]; + if (replica.syncing == .updating_checkpoint) { + // Allow a syncing replica to fast-forward its commit. + // + // But "fast-forwarding" may actually move commit_min slightly backwards: + // 1. Suppose op X is a checkpoint trigger. + // 2. We are committing op X-1 but are stuck due to a block that does not exist in + // the cluster anymore. + // 3. When we sync, `commit_min` "backtracks", to `X - lsm_compaction_ops`. + const commit_min_source = state_checker.commit_mins[replica_index]; + const commit_min_target = + replica.syncing.updating_checkpoint.header.op; + assert(commit_min_source <= commit_min_target + constants.lsm_compaction_ops); + state_checker.commit_mins[replica_index] = commit_min_target; + return; + } + + assert(replica.view >= state_checker.replica_head_max[replica_index].view); + + const commit_root_op = replica.superblock.working.vsr_state.checkpoint.header.op; + const commit_root = replica.superblock.working.vsr_state.checkpoint.header.checksum; + + const commit_a = state_checker.commit_mins[replica_index]; + const commit_b = replica.commit_min; + + const header_b = replica.journal.header_with_op(replica.commit_min); + + if (header_b == null and replica.commit_min != replica.op_checkpoint()) { + // The slot with commit_min may have been overwritten by an op from the next wrap. + // Further, the op may then also be truncated as part of a view change. + if (replica.journal.header_for_op(replica.commit_min)) |header| { + assert(header.op == replica.commit_min + constants.journal_slot_count); + } + return; + } + + if (header_b != null) assert(header_b.?.op == commit_b); + + const checksum_a = state_checker.commits.items[commit_a].header.checksum; + // Even if we have header_b, if its op is commit_root_op, we can't trust it. + // If we just finished state sync, the header in our log might not have been + // committed (it might be left over from before sync). + const checksum_b = if (commit_b == commit_root_op) commit_root else header_b.?.checksum; + + assert(checksum_b != commit_root or + replica.commit_min == replica.superblock.working.vsr_state.checkpoint.header.op); + assert((commit_a == commit_b) == (checksum_a == checksum_b)); + + if (checksum_a == checksum_b) return; + + assert(commit_b < commit_a or commit_a + 1 == commit_b); + state_checker.commit_mins[replica_index] = commit_b; + + // If some other replica has already reached this state, then it will be in the commit + // history: + if (replica.commit_min < state_checker.commits.items.len) { + const commit = &state_checker.commits.items[commit_b]; + if (replica.op_checkpoint() < replica.commit_min) { + if (commit.release) |release| assert(release.value == replica.release.value); + } else { + // When op_checkpoint==commit_min, we recovered from checkpoint, so it is ok if + // the release doesn't match. (commit_min is not actually being executed.) + assert(replica.op_checkpoint() == replica.commit_min); + } + + assert(checksum_b == commit.header.checksum); + commit.replicas.set(replica_index); + + assert(replica.commit_min < state_checker.commits.items.len); + // A replica may transition more than once to the same state, for example, when + // restarting after a crash and replaying the log. The more important invariant is + // that the cluster as a whole may not transition to the same state more than once, + // and once transitioned may not regress. + return; + } + + if (header_b == null) return; + assert(header_b.?.checksum == checksum_b); + assert(header_b.?.parent == checksum_a); + assert(header_b.?.op > 0); + assert(header_b.?.command == .prepare); + assert(header_b.?.operation != .reserved); + + if (header_b.?.client == 0) { + assert(header_b.?.operation == .upgrade or + header_b.?.operation == .pulse); + } else { + if (state_checker.clients_exhaustive) { + // The replica has transitioned to state `b` that is not yet in the commit + // history. Check if this is a valid new state based on the originating client's + // inflight request. + const client: *const Client = for (state_checker.clients) |*client| { + if (client.*.?.id == header_b.?.client) break &client.*.?; + } else unreachable; + + if (client.request_inflight == null) { + return error.ReplicaTransitionedToInvalidState; + } + + const request = client.request_inflight.?.message; + assert(request.header.client == header_b.?.client); + assert(request.header.checksum == header_b.?.request_checksum); + assert(request.header.request == header_b.?.request); + assert(request.header.command == .request); + assert(request.header.operation == header_b.?.operation); + assert(request.header.size == header_b.?.size); + // `checksum_body` will not match; the leader's StateMachine updated the + // timestamps in the prepare body's accounts/transfers. + } else { + // Either: + // - The cluster is running with one or more raw MessageBus "clients", so there + // may be requests not found in `Cluster.clients`. + // - The test includes one or more client evictions. + } + } + + state_checker.requests_committed += 1; + assert(state_checker.requests_committed == header_b.?.op); + + const release = release: { + if (header_b.?.operation == .root or + header_b.?.operation == .upgrade) + { + break :release null; + } else { + break :release replica.release; + } + }; + + assert(state_checker.commits.items.len == header_b.?.op); + state_checker.commits.append(.{ + .header = header_b.?.*, + .release = release, + }) catch unreachable; + state_checker.commits.items[header_b.?.op].replicas.set(replica_index); + } + + pub fn replica_convergence(state_checker: *StateChecker, replica_index: u8) bool { + const a = state_checker.commits.items.len - 1; + const b = state_checker.commit_mins[replica_index]; + return a == b; + } + + pub fn assert_cluster_convergence(state_checker: *StateChecker) void { + for (state_checker.commits.items, 0..) |commit, i| { + assert(commit.replicas.count() > 0); + assert(commit.header.command == .prepare); + assert(commit.header.op == i); + if (i > 0) { + const previous = state_checker.commits.items[i - 1].header; + assert(commit.header.parent == previous.checksum); + assert(commit.header.view >= previous.view); + } + } + } + + pub fn header_with_op(state_checker: *StateChecker, op: u64) vsr.Header.Prepare { + assert(op < state_checker.commits.items.len); + const commit = &state_checker.commits.items[op]; + assert(commit.header.op == op); + assert(commit.replicas.count() > 0); + return commit.header; + } + }; +} diff --git a/ocam/src/testing/cluster/storage_checker.zig b/ocam/src/testing/cluster/storage_checker.zig new file mode 100644 index 00000000..131aa2bf --- /dev/null +++ b/ocam/src/testing/cluster/storage_checker.zig @@ -0,0 +1,505 @@ +//! Verify deterministic storage. +//! +//! At each replica compact and checkpoint, check that storage is byte-for-byte identical across +//! replicas. +//! +//! Areas verified between compaction bars: +//! - Acquired Grid blocks (when ¬syncing) (excluding an open manifest block) +//! +//! Areas verified at checkpoint: +//! - SuperBlock vsr_state.checkpoint +//! - ClientReplies (when repair finishes) +//! - Acquired Grid blocks (when syncing finishes) +//! +//! Areas not verified: +//! - SuperBlock headers, which hold replica-specific state. +//! - WAL headers, which may differ because the WAL writes deliberately corrupt redundant headers +//! to faulty slots to ensure recovery is consistent. +//! - WAL prepares — a replica can commit + checkpoint an op before it is persisted to the WAL. +//! (The primary can commit from the pipeline-queue, backups can commit from the pipeline-cache.) +//! - Non-allocated Grid blocks, which may differ due to state sync. +const std = @import("std"); +const assert = std.debug.assert; +const log = std.log.scoped(.storage_checker); + +const constants = @import("../../constants.zig"); +const stdx = @import("stdx"); +const vsr = @import("../../vsr.zig"); +const schema = @import("../../lsm/schema.zig"); +const Storage = @import("../storage.zig").Storage; + +/// After each compaction bar, save the cumulative hash of all acquired grid blocks. +/// (Excluding the open manifest log block, if any.) +/// +/// This is sparse – not every compaction is necessarily recorded. +/// For example, the StorageChecker will not check the grid if the replica is still state syncing, +/// which may cause a bar to be skipped over. +const Compactions = std.AutoHashMap(u64, u128); + +/// Maps from op_checkpoint to cumulative storage checksum. +/// +/// Not every checkpoint is necessarily recorded — a replica calls on_checkpoint *at most* once. +/// For example, a replica will not call on_checkpoint if it crashes (during a checkpoint) after +/// writing 2 superblock copies. (This could be repeated by other replicas, causing a checkpoint +/// op to be skipped in Checkpoints). +const Checkpoints = std.AutoHashMap(u64, Checkpoint); + +const CheckpointArea = enum { + superblock_checkpoint, + client_replies, + grid, +}; + +const Checkpoint = std.enums.EnumMap(CheckpointArea, u128); + +pub const StorageChecker = struct { + const SuperBlock = vsr.SuperBlockType(Storage); + compactions: Compactions, + checkpoints: Checkpoints, + + free_set: vsr.FreeSet, + free_set_blocks_acquired_encoded: []align(@alignOf(u64)) u8, + free_set_blocks_released_encoded: []align(@alignOf(u64)) u8, + + client_sessions: vsr.ClientSessions, + client_sessions_buffer: []align(@sizeOf(u256)) u8, + + pub fn init(allocator: std.mem.Allocator) !StorageChecker { + var compactions = Compactions.init(allocator); + errdefer compactions.deinit(); + + var checkpoints = Checkpoints.init(allocator); + errdefer checkpoints.deinit(); + + var free_set = try vsr.FreeSet.init( + allocator, + .{ + .grid_size_limit = Storage.grid_blocks_max * constants.block_size, + .blocks_released_prior_checkpoint_durability_max = 0, + }, + ); + errdefer free_set.deinit(allocator); + + var client_sessions = try vsr.ClientSessions.init(allocator); + errdefer client_sessions.deinit(allocator); + + const free_set_size = free_set.encode_size_max(); + + const free_set_blocks_acquired_encoded = + try allocator.alignedAlloc(u8, @alignOf(u64), free_set_size); + errdefer allocator.free(free_set_blocks_acquired_encoded); + + const free_set_blocks_released_encoded = + try allocator.alignedAlloc(u8, @alignOf(u64), free_set_size); + errdefer allocator.free(free_set_blocks_released_encoded); + + const client_sessions_buffer = + try allocator.alignedAlloc(u8, @sizeOf(u256), vsr.ClientSessions.encode_size); + errdefer allocator.free(client_sessions_buffer); + + return StorageChecker{ + .compactions = compactions, + .checkpoints = checkpoints, + .free_set = free_set, + .free_set_blocks_acquired_encoded = free_set_blocks_acquired_encoded, + .free_set_blocks_released_encoded = free_set_blocks_released_encoded, + .client_sessions = client_sessions, + .client_sessions_buffer = client_sessions_buffer, + }; + } + + pub fn deinit(checker: *StorageChecker, allocator: std.mem.Allocator) void { + allocator.free(checker.client_sessions_buffer); + allocator.free(checker.free_set_blocks_acquired_encoded); + allocator.free(checker.free_set_blocks_released_encoded); + checker.client_sessions.deinit(allocator); + checker.free_set.deinit(allocator); + checker.checkpoints.deinit(); + checker.compactions.deinit(); + } + + pub fn replica_compact( + checker: *StorageChecker, + comptime Replica: type, + replica: *const Replica, + ) !void { + const superblock: *const SuperBlock = &replica.superblock; + // If we are recovering from a crash, don't test the checksum until we are caught up. + // Until then our grid's checksum is too far ahead. + if (superblock.working.vsr_state.op_compacted(replica.commit_min)) return; + // If we are syncing, our grid will not be up to date. + if (superblock.working.vsr_state.sync_op_max > 0) return; + + const bar_beat_count = constants.lsm_compaction_ops; + if ((replica.commit_min + 1) % bar_beat_count != 0) return; + + const checksum = checker.checksum_grid( + @TypeOf(replica.state_machine.forest), + &replica.state_machine.forest, + .free_set_from_memory, + ); + log.debug("{?}: replica_compact: op={} area=grid checksum={x:0>32}", .{ + superblock.replica_index, + replica.commit_min, + checksum, + }); + + if (checker.compactions.get(replica.commit_min)) |checksum_expect| { + if (checksum_expect != checksum) { + log.err("{?}: replica_compact: mismatch " ++ + "area=grid expect={x:0>32} actual={x:0>32}", .{ + superblock.replica_index, + checksum_expect, + checksum, + }); + return error.StorageMismatch; + } + } else { + try checker.compactions.putNoClobber(replica.commit_min, checksum); + } + } + + pub fn replica_checkpoint( + checker: *StorageChecker, + comptime Replica: type, + replica: *const Replica, + ) !void { + replica.assert_free_set_consistent(); + + const syncing = replica.superblock.working.vsr_state.sync_op_max > 0; + try checker.check( + "replica_checkpoint", + @TypeOf(replica.state_machine.forest), + &replica.state_machine.forest, + std.enums.EnumSet(CheckpointArea).init(.{ + .superblock_checkpoint = true, + .client_replies = !syncing, + .grid = !syncing, + }), + ); + + if (!syncing) assert(checker.checkpoints.count() > 0); + } + + /// Invoked when both superblock and content sync is complete. + pub fn replica_sync( + checker: *StorageChecker, + comptime Replica: type, + replica: *const Replica, + ) !void { + try checker.check( + "replica_sync", + @TypeOf(replica.state_machine.forest), + &replica.state_machine.forest, + std.enums.EnumSet(CheckpointArea).init(.{ + .superblock_checkpoint = true, + // The replica may have have already committed some additional prepares atop the + // checkpoint, so its client-replies zone will have mutated. + .client_replies = false, + .grid = true, + }), + ); + } + + fn check( + checker: *StorageChecker, + caller: []const u8, + comptime Forest: type, + forest: *const Forest, + areas: std.enums.EnumSet(CheckpointArea), + ) !void { + const superblock: *const SuperBlock = forest.grid.superblock; + const op_checkpoint = superblock.working.vsr_state.checkpoint.header.op; + + const checkpoint_actual = checkpoint: { + var checkpoint = Checkpoint.init(.{ + .superblock_checkpoint = null, + .client_replies = null, + .grid = null, + }); + if (areas.contains(.superblock_checkpoint)) { + checkpoint.put( + .superblock_checkpoint, + vsr.checksum(std.mem.asBytes(&superblock.working.vsr_state.checkpoint)), + ); + } + if (areas.contains(.client_replies)) { + checkpoint.put(.client_replies, checker.checksum_client_replies(superblock)); + } + if (areas.contains(.grid)) { + checkpoint.put(.grid, checker.checksum_grid(Forest, forest, .free_set_from_disk)); + } + break :checkpoint checkpoint; + }; + + for (std.enums.values(CheckpointArea)) |area| { + log.debug("{}: {s}: checkpoint={} area={s} value={?x:0>32}", .{ + superblock.replica_index.?, + caller, + op_checkpoint, + @tagName(area), + checkpoint_actual.get(area), + }); + } + + if (checker.checkpoints.getPtr(op_checkpoint)) |checkpoint_expect| { + var mismatch: bool = false; + for (std.enums.values(CheckpointArea)) |area| { + const checksum_actual = checkpoint_actual.get(area) orelse continue; + if (checkpoint_expect.fetchPut(area, checksum_actual)) |checksum_expect| { + if (checksum_expect != checksum_actual) { + log.warn("{}: {s}: mismatch " ++ + "area={s} expect={x:0>32} actual={x:0>32}", .{ + superblock.replica_index.?, + caller, + @tagName(area), + checksum_expect, + checksum_actual, + }); + + mismatch = true; + } + } + } + if (mismatch) return error.StorageMismatch; + } else { + // This replica is the first to reach op_checkpoint. + // Save its state for other replicas to check themselves against. + try checker.checkpoints.putNoClobber(op_checkpoint, checkpoint_actual); + } + } + + fn checksum_client_replies(checker: *StorageChecker, superblock: *const SuperBlock) u128 { + assert(superblock.working.vsr_state.sync_op_max == 0); + + const client_sessions_size = superblock.working.vsr_state.checkpoint.client_sessions_size; + if (client_sessions_size > 0) { + const checkpoint = &superblock.working.vsr_state.checkpoint; + var client_sessions_block: vsr.BlockReference = .{ + .address = checkpoint.client_sessions_last_block_address, + .checksum = checkpoint.client_sessions_last_block_checksum, + }; + + var client_sessions_cursor: usize = client_sessions_size; + while (true) { + const block = + superblock.storage.grid_block(client_sessions_block.address).?; + assert(schema.header_from_block(block).checksum == client_sessions_block.checksum); + + const block_body = schema.TrailerNode.body(block); + client_sessions_cursor -= block_body.len; + stdx.copy_disjoint( + .inexact, + u8, + checker.client_sessions_buffer[client_sessions_cursor..], + block_body, + ); + + client_sessions_block = schema.TrailerNode.previous(block) orelse break; + } + assert(client_sessions_cursor == 0); + } + assert(vsr.checksum(checker.client_sessions_buffer[0..client_sessions_size]) == + superblock.working.vsr_state.checkpoint.client_sessions_checksum); + + checker.client_sessions.decode(checker.client_sessions_buffer[0..client_sessions_size]); + defer checker.client_sessions.reset(); + + var checksum = vsr.ChecksumStream.init(); + for (checker.client_sessions.entries, 0..) |client_session, slot| { + if (client_session.session == 0) { + // Empty slot. + } else { + assert(client_session.header.command == .reply); + + assert(client_session.header.size >= @sizeOf(vsr.Header)); + if (client_session.header.size == @sizeOf(vsr.Header)) { + // ClientReplies won't store this entry. + } else { + const reply = superblock.storage.area_memory( + .{ .client_replies = .{ .slot = slot } }, + )[0..vsr.sector_ceil(client_session.header.size)]; + + const reply_header = + std.mem.bytesAsValue(vsr.Header, reply[0..@sizeOf(vsr.Header)]); + + assert(reply_header.checksum == client_session.header.checksum); + checksum.add(reply); + } + } + } + return checksum.checksum(); + } + + fn read_free_set_bitset( + checker: *StorageChecker, + superblock: *const SuperBlock, + bitset: vsr.FreeSet.BitsetKind, + ) void { + const free_set_reference = superblock.working.free_set_reference(bitset); + + const free_set_buffer: []align(@alignOf(u64)) u8 = switch (bitset) { + .blocks_acquired => checker.free_set_blocks_acquired_encoded, + .blocks_released => checker.free_set_blocks_released_encoded, + }; + const free_set_size = free_set_reference.trailer_size; + const free_set_checksum = free_set_reference.checksum; + + if (free_set_size > 0) { + // Read free set from the grid by manually following the linked list of blocks. + // Note that free set is written in direct order, and must be read backwards. + var free_set_block: ?vsr.BlockReference = .{ + .address = free_set_reference.last_block_address, + .checksum = free_set_reference.last_block_checksum, + }; + + const free_set_block_count = + stdx.div_ceil(free_set_size, constants.block_size - @sizeOf(vsr.Header)); + + var free_set_cursor: usize = free_set_size; + for (0..free_set_block_count) |_| { + const block = superblock.storage.grid_block(free_set_block.?.address).?; + assert(schema.header_from_block(block).checksum == free_set_block.?.checksum); + + const encoded_words = schema.TrailerNode.body(block); + free_set_cursor -= encoded_words.len; + stdx.copy_disjoint( + .inexact, + u8, + free_set_buffer[free_set_cursor..], + encoded_words, + ); + + free_set_block = schema.TrailerNode.previous(block); + } + assert(free_set_block == null); + assert(free_set_cursor == 0); + } + + assert(vsr.checksum(free_set_buffer[0..free_set_size]) == free_set_checksum); + } + + fn checksum_grid( + checker: *StorageChecker, + comptime Forest: type, + forest: *const Forest, + source: enum { free_set_from_disk, free_set_from_memory }, + ) u128 { + const superblock: *const SuperBlock = forest.grid.superblock; + const manifest_log = &forest.manifest_log; + const free_set = switch (source) { + .free_set_from_memory => forest.grid.free_set, + .free_set_from_disk => blk: { + checker.read_free_set_bitset(superblock, .blocks_acquired); + checker.read_free_set_bitset(superblock, .blocks_released); + const free_set_blocks_acquired_size = + superblock.working.free_set_reference(.blocks_acquired).trailer_size; + const free_set_blocks_released_size = + superblock.working.free_set_reference(.blocks_released).trailer_size; + checker.free_set.decode_chunks( + &.{checker.free_set_blocks_acquired_encoded[0..free_set_blocks_acquired_size]}, + &.{checker.free_set_blocks_released_encoded[0..free_set_blocks_released_size]}, + ); + checker.free_set.opened = true; + break :blk checker.free_set; + }, + }; + defer checker.free_set.reset(); + + var blocks_acquired = free_set.blocks_acquired.iterator(.{}); + var blocks_missing: usize = 0; + + var stream = vsr.ChecksumStream.init(); + + while (blocks_acquired.next()) |block_address_index| { + const block_address: u64 = block_address_index + 1; + + // Calculate the checksum over acquired, unreleased blocks, as the state of released + // blocks is uncertain during state sync. State sync involves syncing the FreeSet + // encoded in a replica's superblock at checkpoint, and the current grid state, both of + // which may not be in sync. Blocks marked released in the FreeSet encoded in the + // superblock are freed at checkpoint durability, and may be overwritten. + if (free_set.is_released(block_address)) continue; + + // The StorageChecker must skip checking open ManifestLog blocks, these have not been + // flushed yet – until they are written, their content in the grid is undefined. + var manifest_log_open_blocks = manifest_log.blocks.iterator(); + while (manifest_log_open_blocks.next()) |open_block| { + const open_block_header = + std.mem.bytesAsValue(vsr.Header.Block, open_block[0..@sizeOf(vsr.Header)]); + assert(open_block_header.address > 0); + if (block_address == open_block_header.address) break; + } else { + const block = blk: { + if (read_block_from_write_queues_by_address( + Forest, + forest, + block_address, + )) |block_write_queue| { + break :blk block_write_queue; + } else if (superblock.storage.grid_block(block_address)) |block_storage| { + break :blk block_storage; + } else { + log.err("{}: checksum_grid: missing block_address={}", .{ + superblock.replica_index.?, + block_address, + }); + + blocks_missing += 1; + continue; + } + }; + + const block_header = schema.header_from_block(block); + assert(block_header.address == block_address); + + stream.add(block[0..block_header.size]); + // Extra guard against identical blocks: + stream.add(std.mem.asBytes(&block_address)); + + // Grid block sector padding is zeroed: + assert(stdx.zeroed(block[block_header.size..vsr.sector_ceil(block_header.size)])); + } + } + assert(blocks_missing == 0); + + return stream.checksum(); + } + + fn read_block_from_write_queues_by_address( + comptime Forest: type, + forest: *const Forest, + address: u64, + ) ?*align(constants.sector_size) const [constants.block_size]u8 { + const grid = forest.grid; + assert(grid.superblock.opened); + assert(grid.callback != .cancel); + assert(address > 0); + + var write_queue_iterator = grid.write_queue.iterate(); + while (write_queue_iterator.next()) |queued_write| { + const queued_write_header = std.mem.bytesAsValue( + vsr.Header.Block, + queued_write.block.*[0..@sizeOf(vsr.Header)], + ); + + if (address == queued_write_header.address) { + return queued_write.block.*; + } + } + + var write_iops_iterator = grid.write_iops.iterate(); + while (write_iops_iterator.next()) |iop| { + const queued_write_header = std.mem.bytesAsValue( + vsr.Header.Block, + iop.write.block.*[0..@sizeOf(vsr.Header)], + ); + + if (address == queued_write_header.address) { + return iop.write.block.*; + } + } + + return null; + } +}; diff --git a/ocam/src/testing/exhaustigen.zig b/ocam/src/testing/exhaustigen.zig new file mode 100644 index 00000000..b5bfb605 --- /dev/null +++ b/ocam/src/testing/exhaustigen.zig @@ -0,0 +1,128 @@ +//! An utility for exhaustive generation of arbitrary data +//! +test "generate all permutations" { + var g: Gen = .{}; + var permutation_count: u32 = 0; + + // This loop imperatively generates all permutations of "abcd": + while (!g.done()) { + var pool_buffer: [4]u8 = "abcd".*; + var permutation: [4]u8 = undefined; + + for (0..permutation.len) |i| { + const pool = pool_buffer[0 .. pool_buffer.len - i]; + + // Pick a "random" number from the pool, append it to the permutation, + // and swap-remove it from the pool. + const pool_index = g.index(pool); + permutation[i] = pool[pool_index]; + pool[pool_index] = pool[pool.len - 1]; + } + // Here, `permutation` enumerates all permutations of `abcd`: + // std.debug.print("permutation: {s}\n", .{permutation}); + permutation_count += 1; + } + + // Verify that we indeed generated n! permutations. + var factorial: usize = 1; + for (1..5) |n| factorial *= n; + + assert(permutation_count == factorial); +} + +const std = @import("std"); +const assert = std.debug.assert; + +// The implementation is tricky, refer to the post for details. +// +// On each iteration of `while (!g.done())` loop, Gen generates a sequence of numbers. +// Internally, it remembers this sequence together with bounds the user requested: +// +// value: 3 1 4 4 +// bound: 5 4 4 4 +// +// To advance to the next iteration, Gen finds the smallest sequence of values which is larger than +// the current one, but still satisfies all the bounds. "Smallest" means that Gen tries to increment +// the rightmost number. +// +// In the above example, the last two "4"s already match the bound, so we can't increment them. +// However, we can increment the second number, "1", to get 3 2 4 4. This isn’t the smallest +// sequence though, 3 2 0 0 is be smaller. So, after incrementing the rightmost number possible, +// we zero the rest. +const Gen = @This(); + +started: bool = false, +v: [32]struct { value: u32, bound: u32 } = undefined, +p: usize = 0, +p_max: usize = 0, + +pub fn done(g: *@This()) bool { + if (!g.started) { + g.started = true; + return false; + } + var i = g.p_max; + while (i > 0) { + i -= 1; + if (g.v[i].value < g.v[i].bound) { + g.v[i].value += 1; + g.p_max = i + 1; + g.p = 0; + return false; + } + } + return true; +} + +fn gen(g: *Gen, bound: u32) u32 { + assert(g.p < g.v.len); + if (g.p == g.p_max) { + g.v[g.p] = .{ .value = 0, .bound = 0 }; + g.p_max += 1; + } + g.p += 1; + g.v[g.p - 1].bound = bound; + return g.v[g.p - 1].value; +} + +pub fn int_inclusive(g: *Gen, Int: type, bound: Int) Int { + return @intCast(g.gen(@intCast(bound))); +} + +pub fn range_inclusive(g: *Gen, Int: type, min: Int, max: Int) Int { + comptime assert(@typeInfo(Int).int.signedness == .unsigned); + assert(min <= max); + return min + g.int_inclusive(Int, max - min); +} + +pub fn shuffle(g: *Gen, T: type, slice: []T) void { + for (0..slice.len) |i| { + const j = g.int_inclusive(u64, i); + std.mem.swap(T, &slice[i], &slice[j]); + } +} + +test shuffle { + var n_factorial: u32 = 1; + inline for (0..5) |n| { + var g: Gen = .{}; + var count: u32 = 0; + while (!g.done()) { + var array: [n]u8 = @splat(0); + g.shuffle(u8, &array); + count += 1; + } + assert(count == n_factorial); + n_factorial *= (n + 1); + } +} + +pub fn index(g: *Gen, slice: anytype) usize { + assert(slice.len > 0); + return g.int_inclusive(usize, slice.len - 1); +} + +pub fn enum_value(g: *Gen, Enum: type) Enum { + const values = std.enums.values(Enum); + return values[g.index(values)]; +} diff --git a/ocam/src/testing/fixtures.zig b/ocam/src/testing/fixtures.zig new file mode 100644 index 00000000..19ef8d64 --- /dev/null +++ b/ocam/src/testing/fixtures.zig @@ -0,0 +1,183 @@ +//! Convenient constructs for TigerBeetle components, for fuzzing and testing. +//! +//! Consider the Grid. In the actual database, there is only single call to Grid.init. +//! However, Grid is needed for most of our tests and fuzzers. If the init call is repeated +//! in every fuzzer, changing Storage creation flow becomes hard. To solve this, all fuzzers create +//! Grid through this file, such that we have one production and one test call to Grid.init. +//! +//! Design: +//! +//! - All functions take struct options as a last argument, even if it starts out as empty. +//! All call-sites pass at least .{}, which makes adding new options cheap. +//! - Most options should have defaults. This is intentional deviation from TigerStyle, as, for +//! tests, we gain a useful property: all options that are set are meaningful for a particular +//! test. +//! - All dependent fixtures are passed in positionally because they can't be defaulted and their +//! types are unique. +//! - It could be convenient to export types themselves, in addition to constructors, but we avoid +//! introducing two ways to import something. +const std = @import("std"); +const vsr = @import("../vsr.zig"); +const constants = @import("../constants.zig"); +const assert = std.debug.assert; + +const Time = @import("../time.zig").Time; +const OffsetType = @import("./time.zig").OffsetType; +const Tracer = @import("../trace.zig").Tracer; +const Storage = @import("./storage.zig").Storage; +const SuperBlock = vsr.SuperBlockType(Storage); +const Grid = vsr.GridType(Storage); + +const TimeSim = @import("./time.zig").TimeSim; + +pub const cluster: u128 = 0; +pub const replica: u8 = 0; +pub const replica_count: u8 = 6; + +pub fn init_time(options: struct { + resolution: u64 = constants.tick_ms * std.time.ns_per_ms, + offset_type: OffsetType = .linear, + offset_coefficient_A: i64 = 0, + offset_coefficient_B: i64 = 0, + offset_coefficient_C: u32 = 0, +}) TimeSim { + const result: TimeSim = .{ + .resolution = options.resolution, + .offset_type = options.offset_type, + .offset_coefficient_A = options.offset_coefficient_A, + .offset_coefficient_B = options.offset_coefficient_B, + .offset_coefficient_C = options.offset_coefficient_C, + }; + return result; +} + +pub fn init_tracer(gpa: std.mem.Allocator, init: Time, options: struct { + writer: ?std.io.AnyWriter = null, + process_id: Tracer.ProcessID = .{ .replica = .{ .cluster = cluster, .replica = replica } }, +}) !Tracer { + return Tracer.init(gpa, init, options.process_id, .{ .writer = options.writer }); +} + +pub fn init_storage(gpa: std.mem.Allocator, options: Storage.Options) !Storage { + return try Storage.init(gpa, options); +} + +pub fn storage_format( + gpa: std.mem.Allocator, + storage: *Storage, + options: struct { + cluster: u128 = cluster, + replica: u8 = replica, + replica_count: u8 = replica_count, + release: vsr.Release = vsr.Release.minimum, + }, +) !void { + assert(storage.reads.count() == 0); + assert(storage.writes.count() == 0); + + var superblock = try init_superblock(gpa, storage, .{}); + defer superblock.deinit(gpa); + + const Context = struct { + superblock_context: SuperBlock.Context = undefined, + done: bool = false, + fn callback(superblock_context: *SuperBlock.Context) void { + const self: *@This() = @fieldParentPtr("superblock_context", superblock_context); + assert(!self.done); + self.done = true; + } + }; + var context: Context = .{}; + + superblock.format(Context.callback, &context.superblock_context, .{ + .cluster = options.cluster, + .replica = options.replica, + .replica_count = options.replica_count, + .release = options.release, + .view = null, + }); + for (0..10_000) |_| { + if (context.done) break; + storage.run(); + } else @panic("superblock format loop stuck"); + assert(storage.reads.count() == 0); + assert(storage.writes.count() == 0); +} + +pub fn init_superblock(gpa: std.mem.Allocator, storage: *Storage, options: struct { + storage_size_limit: ?u64 = null, +}) !SuperBlock { + return try SuperBlock.init(gpa, storage, .{ + .storage_size_limit = options.storage_size_limit orelse storage.size, + }); +} + +pub fn init_grid(gpa: std.mem.Allocator, trace: *Tracer, superblock: *SuperBlock, options: struct { + missing_blocks_max: u64 = 0, + missing_tables_max: u64 = 0, + blocks_released_prior_checkpoint_durability_max: u64 = 0, + stash_blocks_count: u64 = 1024, +}) !Grid { + return try Grid.init(gpa, .{ + .superblock = superblock, + .trace = trace, + .stash_blocks_count = options.stash_blocks_count, + .missing_blocks_max = options.missing_blocks_max, + .missing_tables_max = options.missing_tables_max, + .blocks_released_prior_checkpoint_durability_max = // + options.blocks_released_prior_checkpoint_durability_max, + }); +} + +pub fn open_superblock(superblock: *SuperBlock) void { + const storage: *Storage = superblock.storage; + assert(storage.reads.count() == 0); + assert(storage.writes.count() == 0); + defer assert(storage.reads.count() == 0); + defer assert(storage.writes.count() == 0); + + const Context = struct { + superblock_context: SuperBlock.Context = undefined, + pending: bool = false, + + fn callback(superblock_context: *SuperBlock.Context) void { + const self: *@This() = @fieldParentPtr("superblock_context", superblock_context); + assert(self.pending); + self.pending = false; + } + }; + var context: Context = .{}; + + context.pending = true; + superblock.open(Context.callback, &context.superblock_context); + for (0..10_000) |_| { + storage.run(); + if (!context.pending) break; + } else @panic("open superblock stuck"); +} + +pub fn open_grid(grid: *Grid) void { + const storage: *Storage = grid.superblock.storage; + assert(storage.reads.count() == 0); + assert(storage.writes.count() == 0); + defer assert(storage.reads.count() == 0); + defer assert(storage.writes.count() == 0); + + const Context = struct { + pending: bool = false, + // NB: This lacks in elegance and robustness, but is good enough for testing. + var global: @This() = .{}; + fn callback(_: *Grid) void { + assert(@This().global.pending); + @This().global.pending = false; + } + }; + + assert(!Context.global.pending); + Context.global.pending = true; + grid.open(Context.callback); + for (0..10_000) |_| { + storage.run(); + if (!Context.global.pending) break; + } else @panic("open grid stuck"); +} diff --git a/ocam/src/testing/fuzz.zig b/ocam/src/testing/fuzz.zig new file mode 100644 index 00000000..9171ec5a --- /dev/null +++ b/ocam/src/testing/fuzz.zig @@ -0,0 +1,143 @@ +//! Utils functions for writing fuzzers. + +const builtin = @import("builtin"); +const std = @import("std"); +const stdx = @import("stdx"); +const assert = std.debug.assert; +const PRNG = stdx.PRNG; +const Duration = stdx.Duration; + +const GiB = stdx.GiB; + +const log = std.log.scoped(.fuzz); + +/// Returns an integer of type `T` with an exponential distribution of rate `avg`. +/// Note: If you specify a very high rate then `std.math.maxInt(T)` may be over-represented. +pub fn random_int_exponential(prng: *stdx.PRNG, comptime T: type, avg: T) T { + comptime { + const info = @typeInfo(T); + assert(info == .int); + assert(info.int.signedness == .unsigned); + } + // Note: we use floats and rely on std implementation. Ideally, we should do neither, but I + // wasn't able to find a quick way to generate geometrically distributed integers using only + // integer arithmetic. + const random = std.Random.init(prng, stdx.PRNG.fill); + const exp = random.floatExp(f64) * @as(f64, @floatFromInt(avg)); + return std.math.lossyCast(T, exp); +} + +/// Return a distribution for use with `random_enum`. +/// +/// This is swarm testing: some variants are disabled completely, +/// and the rest have wildly different probabilities. +pub fn random_enum_weights( + prng: *stdx.PRNG, + comptime Enum: type, +) stdx.PRNG.EnumWeightsType(Enum) { + const fields = comptime std.meta.fieldNames(Enum); + + var combination = stdx.PRNG.Combination.init(.{ + .total = fields.len, + .sample = prng.range_inclusive(u32, 1, fields.len), + }); + defer assert(combination.done()); + + var weights: PRNG.EnumWeightsType(Enum) = undefined; + inline for (fields) |field| { + @field(weights, field) = if (combination.take(prng)) + prng.range_inclusive(u64, 1, 100) + else + 0; + } + + return weights; +} + +/// We have two opposing desires for prng ids: +/// 1. We want to cause many collisions. +/// 2. We want to generate enough ids that various caches can't hold them all. +/// +/// So, flip a coin and pick an an ID either from a small, or from a large set. +pub fn random_id(prng: *stdx.PRNG, comptime Int: type, options: struct { + average_hot: Int, + average_cold: Int, +}) Int { + assert(options.average_hot < options.average_cold); + const average: Int = if (prng.boolean()) options.average_hot else options.average_cold; + return random_int_exponential(prng, Int, average); +} + +pub fn range_inclusive_ms(prng: *stdx.PRNG, min: anytype, max: anytype) Duration { + const min_ns = switch (@TypeOf(min)) { + comptime_int, u64 => min * std.time.ns_per_ms, + Duration => min.ns, + else => comptime unreachable, + }; + const max_ns = switch (@TypeOf(max)) { + comptime_int, u64 => max * std.time.ns_per_ms, + Duration => max.ns, + else => comptime unreachable, + }; + return .{ .ns = prng.range_inclusive(u64, min_ns, max_ns) }; +} + +pub const FuzzArgs = struct { + seed: u64, + events_max: ?usize, +}; + +pub fn parse_seed(bytes: []const u8) u64 { + if (bytes.len == 40) { + // Normally, a seed is specified as a base-10 integer. However, as a special case, we allow + // using a Git hash (a hex string 40 character long). This is used by our CI, which passes + // current commit hash as a seed --- that way, we run simulator on CI, we run it with + // different, "random" seeds, but the failures remain reproducible just from the commit + // hash! + const commit_hash = stdx.parse_int(u160, bytes, .{ + .base = 16, + .allow_leading_zero = true, + }) catch + @panic("commit hash seed invalid"); + return @truncate(commit_hash); + } + + return stdx.parse_int(u64, bytes, .{}) catch + @panic("seed invalid"); +} + +// Like `std.meta.DeclEnum`, but allows excluding specific things. Feed the result into +// random_enum_weights for swarm testing public API of a data structure. +pub fn DeclEnumExcludingType(T: type, exclude: []const std.meta.DeclEnum(T)) type { + const base = @typeInfo(std.meta.DeclEnum(T)).@"enum"; + assert(exclude.len > 0); // Use plain std.meta.DeclEnum. + assert(exclude.len < base.fields.len); + var fields_filtered: [base.fields.len - exclude.len]std.builtin.Type.EnumField = undefined; + var i: usize = 0; + next_field: for (base.fields) |field| { + for (exclude) |excluded| { + if (std.mem.eql(u8, field.name, @tagName(excluded))) continue :next_field; + } + fields_filtered[i] = field; + i += 1; + } + assert(i == fields_filtered.len); + + return @Type(.{ .@"enum" = .{ + .tag_type = base.tag_type, + .fields = &fields_filtered, + .decls = &.{}, + .is_exhaustive = true, + } }); +} + +pub fn limit_ram() void { + if (builtin.target.os.tag != .linux) return; + + std.posix.setrlimit(.AS, .{ + .cur = 20 * GiB, + .max = 20 * GiB, + }) catch |err| { + log.warn("failed to setrlimit address space: {}", .{err}); + }; +} diff --git a/ocam/src/testing/id.zig b/ocam/src/testing/id.zig new file mode 100644 index 00000000..1d202a9a --- /dev/null +++ b/ocam/src/testing/id.zig @@ -0,0 +1,97 @@ +const std = @import("std"); +const stdx = @import("stdx"); + +/// Permute indices (or other encoded data) into ids to: +/// +/// * test different patterns of ids (e.g. random, ascending, descending), and +/// * allow the original index to recovered from the id, enabling less stateful testing. +/// +pub const IdPermutation = union(enum) { + /// Ascending indices become ascending ids. + identity: void, + + /// Ascending indices become descending ids. + inversion: void, + + /// Ascending indices alternate between ascending/descending (e.g. 1,100,3,98,…). + zigzag: void, + + /// Ascending indices become pseudo-UUIDs. + /// + /// Sandwich the index "data" between random bits — this randomizes the id's prefix and suffix, + /// but the index is easily recovered: + /// + /// * id_bits[_0.._32] = random + /// * id_bits[32.._96] = data + /// * id_bits[96..128] = random + random: u64, + + pub fn encode(self: *const IdPermutation, data: usize) u128 { + return switch (self.*) { + .identity => data, + .inversion => std.math.maxInt(u128) - @as(u128, data), + .zigzag => { + if (data % 2 == 0) { + return data; + } else { + // -1 to stay odd. + return std.math.maxInt(u128) - @as(u128, data) -% 1; + } + }, + .random => |seed| { + var prng = stdx.PRNG.from_seed(seed +% data); + const random_mask = ~@as(u128, std.math.maxInt(u64) << 32); + const random_bits = random_mask & prng.int(u128); + return @as(u128, data) << 32 | random_bits; + }, + }; + } + + pub fn decode(self: *const IdPermutation, id: u128) usize { + return switch (self.*) { + .identity => @intCast(id), + .inversion => @intCast(std.math.maxInt(u128) - id), + .zigzag => { + if (id % 2 == 0) { + return @intCast(id); + } else { + // -1 to stay odd. + return @intCast(std.math.maxInt(u128) - id -% 1); + } + }, + .random => @truncate(id >> 32), + }; + } + + pub fn generate(prng: *stdx.PRNG) IdPermutation { + return switch (prng.enum_uniform(std.meta.Tag(IdPermutation))) { + .identity => .{ .identity = {} }, + .inversion => .{ .inversion = {} }, + .zigzag => .{ .zigzag = {} }, + .random => .{ .random = prng.int(u64) }, + }; + } +}; + +test "IdPermutation" { + var prng = stdx.PRNG.from_seed_testing(); + + for ([_]IdPermutation{ + .{ .identity = {} }, + .{ .inversion = {} }, + .{ .zigzag = {} }, + .{ .random = prng.int(u64) }, + }) |permutation| { + var i: usize = 0; + while (i < 20) : (i += 1) { + const r = prng.int(usize); + try test_id_permutation(permutation, r); + try test_id_permutation(permutation, i); + try test_id_permutation(permutation, std.math.maxInt(usize) - i); + } + } +} + +fn test_id_permutation(permutation: IdPermutation, value: usize) !void { + try std.testing.expectEqual(value, permutation.decode(permutation.encode(value))); +} diff --git a/ocam/src/testing/io.zig b/ocam/src/testing/io.zig new file mode 100644 index 00000000..d40ba43d --- /dev/null +++ b/ocam/src/testing/io.zig @@ -0,0 +1,365 @@ +const std = @import("std"); +const posix = std.posix; +const mem = std.mem; +const assert = std.debug.assert; + +const stdx = @import("stdx"); +const constants = @import("../constants.zig"); +const common = @import("../io/common.zig"); +const QueueType = @import("../queue.zig").QueueType; +const buffer_limit = @import("../io.zig").buffer_limit; +const Ratio = stdx.PRNG.Ratio; + +/// A very simple mock IO implementation that only implements what is needed to test Storage. +pub const IO = struct { + pub const fd_t = u32; + pub const NextTickSource = common.NextTickSource; + + pub const File = struct { + buffer: []u8, + + /// Each bit of the fault map represents a sector that will fault consistently. + fault_map: ?[]const u8 = null, + + closed: bool = false, + + // Maintained for appending to the end of the file via `write_blocking`. + offset: u32 = 0, + }; + + /// Options for fault injection during fuzz testing. + pub const Options = struct { + /// Seed for the storage PRNG. + seed: u64 = 0, + + /// Chance out of 100 that a read larger than a logical sector + /// will return an error.InputOutput. + larger_than_logical_sector_read_fault_probability: Ratio = Ratio.zero(), + }; + + const Queue = QueueType(Completion); + + files: []File, + + options: Options, + prng: stdx.PRNG, + + completed: Queue = Queue.init(.{ .name = "io_completed" }), + + pub fn init(files: []File, options: Options) !IO { + return .{ + .options = options, + .prng = stdx.PRNG.from_seed(options.seed), + .files = files, + }; + } + + pub fn deinit(io: *IO) void { + for (io.files) |file| assert(file.closed); + } + + /// Pass all queued submissions to the kernel and peek for completions. + pub fn run(io: *IO) !void { + while (io.completed.pop()) |completion| { + completion.callback(io, completion); + } + } + + /// This struct holds the data needed for a single IO operation. + pub const Completion = struct { + link: Queue.Link, + context: ?*anyopaque, + callback: *const fn (*IO, *Completion) void, + operation: Operation, + }; + + const Operation = union(enum) { + read: struct { + fd: fd_t, + buf: [*]u8, + len: u32, + offset: u64, + }, + write: struct { + fd: fd_t, + buf: [*]const u8, + len: u32, + offset: u64, + }, + fsync: struct { + fd: fd_t, + }, + next_tick: struct { + source: NextTickSource, + }, + }; + + fn submit( + self: *IO, + context: anytype, + comptime callback: anytype, + completion: *Completion, + comptime operation_tag: std.meta.Tag(Operation), + operation_data: std.meta.TagPayload(Operation, operation_tag), + comptime OperationImpl: type, + ) void { + const on_complete_fn = struct { + fn on_complete(io: *IO, _completion: *Completion) void { + // Perform the actual operation. + const op_data = &@field(_completion.operation, @tagName(operation_tag)); + const result = OperationImpl.do_operation(io, op_data); + + // Complete the Completion. + return callback( + @ptrCast(@alignCast(_completion.context)), + _completion, + result, + ); + } + }.on_complete; + + completion.* = .{ + .link = .{}, + .context = context, + .callback = on_complete_fn, + .operation = @unionInit(Operation, @tagName(operation_tag), operation_data), + }; + + self.completed.push(completion); + } + + pub const OpenDataFilePurpose = enum { format, open, inspect }; + + pub const ReadError = error{ + WouldBlock, + NotOpenForReading, + ConnectionResetByPeer, + Alignment, + InputOutput, + IsDir, + SystemResources, + Unseekable, + ConnectionTimedOut, + } || posix.UnexpectedError; + + pub fn read( + self: *IO, + comptime Context: type, + context: Context, + comptime callback: fn ( + context: Context, + completion: *Completion, + result: ReadError!usize, + ) void, + completion: *Completion, + fd: fd_t, + buffer: []u8, + offset: u64, + ) void { + assert(fd < self.files.len); + + self.submit( + context, + callback, + completion, + .read, + .{ + .fd = fd, + .buf = buffer.ptr, + .len = @as(u32, @intCast(buffer_limit(buffer.len))), + .offset = offset, + }, + struct { + fn do_operation(io: *IO, op: anytype) ReadError!usize { + const sector_marked_in_fault_map = if (io.files[op.fd].fault_map) |fault_map| + std.mem.readPackedIntNative( + u1, + fault_map, + @divExact(op.offset, constants.sector_size), + ) != 0 + else + false; + + const sector_has_larger_than_logical_sector_read_fault = + (op.len > constants.sector_size and io.prng.chance( + io.options.larger_than_logical_sector_read_fault_probability, + )); + + if (sector_marked_in_fault_map or + sector_has_larger_than_logical_sector_read_fault) + { + return error.InputOutput; + } + + const data = io.files[op.fd].buffer; + stdx.copy_disjoint(.exact, u8, op.buf[0..op.len], data[op.offset..][0..op.len]); + return op.len; + } + }, + ); + } + + pub const WriteError = posix.PWriteError; + + pub fn write( + self: *IO, + comptime Context: type, + context: Context, + comptime callback: fn ( + context: Context, + completion: *Completion, + result: WriteError!usize, + ) void, + completion: *Completion, + fd: fd_t, + buffer: []const u8, + offset: u64, + ) void { + assert(fd < self.files.len); + + self.submit( + context, + callback, + completion, + .write, + .{ + .fd = fd, + .buf = buffer.ptr, + .len = @as(u32, @intCast(buffer_limit(buffer.len))), + .offset = offset, + }, + struct { + fn do_operation(io: *IO, op: anytype) WriteError!usize { + const data = io.files[op.fd].buffer; + if (op.offset + op.len >= data.len) { + @panic("write beyond simulated file size"); + } + stdx.copy_disjoint(.exact, u8, data[op.offset..][0..op.len], op.buf[0..op.len]); + return op.len; + } + }, + ); + } + + pub const FsyncError = posix.SyncError; + + pub fn fsync( + self: *IO, + comptime Context: type, + context: Context, + comptime callback: fn ( + context: Context, + completion: *Completion, + result: FsyncError!void, + ) void, + completion: *Completion, + fd: fd_t, + ) void { + assert(fd < self.files.len); + + self.submit( + context, + callback, + completion, + .fsync, + .{ .fd = fd }, + struct { + fn do_operation(_: *IO, _: anytype) FsyncError!void {} + }, + ); + } + + pub const NextTickResult = void; + + /// Schedule a deferred callback that doesn't involve kernel IO. + pub fn next_tick( + self: *IO, + comptime Context: type, + context: Context, + comptime callback: fn ( + context: Context, + completion: *Completion, + result: NextTickResult, + ) void, + completion: *Completion, + source: NextTickSource, + ) void { + completion.* = .{ + .link = .{}, + .context = context, + .operation = .{ .next_tick = .{ .source = source } }, + .callback = struct { + fn on_complete(_: *IO, _completion: *Completion) void { + callback(@ptrCast(@alignCast(_completion.context)), _completion, {}); + } + }.on_complete, + }; + self.completed.push(completion); + } + + /// Remove all next_tick entries with the given source from the completed queue. + pub fn reset_next_tick(self: *IO, source: NextTickSource) void { + var completed = self.completed; + self.completed.reset(); + + while (completed.pop()) |completion| { + if (completion.operation == .next_tick and + completion.operation.next_tick.source == source) + { + continue; + } + self.completed.push(completion); + } + } + + pub fn aof_blocking_write_all(self: *IO, fd: fd_t, source: []const u8) posix.WriteError!void { + assert(fd < self.files.len); + + const file_index = @as(u32, @intCast(fd)); + const file = &self.files[file_index]; + const target = file.buffer; + const offset = file.offset; + + assert(offset + source.len <= target.len); + + stdx.copy_disjoint(.exact, u8, target[offset..][0..source.len], source); + + file.offset += @as(u32, @intCast(source.len)); + } + + pub const PReadError = posix.PReadError; + + pub fn aof_blocking_close(self: *IO, fd: fd_t) void { + assert(fd < self.files.len); + self.files[fd].closed = true; + } + + pub fn aof_blocking_pread_all(self: *IO, fd: fd_t, target: []u8, offset: u64) PReadError!usize { + assert(fd < self.files.len); + + const file_index = @as(u32, @intCast(fd)); + const source = self.files[file_index].buffer; + + assert(offset + target.len <= source.len); + + stdx.copy_disjoint(.exact, u8, target, source[offset..][0..target.len]); + + return target.len; + } + + pub fn aof_blocking_stat(_: *IO, _: []const u8) std.fs.Dir.StatFileError!std.fs.File.Stat { + return error.Unexpected; + } + + pub fn aof_blocking_fstat(_: *IO, _: fd_t) std.fs.Dir.StatError!std.fs.File.Stat { + return error.Unexpected; + } + + pub fn aof_blocking_open(_: *IO, _: []const u8) !fd_t { + return error.Unexpected; + } + + pub fn reset(self: *IO) void { + self.completed.reset(); + } +}; diff --git a/ocam/src/testing/marks.zig b/ocam/src/testing/marks.zig new file mode 100644 index 00000000..643c9574 --- /dev/null +++ b/ocam/src/testing/marks.zig @@ -0,0 +1,126 @@ +//! This file piggy-backs on the logging infrastructure to implement explicit coverage marks: +//! +//! +//! In production code, you can mark certain log lines as "this should be covered by a test". +//! In test code, you can then assert that a _specific_ test covers a specific log line. The two +//! benefits are: +//! - tests are more resilient to refactors +//! - production code is more readable (you can immediately jump to a specific test) +//! +//! At the surface level, this resembles usual code coverage, but the idea is closer to traceability +//! from safety-critical systems: +//! +//! +//! That is, the important part is not that a log line is covered at all, but that we can trace +//! production code to a single minimal hand-written test which explains why the code needs to +//! exist. +test "tutorial" { + // Import by a qualified name. + const marks = @import("./marks.zig"); + + const production_code = struct { + // In production code, wrap the logger. + const log = marks.wrap_log(std.log.scoped(.my_module)); + + fn function_under_test(x: u32) void { + if (x % 2 == 0) { + // Both `log.info` and log.covered.info` are available. + // Only second version records coverage. + log.mark.info("x is even (x={})", .{x}); + } + } + }; + + // Create a mark with the `mark` function... + const mark = marks.check("x is even"); + production_code.function_under_test(92); + try mark.expect_hit(); // ... and don't forget to assert at the end! +} + +const std = @import("std"); +const assert = std.debug.assert; +const builtin = @import("builtin"); + +const GlobalStateType = if (builtin.is_test) struct { + mark_name: ?[]const u8 = null, + mark_hit_count: u32 = 0, +} else void; + +/// Stores the currently active mark and its hit count. State is not synchronized and assumes +/// single threaded execution. +var global_state: GlobalStateType = .{}; + +pub const Mark = struct { + name: []const u8, + + pub fn expect_hit(mark: Mark) !void { + comptime assert(builtin.is_test); + assert(global_state.mark_name.?.ptr == mark.name.ptr); + defer global_state = .{}; + + if (global_state.mark_hit_count == 0) { + std.debug.print("mark '{s}' not hit", .{mark.name}); + return error.MarkNotHit; + } + } + + pub fn expect_not_hit(mark: Mark) !void { + comptime assert(builtin.is_test); + assert(global_state.mark_name.?.ptr == mark.name.ptr); + defer global_state = .{}; + + if (global_state.mark_hit_count != 0) { + std.debug.print("mark '{s}' hit", .{mark.name}); + return error.MarkHit; + } + } +}; + +pub fn check(name: []const u8) Mark { + comptime assert(builtin.is_test); + assert(global_state.mark_name == null); + assert(global_state.mark_hit_count == 0); + + global_state.mark_name = name; + return Mark{ .name = name }; +} + +pub fn wrap_log(comptime base: type) type { + return struct { + pub const mark = if (builtin.is_test) struct { + pub fn err(comptime fmt: []const u8, args: anytype) void { + record(fmt); + base.err(fmt, args); + } + + pub fn warn(comptime fmt: []const u8, args: anytype) void { + record(fmt); + base.warn(fmt, args); + } + + pub fn info(comptime fmt: []const u8, args: anytype) void { + record(fmt); + base.info(fmt, args); + } + + pub fn debug(comptime fmt: []const u8, args: anytype) void { + record(fmt); + base.debug(fmt, args); + } + } else base; + + pub const err = base.err; + pub const warn = base.warn; + pub const info = base.info; + pub const debug = base.debug; + }; +} + +fn record(fmt: []const u8) void { + comptime assert(builtin.is_test); + if (global_state.mark_name) |mark_active| { + if (std.mem.indexOf(u8, fmt, mark_active) != null) { + global_state.mark_hit_count += 1; + } + } +} diff --git a/ocam/src/testing/packet_simulator.zig b/ocam/src/testing/packet_simulator.zig new file mode 100644 index 00000000..cfb3726e --- /dev/null +++ b/ocam/src/testing/packet_simulator.zig @@ -0,0 +1,533 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const log = std.log.scoped(.packet_simulator); +const vsr = @import("../vsr.zig"); +const fuzz = @import("./fuzz.zig"); +const stdx = @import("stdx"); +const constants = @import("../constants.zig"); +const Ratio = stdx.PRNG.Ratio; +const Duration = stdx.Duration; +const Instant = stdx.Instant; + +pub const PacketSimulatorOptions = struct { + node_count: u8, + client_count: u8, + seed: u64, + + recorded_count_max: u8 = 0, + + /// Mean for the exponential distribution used to calculate forward delay. + one_way_delay_mean: Duration, + one_way_delay_min: Duration, + + packet_loss_probability: Ratio = Ratio.zero(), + packet_replay_probability: Ratio = Ratio.zero(), + + /// How the partitions should be generated + partition_mode: PartitionMode = .none, + + partition_symmetry: PartitionSymmetry = .symmetric, + + /// Probability per tick that a partition will occur + partition_probability: Ratio = Ratio.zero(), + + /// Probability per tick that a partition will resolve + unpartition_probability: Ratio = Ratio.zero(), + + /// Minimum time a partition lasts + partition_stability: u32 = 0, + + /// Minimum time the cluster is fully connected until it is partitioned again + unpartition_stability: u32 = 0, + + /// The maximum number of in-flight packets a path can have before packets are randomly dropped. + path_maximum_capacity: u8, + + /// Mean for the exponential distribution used to calculate how long a path is clogged for. + path_clog_duration_mean: Duration, + path_clog_probability: Ratio, +}; + +pub const Path = struct { + source: u8, + target: u8, +}; + +pub const LinkFilter = std.enums.EnumSet(vsr.Command); + +/// Determines how the partitions are created. Partitions +/// are two-way, i.e. if i cannot communicate with j, then +/// j cannot communicate with i. +/// +/// Only nodes (replicas or standbys) are partitioned. There will always be exactly two partitions. +pub const PartitionMode = enum { + /// Disable automatic partitioning. + none, + + /// Draws the size of the partition uniformly at random from (1, n-1). + /// Replicas are randomly assigned a partition. + uniform_size, + + /// Assigns each node to a partition uniformly at random. This biases towards + /// equal-size partitions. + uniform_partition, + + /// Isolates exactly one node. + isolate_single, +}; + +pub const PartitionSymmetry = enum { symmetric, asymmetric }; + +pub fn PacketSimulatorType(comptime Packet: type) type { + return struct { + const PacketSimulator = @This(); + + const VTable = struct { + packet_command: *const fn (*PacketSimulator, Packet) vsr.Command, + packet_clone: *const fn (*PacketSimulator, Packet) Packet, + packet_deinit: *const fn (*PacketSimulator, Packet) void, + packet_deliver: *const fn (*PacketSimulator, Packet, Path) void, + packet_delay: *const fn (*PacketSimulator, Packet, Path) Duration = + &packet_delay_default, + }; + + const LinkPacket = struct { + ready_at: Instant, + packet: Packet, + + fn less_than(_: void, a: LinkPacket, b: LinkPacket) std.math.Order { + return std.math.order(a.ready_at.ns, b.ready_at.ns); + } + }; + + pub const LinkDropPacketFn = *const fn (packet: Packet) bool; + + const Link = struct { + queue: std.PriorityQueue(LinkPacket, void, LinkPacket.less_than), + /// Commands in the set are delivered. + /// Commands not in the set are dropped. + filter: LinkFilter = LinkFilter.initFull(), + drop_packet_fn: ?LinkDropPacketFn = null, + /// Commands in the set are recorded for a later replay. + record: LinkFilter = .{}, + /// We can arbitrary clog a path until a given moment. + clogged_till: Instant = .{ .ns = 0 }, + + fn should_drop(link: *const @This(), packet: Packet, command: vsr.Command) bool { + if (!link.filter.contains(command)) { + return true; + } + if (link.drop_packet_fn) |drop_packet_fn| { + return drop_packet_fn(packet); + } + return false; + } + }; + + const RecordedPacket = struct { + packet: Packet, + path: Path, + }; + const Recorded = std.ArrayListUnmanaged(RecordedPacket); + + options: PacketSimulatorOptions, + vtable: VTable, + prng: stdx.PRNG, + ticks: u64 = 0, + + /// A send and receive path between each node in the network. + /// Indexed by path_index(). + links: []Link, + + /// Recorded messages for manual replay in unit-tests. + recorded: Recorded, + + /// Scratch space for automatically generating partitions. + /// The "source of truth" for partitions is links[*].filter. + auto_partition: []bool, + auto_partition_active: bool, + auto_partition_nodes: []u8, + auto_partition_stability: u32, + + pub fn init( + allocator: std.mem.Allocator, + options: PacketSimulatorOptions, + vtable: VTable, + ) !PacketSimulator { + assert(options.node_count > 0); + assert(options.one_way_delay_mean.ns >= options.one_way_delay_min.ns); + + const process_count_ = options.node_count + options.client_count; + const links = try allocator.alloc(Link, @as(usize, process_count_) * process_count_); + errdefer allocator.free(links); + + for (links, 0..) |*link, i| { + errdefer for (links[0..i]) |*l| l.queue.deinit(); + + link.* = .{ + .queue = std.PriorityQueue(LinkPacket, void, LinkPacket.less_than) + .init(allocator, {}), + }; + try link.queue.ensureTotalCapacity(options.path_maximum_capacity); + } + errdefer for (links) |*link| link.queue.deinit(); + + var recorded = try Recorded.initCapacity(allocator, options.recorded_count_max); + errdefer recorded.deinit(allocator); + + const auto_partition = try allocator.alloc(bool, @as(usize, options.node_count)); + errdefer allocator.free(auto_partition); + @memset(auto_partition, false); + + const auto_partition_nodes = try allocator.alloc(u8, @as(usize, options.node_count)); + errdefer allocator.free(auto_partition_nodes); + for (auto_partition_nodes, 0..) |*node, i| node.* = @intCast(i); + + return PacketSimulator{ + .options = options, + .vtable = vtable, + .prng = stdx.PRNG.from_seed(options.seed), + .links = links, + + .recorded = recorded, + + .auto_partition_active = false, + .auto_partition = auto_partition, + .auto_partition_nodes = auto_partition_nodes, + .auto_partition_stability = options.unpartition_stability, + }; + } + + pub fn deinit(self: *PacketSimulator, allocator: std.mem.Allocator) void { + for (self.links) |*link| { + for (link.queue.items) |link_packet| { + self.packet_deinit(link_packet.packet); + } + + link.queue.deinit(); + } + + while (self.recorded.pop()) |recorded_packet| { + self.packet_deinit(recorded_packet.packet); + } + self.recorded.deinit(allocator); + + allocator.free(self.links); + allocator.free(self.auto_partition); + allocator.free(self.auto_partition_nodes); + } + + /// Drop all pending packets. + pub fn link_clear(self: *PacketSimulator, path: Path) void { + const link = &self.links[self.path_index(path)]; + while (link.queue.removeOrNull()) |link_packet| { + self.packet_deinit(link_packet.packet); + } + assert(link.queue.count() == 0); + } + + pub fn link_filter(self: *PacketSimulator, path: Path) *LinkFilter { + return &self.links[self.path_index(path)].filter; + } + + pub fn link_drop_packet_fn(self: *PacketSimulator, path: Path) *?LinkDropPacketFn { + return &self.links[self.path_index(path)].drop_packet_fn; + } + + pub fn link_record(self: *PacketSimulator, path: Path) *LinkFilter { + return &self.links[self.path_index(path)].record; + } + + pub fn replay_recorded(self: *PacketSimulator) void { + assert(self.recorded.items.len > 0); + + var recording = false; + for (self.links) |*link| { + recording = recording or link.record.bits.count() > 0; + link.record = .{}; + } + assert(recording); + + while (self.recorded.pop()) |packet| { + self.submit_packet(packet.packet, packet.path); + } + } + + fn process_count(self: *const PacketSimulator) usize { + return self.options.node_count + self.options.client_count; + } + + fn path_index(self: *const PacketSimulator, path: Path) usize { + assert(path.source < self.process_count()); + assert(path.target < self.process_count()); + + return @as(usize, path.source) * self.process_count() + path.target; + } + + fn should_drop(self: *PacketSimulator) bool { + return self.prng.chance(self.options.packet_loss_probability); + } + + fn is_clogged(self: *PacketSimulator, path: Path) bool { + return self.links[self.path_index(path)].clogged_till.ns > self.tick_instant().ns; + } + + fn should_clog(self: *PacketSimulator, path: Path) bool { + _ = path; + + return self.prng.chance(self.options.path_clog_probability); + } + + fn clog_for(self: *PacketSimulator, path: Path, duration: Duration) void { + self.links[self.path_index(path)].clogged_till = + self.tick_instant().add(duration); + log.debug("Path path.source={} path.target={} clogged for {}", .{ + path.source, + path.target, + duration, + }); + } + + fn should_replay(self: *PacketSimulator) bool { + return self.prng.chance(self.options.packet_replay_probability); + } + + fn should_partition(self: *PacketSimulator) bool { + return self.prng.chance(self.options.partition_probability); + } + + fn should_unpartition(self: *PacketSimulator) bool { + return self.prng.chance(self.options.unpartition_probability); + } + + /// Partitions the network. Guaranteed to isolate at least one replica. + fn auto_partition_network(self: *PacketSimulator) void { + assert(self.options.node_count > 1); + + var partition = self.auto_partition; + switch (self.options.partition_mode) { + .none => @memset(partition, false), + .uniform_size => { + const partition_size = + self.prng.range_inclusive(u8, 1, self.options.node_count - 1); + self.prng.shuffle(u8, self.auto_partition_nodes); + for (self.auto_partition_nodes, 0..) |r, i| { + partition[r] = i < partition_size; + } + }, + .uniform_partition => { + var only_same = true; + partition[0] = self.prng.boolean(); + + var i: usize = 1; + while (i < self.options.node_count) : (i += 1) { + partition[i] = self.prng.boolean(); + only_same = + only_same and (partition[i - 1] == partition[i]); + } + + if (only_same) { + const n = self.prng.index(partition); + partition[n] = true; + } + }, + .isolate_single => { + @memset(partition, false); + const n = self.prng.index(partition); + partition[n] = true; + }, + } + + self.auto_partition_active = true; + self.auto_partition_stability = self.options.partition_stability; + + const asymmetric_partition_side = self.prng.boolean(); + var from: u8 = 0; + while (from < self.process_count()) : (from += 1) { + var to: u8 = 0; + while (to < self.process_count()) : (to += 1) { + const path: Path = .{ .source = from, .target = to }; + const enabled = + from >= self.options.node_count or + to >= self.options.node_count or + partition[from] == partition[to] or + (self.options.partition_symmetry == .asymmetric and + partition[from] == asymmetric_partition_side); + self.links[self.path_index(path)].filter = + if (enabled) LinkFilter.initFull() else LinkFilter{}; + } + } + } + + pub fn step(self: *PacketSimulator) bool { + var advanced = false; + for (0..self.process_count()) |from| { + for (0..self.process_count()) |to| { + const path: Path = .{ .source = @intCast(from), .target = @intCast(to) }; + if (self.is_clogged(path)) continue; + + const queue = &self.links[self.path_index(path)].queue; + if (queue.peek()) |link_packet| { + if (link_packet.ready_at.ns <= self.tick_instant().ns) { + _ = queue.remove(); + self.submit_packet_finish(path, link_packet); + self.packet_deinit(link_packet.packet); + advanced = true; + } + } + } + } + return advanced; + } + + pub fn tick(self: *PacketSimulator) void { + self.ticks += 1; + + if (self.auto_partition_stability > 0) { + self.auto_partition_stability -= 1; + } else { + if (self.auto_partition_active) { + if (self.should_unpartition()) { + self.auto_partition_active = false; + self.auto_partition_stability = self.options.unpartition_stability; + @memset(self.auto_partition, false); + for (self.links) |*link| link.filter = LinkFilter.initFull(); + log.warn("unpartitioned network: partition={any}", .{self.auto_partition}); + } + } else { + if (self.options.node_count > 1 and self.should_partition()) { + self.auto_partition_network(); + log.warn("partitioned network: partition={any}", .{self.auto_partition}); + } + } + } + + for (0..self.process_count()) |from| { + for (0..self.process_count()) |to| { + const path: Path = .{ .source = @intCast(from), .target = @intCast(to) }; + if (self.should_clog(path)) { + self.clog_for(path, .{ + .ns = fuzz.random_int_exponential( + &self.prng, + u64, + self.options.path_clog_duration_mean.ns, + ), + }); + } + } + } + } + + pub fn submit_packet( + self: *PacketSimulator, + packet: Packet, + path: Path, + ) void { + const queue = &self.links[self.path_index(path)].queue; + const queue_count = queue.count(); + if (queue_count + 1 > self.options.path_maximum_capacity) { + const link_packet = queue.removeIndex(self.prng.index(queue.items)); + defer self.packet_deinit(link_packet.packet); + + log.warn("submit_packet: {} reached capacity, dropped packet: {}", .{ + path, + if (@typeInfo(Packet) == .pointer) + link_packet.packet.header + else + link_packet.packet, + }); + } + + queue.add(.{ + .ready_at = self.tick_instant().add(self.packet_delay(packet, path)), + .packet = packet, + }) catch unreachable; + + const command = self.packet_command(packet); + const recording = self.links[self.path_index(path)].record.contains(command); + if (recording) { + self.recorded.addOneAssumeCapacity().* = .{ + .packet = self.packet_clone(packet), + .path = path, + }; + } + } + + fn submit_packet_finish(self: *PacketSimulator, path: Path, link_packet: LinkPacket) void { + assert(link_packet.ready_at.ns <= self.tick_instant().ns); + const command = self.packet_command(link_packet.packet); + if (self.links[self.path_index(path)].should_drop(link_packet.packet, command)) { + log.warn( + "dropped packet (different partitions): from={} to={}: {}", + .{ + path.source, + path.target, + if (@typeInfo(Packet) == .pointer) + link_packet.packet.header + else + link_packet.packet, + }, + ); + return; + } + + if (self.should_drop()) { + log.warn("dropped packet from={} to={}: {}", .{ + path.source, + path.target, + if (@typeInfo(Packet) == .pointer) + link_packet.packet.header + else + link_packet.packet, + }); + return; + } + + if (self.should_replay()) { + self.submit_packet( + self.packet_clone(link_packet.packet), + path, + ); + log.debug("replayed packet from={} to={}", .{ path.source, path.target }); + } + + log.debug("delivering packet from={} to={}", .{ path.source, path.target }); + self.packet_deliver(link_packet.packet, path); + } + + fn tick_instant(self: *const PacketSimulator) Instant { + return .{ .ns = self.ticks * constants.tick_ms * std.time.ns_per_ms }; + } + + fn packet_command(self: *PacketSimulator, packet: Packet) vsr.Command { + return self.vtable.packet_command(self, packet); + } + + fn packet_clone(self: *PacketSimulator, packet: Packet) Packet { + return self.vtable.packet_clone(self, packet); + } + + fn packet_deinit(self: *PacketSimulator, packet: Packet) void { + self.vtable.packet_deinit(self, packet); + } + + fn packet_deliver(self: *PacketSimulator, packet: Packet, path: Path) void { + self.vtable.packet_deliver(self, packet, path); + } + + fn packet_delay(self: *PacketSimulator, packet: Packet, path: Path) Duration { + return self.vtable.packet_delay(self, packet, path); + } + + /// Return a value produced using an exponential distribution with + /// the minimum and mean specified in self.options + fn packet_delay_default(self: *PacketSimulator, _: Packet, _: Path) Duration { + const min = self.options.one_way_delay_min; + const mean = self.options.one_way_delay_mean; + return .{ + .ns = @max(min.ns, fuzz.random_int_exponential(&self.prng, u64, mean.ns)), + }; + } + }; +} diff --git a/ocam/src/testing/reply_sequence.zig b/ocam/src/testing/reply_sequence.zig new file mode 100644 index 00000000..2b39e1bd --- /dev/null +++ b/ocam/src/testing/reply_sequence.zig @@ -0,0 +1,154 @@ +//! Replies from the cluster may arrive out-of-order; the ReplySequence reassembles them in the +//! correct order (by ascending op number). +const std = @import("std"); +const assert = std.debug.assert; +const maybe = stdx.maybe; + +const stdx = @import("stdx"); +const constants = @import("../constants.zig"); +const MessagePool = @import("../message_pool.zig").MessagePool; +const Message = MessagePool.Message; + +const PriorityQueue = std.PriorityQueue; + +/// Both messages belong to the ReplySequence's `MessagePool`. +const PendingReply = struct { + /// `client_index` is null when the prepare does not originate from a client. + client_index: ?usize, + prepare: *Message.Prepare, + reply: *Message.Reply, + + /// `PendingReply`s are ordered by ascending reply op. + fn compare(context: void, a: PendingReply, b: PendingReply) std.math.Order { + _ = context; + return std.math.order(a.reply.header.op, b.reply.header.op); + } +}; + +const PendingReplyQueue = PriorityQueue(PendingReply, void, PendingReply.compare); + +pub const ReplySequence = struct { + /// Reply messages (from cluster to client) may be reordered during transit. + /// The ReplySequence must reassemble them in the original order (ascending op/commit + /// number) before handing them off to the Workload for verification. + /// + /// `ReplySequence.stalled_queue` hold replies (and corresponding prepares) that are + /// waiting to be processed. + pub const stalled_queue_capacity = + constants.clients_max * constants.client_request_queue_max * 2; + + message_pool: MessagePool, + + /// The list of messages waiting to be verified (the reply for a lower op has not yet arrived). + /// Includes `register` messages. + stalled_queue: PendingReplyQueue, + + pub fn init(allocator: std.mem.Allocator) !ReplySequence { + // *2 for PendingReply.prepare and PendingReply.reply. + var message_pool = try MessagePool.init_capacity(allocator, stalled_queue_capacity * 2); + errdefer message_pool.deinit(allocator); + + var stalled_queue = PendingReplyQueue.init(allocator, {}); + errdefer stalled_queue.deinit(); + try stalled_queue.ensureTotalCapacity(stalled_queue_capacity); + + return ReplySequence{ + .message_pool = message_pool, + .stalled_queue = stalled_queue, + }; + } + + pub fn deinit(sequence: *ReplySequence, allocator: std.mem.Allocator) void { + while (sequence.stalled_queue.removeOrNull()) |pending| { + sequence.message_pool.unref(pending.prepare); + sequence.message_pool.unref(pending.reply); + } + sequence.stalled_queue.deinit(); + sequence.message_pool.deinit(allocator); + } + + pub fn empty(sequence: *const ReplySequence) bool { + return sequence.stalled_queue.count() == 0; + } + + pub fn free(sequence: ReplySequence) usize { + return stalled_queue_capacity - sequence.stalled_queue.count(); + } + + pub fn insert( + sequence: *ReplySequence, + client_index: ?usize, + prepare_message: *const Message.Prepare, + reply_message: *const Message.Reply, + ) void { + assert(sequence.stalled_queue.count() < stalled_queue_capacity); + + assert(prepare_message.header.invalid() == null); + assert(prepare_message.header.command == .prepare); + + // The ReplySequence includes "replies" that don't actually get sent to a client (e.g. + // upgrade/pulse replies). + maybe(reply_message.header.invalid() == null); + assert((reply_message.header.client == 0) == (client_index == null)); + assert(reply_message.header.client == prepare_message.header.client); + assert(reply_message.header.request == prepare_message.header.request); + assert(reply_message.header.command == .reply); + assert(reply_message.header.operation == prepare_message.header.operation); + assert(reply_message.header.op == prepare_message.header.op); + + var pending_replies = sequence.stalled_queue.iterator(); + while (pending_replies.next()) |pending| { + assert(reply_message.header.op != pending.reply.header.op); + } + + sequence.stalled_queue.add(.{ + .client_index = client_index, + .prepare = sequence.clone_message(prepare_message.base_const()).into(.prepare).?, + .reply = sequence.clone_message(reply_message.base_const()).into(.reply).?, + }) catch unreachable; + } + + pub fn contains(sequence: *ReplySequence, reply: *const Message.Reply) bool { + assert(reply.header.command == .reply); + + var pending_replies = sequence.stalled_queue.iterator(); + while (pending_replies.next()) |pending| { + if (reply.header.op == pending.reply.header.op) { + assert(reply.header.checksum == pending.reply.header.checksum); + return true; + } + } + return false; + } + + // TODO(Zig): This type signature could be *const once std.PriorityQueue.peek() is updated. + pub fn peek(sequence: *ReplySequence, op: u64) ?PendingReply { + assert(sequence.stalled_queue.count() <= stalled_queue_capacity); + + const commit = sequence.stalled_queue.peek() orelse return null; + if (commit.reply.header.op == op) { + return commit; + } else { + assert(commit.reply.header.op > op); + return null; + } + } + + pub fn next(sequence: *ReplySequence) void { + const commit = sequence.stalled_queue.remove(); + sequence.message_pool.unref(commit.reply); + sequence.message_pool.unref(commit.prepare); + } + + /// Copy the message from a Client's MessagePool to the ReplySequence's MessagePool. + /// + /// The client has a finite amount of messages in its pool, and the ReplySequence needs to hold + /// onto prepares/replies until all preceding prepares/replies have arrived. + /// + /// Returns the ReplySequence's message. + fn clone_message(sequence: *ReplySequence, message_client: *const Message) *Message { + const message_sequence = sequence.message_pool.get_message(null); + stdx.copy_disjoint(.exact, u8, message_sequence.buffer, message_client.buffer); + return message_sequence; + } +}; diff --git a/ocam/src/testing/state_machine.zig b/ocam/src/testing/state_machine.zig new file mode 100644 index 00000000..87a10e6f --- /dev/null +++ b/ocam/src/testing/state_machine.zig @@ -0,0 +1,394 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const stdx = @import("stdx"); +const vsr = @import("../vsr.zig"); +const constants = @import("../constants.zig"); +const GrooveType = @import("../lsm/groove.zig").GrooveType; +const ForestType = @import("../lsm/forest.zig").ForestType; + +pub fn StateMachineType(comptime Storage: type) type { + return struct { + const StateMachine = @This(); + const Grid = @import("../vsr/grid.zig").GridType(Storage); + + pub const Workload = WorkloadType(StateMachine); + + pub const Operation = enum(u8) { + echo = constants.vsr_operations_reserved + 0, + + pub fn EventType(comptime _: Operation) type { + return u8; // Must be non-zero-sized for sliceAsBytes(). + } + + pub fn ResultType(comptime _: Operation) type { + return u8; // Must be non-zero-sized for sliceAsBytes(). + } + + pub fn result_size(_: Operation) u32 { + return @sizeOf(u8); + } + + pub fn event_size(_: Operation) u32 { + return @sizeOf(u8); + } + + pub fn from_vsr(operation: vsr.Operation) ?Operation { + if (operation.vsr_reserved()) return null; + return vsr.Operation.to(Operation, operation); + } + + pub fn to_vsr(operation: Operation) vsr.Operation { + return vsr.Operation.from(Operation, operation); + } + }; + + pub const Options = struct { + batch_size_limit: u32, + lsm_forest_compaction_block_count: u32 = Forest.Options.compaction_block_count_min, + lsm_forest_node_count: u32, + }; + + pub const Forest = ForestType(Storage, .{ .things = ThingGroove }); + + const ThingGroove = GrooveType( + Storage, + Thing, + .{ + .ids = .{ + .timestamp = 1, + .id = 2, + .value = 3, + }, + .batch_value_count_max = .{ + .timestamp = 1, + .id = 1, + .value = 1, + }, + .primary_key = "id", + .primary_key_orphaned = false, + .unique_keys = &[_][:0]const u8{"id"}, + .ignored = &[_][:0]const u8{}, + .optional = &[_][:0]const u8{}, + .derived = .{}, + .objects_cache = true, + }, + ); + + const Thing = extern struct { + timestamp: u64, + value: u64, + id: u128, + }; + + options: Options, + forest: Forest, + + prefetch_timestamp: u64 = 0, + prepare_timestamp: u64 = 0, + commit_timestamp: u64 = 0, + + prefetch_context: ThingGroove.PrefetchContext = undefined, + callback: ?*const fn (state_machine: *StateMachine) void = null, + + pub fn init( + self: *StateMachine, + allocator: std.mem.Allocator, + time: vsr.time.Time, + grid: *Grid, + options: Options, + ) !void { + _ = time; + self.* = .{ + .options = options, + .forest = undefined, + }; + + const things_cache_entries_max = + ThingGroove.ObjectsCache.Cache.value_count_max_multiple; + + try self.forest.init( + allocator, + grid, + .{ + .compaction_block_count = options.lsm_forest_compaction_block_count, + .node_count = options.lsm_forest_node_count, + }, + .{ + .things = .{ + .cache_entries_max = things_cache_entries_max, + .prefetch_entries_for_read_max = 0, + .prefetch_entries_for_update_max = 1, + .tree_options_object = .{ .batch_value_count_limit = 1 }, + .tree_options_index = .{ + .id = .{ .batch_value_count_limit = 1 }, + .value = .{ .batch_value_count_limit = 1 }, + }, + }, + }, + ); + errdefer self.forest.deinit(allocator); + } + + pub fn deinit(state_machine: *StateMachine, allocator: std.mem.Allocator) void { + state_machine.forest.deinit(allocator); + } + + pub fn reset(state_machine: *StateMachine) void { + state_machine.forest.reset(); + + state_machine.* = .{ + .options = state_machine.options, + .forest = state_machine.forest, + }; + } + + pub fn open(state_machine: *StateMachine, callback: *const fn (*StateMachine) void) void { + assert(state_machine.callback == null); + + state_machine.callback = callback; + state_machine.forest.open(open_callback); + } + + fn open_callback(forest: *Forest) void { + const state_machine: *StateMachine = @fieldParentPtr("forest", forest); + const callback = state_machine.callback.?; + state_machine.callback = null; + + callback(state_machine); + } + + pub fn pulse_needed(state_machine: *const StateMachine, timestamp: u64) bool { + _ = state_machine; + _ = timestamp; + return false; + } + + pub fn input_valid( + state_machine: *const StateMachine, + operation: Operation, + input: []align(constants.cache_line_size) const u8, + ) bool { + _ = state_machine; + _ = operation; + _ = input; + return true; + } + + pub fn prepare( + state_machine: *StateMachine, + operation: Operation, + input: []align(constants.cache_line_size) const u8, + ) void { + _ = state_machine; + _ = operation; + _ = input; + } + + pub fn prefetch( + state_machine: *StateMachine, + callback: *const fn (*StateMachine) void, + op: u64, + snapshot: u64, + operation: Operation, + input: []align(constants.cache_line_size) const u8, + ) void { + _ = operation; + _ = input; + + assert(state_machine.callback == null); + state_machine.callback = callback; + + // TODO(Snapshots) Pass in the target snapshot. + state_machine.forest.grooves.things.prefetch_setup(snapshot); + state_machine.forest.grooves.things.prefetch_enqueue(.{ .id = op }); + state_machine.forest.grooves.things.prefetch( + prefetch_callback, + &state_machine.prefetch_context, + ); + } + + fn prefetch_callback(completion: *ThingGroove.PrefetchContext) void { + const state_machine: *StateMachine = + @alignCast(@fieldParentPtr("prefetch_context", completion)); + const callback = state_machine.callback.?; + state_machine.callback = null; + + callback(state_machine); + } + + pub fn commit( + state_machine: *StateMachine, + client: u128, + op: u64, + timestamp: u64, + operation: Operation, + input: []align(constants.cache_line_size) const u8, + output: *align(constants.cache_line_size) [constants.message_body_size_max]u8, + ) usize { + assert(op != 0); + + switch (operation) { + .echo => { + assert(state_machine.forest.grooves.things.get(op) == .not_found); + + var value = vsr.ChecksumStream.init(); + value.add(std.mem.asBytes(&client)); + value.add(std.mem.asBytes(&op)); + value.add(std.mem.asBytes(×tamp)); + value.add(std.mem.asBytes(&operation)); + value.add(input); + + state_machine.forest.grooves.things.insert(&.{ + .timestamp = timestamp, + .id = op, + .value = @as(u64, @truncate(value.checksum())), + }); + + stdx.copy_disjoint(.inexact, u8, output, input); + return input.len; + }, + } + } + + pub fn compact( + state_machine: *StateMachine, + callback: *const fn (*StateMachine) void, + op: u64, + ) void { + assert(op != 0); + assert(state_machine.callback == null); + + state_machine.callback = callback; + state_machine.forest.compact(compact_callback, op); + } + + fn compact_callback(forest: *Forest) void { + const state_machine: *StateMachine = @fieldParentPtr("forest", forest); + const callback = state_machine.callback.?; + state_machine.callback = null; + + callback(state_machine); + } + + pub fn checkpoint( + state_machine: *StateMachine, + callback: *const fn (*StateMachine) void, + ) void { + assert(state_machine.callback == null); + + state_machine.callback = callback; + state_machine.forest.checkpoint(checkpoint_callback); + } + + fn checkpoint_callback(forest: *Forest) void { + const state_machine: *StateMachine = @fieldParentPtr("forest", forest); + const callback = state_machine.callback.?; + state_machine.callback = null; + + callback(state_machine); + } + }; +} + +fn WorkloadType(comptime StateMachine: type) type { + return struct { + const Workload = @This(); + + prng: *stdx.PRNG, + options: Options, + requests_sent: usize = 0, + requests_delivered: usize = 0, + + pub fn init( + allocator: std.mem.Allocator, + prng: *stdx.PRNG, + options: Options, + ) !Workload { + _ = allocator; + + return Workload{ + .prng = prng, + .options = options, + }; + } + + pub fn deinit(workload: *Workload, allocator: std.mem.Allocator) void { + _ = workload; + _ = allocator; + } + + pub fn done(workload: *const Workload) bool { + return workload.requests_sent == workload.requests_delivered; + } + + pub fn build_request( + workload: *Workload, + client_index: usize, + body: []align(constants.cache_line_size) u8, + ) struct { + operation: StateMachine.Operation, + size: usize, + } { + _ = client_index; + + workload.requests_sent += 1; + + const size = workload.prng.int_inclusive(usize, workload.options.batch_size_limit); + workload.prng.fill(body[0..size]); + + return .{ + .operation = .echo, + .size = size, + }; + } + + pub fn on_reply( + workload: *Workload, + client_index: usize, + operation: StateMachine.Operation, + timestamp: u64, + request_body: []align(constants.cache_line_size) const u8, + reply_body: []align(constants.cache_line_size) const u8, + ) void { + _ = client_index; + _ = timestamp; + + workload.requests_delivered += 1; + assert(workload.requests_delivered <= workload.requests_sent); + + assert(operation == .echo); + assert(std.mem.eql(u8, request_body, reply_body)); + } + + pub fn on_pulse( + workload: *Workload, + operation: StateMachine.Operation, + timestamp: u64, + ) void { + _ = workload; + _ = operation; + _ = timestamp; + + // This state machine does not implement a pulse operation. + unreachable; + } + + pub const Options = struct { + batch_size_limit: u32, + + pub fn generate(prng: *stdx.PRNG, options: struct { + batch_size_limit: u32, + multi_batch_per_request_limit: u32, + client_count: usize, + in_flight_max: usize, + }) Options { + _ = prng; + + return .{ + .batch_size_limit = options.batch_size_limit, + }; + } + }; + }; +} diff --git a/ocam/src/testing/storage.zig b/ocam/src/testing/storage.zig new file mode 100644 index 00000000..30289ef1 --- /dev/null +++ b/ocam/src/testing/storage.zig @@ -0,0 +1,1224 @@ +//! In-memory storage, with simulated faults and latency. +//! +//! +//! Fault Injection +//! +//! Storage injects faults that a fully-connected cluster can (i.e. should be able to) recover from. +//! Each zone can tolerate a different pattern of faults. +//! +//! - superblock: +//! - One read/write fault is permitted per area (section, free set, …). +//! - An additional fault is permitted at the target of a pending write during a crash. +//! +//! - wal_headers, wal_prepares: +//! - Read/write faults are distributed between replicas according to ClusterFaultAtlas, to ensure +//! that at least one replica will have a valid copy to help others repair. +//! (See: generate_faulty_wal_areas()). +//! - When a replica crashes, it may fault the WAL outside of ClusterFaultAtlas. +//! - When replica_count=1, its WAL can only be corrupted by a crash, never a read/write. +//! (When replica_count=1, there are no other replicas to assist with repair). +//! +//! - grid: +//! - Similarly to prepares and headers, ClusterFaultAtlas ensures that at least one replica will +//! have a block. +//! - When replica_count≤2, grid faults are disabled. +//! +const std = @import("std"); +const assert = std.debug.assert; +const panic = std.debug.panic; +const math = std.math; +const mem = std.mem; +const Ratio = stdx.PRNG.Ratio; +const Duration = stdx.Duration; +const Instant = stdx.Instant; + +const QueueType = @import("../queue.zig").QueueType; +const IOPSType = stdx.IOPSType; +const constants = @import("../constants.zig"); +const vsr = @import("../vsr.zig"); +const superblock = @import("../vsr/superblock.zig"); +const FreeSet = @import("../vsr/free_set.zig").FreeSet; +const schema = @import("../lsm/schema.zig"); +const stdx = @import("stdx"); +const maybe = stdx.maybe; +const fuzz = @import("./fuzz.zig"); +const GridChecker = @import("./cluster/grid_checker.zig").GridChecker; + +const log = std.log.scoped(.storage); + +pub const Storage = struct { + /// Options for fault injection during fuzz testing + pub const Options = struct { + size: u64, + /// Seed for the storage PRNG. + seed: u64 = 0, + + /// Required when `fault_atlas` is set. + replica_index: ?u8 = null, + + /// Minimum number of ticks it may take to read data. + read_latency_min: Duration = .{ .ns = 0 }, + /// Average number of ticks it may take to read data. Must be >= read_latency_min. + read_latency_mean: Duration = .{ .ns = 0 }, + /// Minimum number of ticks it may take to write data. + write_latency_min: Duration = .{ .ns = 0 }, + /// Average number of ticks it may take to write data. Must be >= write_latency_min. + write_latency_mean: Duration = .{ .ns = 0 }, + + /// Chance out of 100 that a read will corrupt a sector, if the target memory is within + /// a faulty area of this replica. + read_fault_probability: Ratio = Ratio.zero(), + /// Chance out of 100 that a write will corrupt a sector, if the target memory is within + /// a faulty area of this replica. + write_fault_probability: Ratio = Ratio.zero(), + /// Chance out of 100 that a write will misdirect to the wrong sector, if the target memory + /// is within a faulty area of this replica. + write_misdirect_probability: Ratio = Ratio.zero(), + /// Chance out of 100 that a crash will corrupt a sector of a pending write's target, + /// if the target memory is within a faulty area of this replica. + crash_fault_probability: Ratio = Ratio.zero(), + + /// Enable/disable automatic read/write faults. + /// Does not impact crash faults or manual faults. + fault_atlas: ?*const ClusterFaultAtlas = null, + + /// Accessed by the Grid for extra verification of grid coherence. + grid_checker: ?*GridChecker = null, + + iops_read_max: u64 = constants.iops_read_max, + iops_write_max: u64 = constants.iops_write_max, + }; + + /// See usage in Journal.write_sectors() for details. + /// TODO: allow testing in both modes. + pub const synchronicity: enum { + always_synchronous, + always_asynchronous, + } = .always_asynchronous; + + pub const Read = struct { + callback: *const fn (read: *Storage.Read) void, + buffer: []u8, + zone: vsr.Zone, + /// Relative offset within the zone. + offset: u64, + /// Tick at which this read is considered "completed" and the callback should be called. + ready_at: Instant, + stack_trace: StackTrace, + + fn less_than(_: void, a: *Read, b: *Read) math.Order { + return math.order(a.ready_at.ns, b.ready_at.ns); + } + }; + + pub const Write = struct { + callback: *const fn (write: *Storage.Write) void, + buffer: []const u8, + zone: vsr.Zone, + /// Relative offset within the zone. + offset: u64, + ready_at: Instant, + stack_trace: StackTrace, + + fn less_than(_: void, a: *Write, b: *Write) math.Order { + return math.order(a.ready_at.ns, b.ready_at.ns); + } + }; + + pub const NextTick = struct { + link: QueueType(NextTick).Link = .{}, + source: NextTickSource, + callback: *const fn (next_tick: *NextTick) void, + }; + + pub const NextTickSource = enum { lsm, vsr }; + + pub const Tracer = vsr.trace.Tracer; + + /// See `Storage.overlays`. + const overlays_count_max = 2; + + const OverlayBuffers = [overlays_count_max][constants.message_size_max]u8; + + allocator: mem.Allocator, + + size: u64, + options: Options, + prng: stdx.PRNG, + + /// `memory` always contains the pristine data as-written -- it does not include storage faults. + memory: []align(constants.sector_size) u8, + /// Set bits correspond to sectors that have ever been written to. + memory_written: std.DynamicBitSetUnmanaged, + /// Set bits correspond to faulty sectors. The underlying sectors of `memory` is left clean. + faults: std.DynamicBitSetUnmanaged, + + /// Overlays take precedence over the (pristine) data in `memory`. + /// + /// Each misdirected write creates two overlays. + /// When a misdirected write is triggered: + /// - The intended target is overlaid with its old data. + /// - The intended target's `memory` is set to the `write.buffer` data. + /// - The mistaken target is overlaid with the `write.buffer` data. + /// - The mistaken target's `memory` is left untouched. + /// + /// The reason for all of this is: + /// - By keeping `memory` pristine, we can trivially disable both sides of the misdirected-write + /// fault by flipping the `faulty` flag. + /// - By tracking the overlays separately, they can be repaired separately. + /// + /// Other notes: + /// - We allow for (at most) one misdirect fault per Storage for the time being, for simplicity + /// and because double-faults are not covered by our fault model. This will hopefully match + /// physical disks – misdirected faults are an order of magnitude less frequent than bit rot, + /// which in turn is an order of magnitude less frequent than LSEs. + /// - In order to keep things interesting: + /// - misdirections are always within the same zone, + /// - the entire write is misdirected (rather than only some of the sectors), and + /// - the misdirected write lands on a convenient offset. + /// Thanks to rigorous checksums, misdirections that break these rules just manifest as + /// corruptions, and corruption is already well-tested (see `faults`). The goal here is to + /// test how TigerBeetle handles well-formed but incorrectly-located data. + /// TODO: Suppose cross-zone misdirects to help find cases where we don't check `command`. + overlays: IOPSType(struct { zone: vsr.Zone, offset: u64, size: u32 }, overlays_count_max) = .{}, + overlay_buffers: *align(constants.sector_size) OverlayBuffers, + + /// Whether to enable faults (when false, this supersedes `faulty_wal_areas` &c). + /// This is used to disable faults during the replica's first startup. + faulty: bool = true, + + reads: std.PriorityQueue(*Storage.Read, void, Storage.Read.less_than), + writes: std.PriorityQueue(*Storage.Write, void, Storage.Write.less_than), + + ticks: u64 = 0, + next_tick_queue: QueueType(NextTick) = QueueType(NextTick).init(.{ + .name = "storage_next_tick", + }), + + pub fn init(allocator: mem.Allocator, options: Storage.Options) !Storage { + assert(options.size <= constants.storage_size_limit_max); + assert(options.write_latency_mean.ns >= options.write_latency_min.ns); + assert(options.read_latency_mean.ns >= options.read_latency_min.ns); + if (options.fault_atlas != null) assert(options.replica_index != null); + + const prng = stdx.PRNG.from_seed(options.seed); + const sector_count = @divExact(options.size, constants.sector_size); + const memory = try allocator.alignedAlloc(u8, constants.sector_size, options.size); + errdefer allocator.free(memory); + + var memory_written = try std.DynamicBitSetUnmanaged.initEmpty(allocator, sector_count); + errdefer memory_written.deinit(allocator); + + var faults = try std.DynamicBitSetUnmanaged.initEmpty(allocator, sector_count); + errdefer faults.deinit(allocator); + + const overlay_buffers_alloc = + try allocator.alignedAlloc(u8, constants.sector_size, @sizeOf(OverlayBuffers)); + const overlay_buffers = std.mem.bytesAsValue(OverlayBuffers, overlay_buffers_alloc); + errdefer allocator.destroy(overlay_buffers); + + var reads = std.PriorityQueue(*Storage.Read, void, Storage.Read.less_than) + .init(allocator, {}); + errdefer reads.deinit(); + + try reads.ensureTotalCapacity(options.iops_read_max); + + var writes = std.PriorityQueue(*Storage.Write, void, Storage.Write.less_than) + .init(allocator, {}); + errdefer writes.deinit(); + + try writes.ensureTotalCapacity(options.iops_write_max); + + return Storage{ + .allocator = allocator, + .size = options.size, + .options = options, + .prng = prng, + .memory = memory, + .memory_written = memory_written, + .faults = faults, + .overlay_buffers = overlay_buffers, + .reads = reads, + .writes = writes, + }; + } + + pub fn deinit(storage: *Storage, allocator: mem.Allocator) void { + storage.writes.deinit(); + storage.reads.deinit(); + allocator.destroy(storage.overlay_buffers); + storage.faults.deinit(allocator); + storage.memory_written.deinit(allocator); + allocator.free(storage.memory); + } + + /// Cancel any currently in-progress reads/writes. + /// Corrupt the target sectors of any in-progress writes. + pub fn reset(storage: *Storage) void { + log.debug("Reset: {} pending reads, {} pending writes, {} pending next_ticks", .{ + storage.reads.count(), + storage.writes.count(), + storage.next_tick_queue.count(), + }); + while (storage.writes.removeOrNull()) |write| { + if (storage.prng.chance(storage.options.crash_fault_probability)) { + // Randomly corrupt one of the faulty sectors the operation targeted. + // TODO: inject more realistic and varied storage faults as described above. + const sectors = SectorRange.from_zone(write.zone, write.offset, write.buffer.len); + storage.fault_sector(write.zone, sectors.random(&storage.prng)); + } + } + while (storage.reads.removeOrNull()) |_| {} + storage.next_tick_queue.reset(); + + assert(storage.writes.count() == 0); + assert(storage.reads.count() == 0); + assert(storage.next_tick_queue.count() == 0); + } + + /// Compile-time upper bound on the size of a grid of a testing Storage. + pub const grid_blocks_max = + grid_blocks_for_storage_size(constants.storage_size_limit_max); + + /// Runtime bound on the size of the grid of a testing Storage. + pub fn grid_blocks(storage: *const Storage) u64 { + return grid_blocks_for_storage_size(storage.size); + } + + /// How many grid blocks fit in the Storage of the specified size. + fn grid_blocks_for_storage_size(size: u64) u64 { + assert(size <= constants.storage_size_limit_max); + const free_set_shard_count = @divFloor( + size - superblock.data_file_size_min, + constants.block_size * FreeSet.shard_bits, + ); + return free_set_shard_count * FreeSet.shard_bits; + } + + /// Returns the number of bytes that have been written to, assuming that (the simulated) + /// `fallocate()` creates a sparse file. + pub fn size_used(storage: *const Storage) usize { + return storage.memory_written.count() * constants.sector_size; + } + + /// Copy state from `origin` to `storage`: + /// + /// - ticks + /// - memory + /// - occupied memory + /// - faulty sectors + /// - reads in-progress + /// - writes in-progress + /// + /// Both instances must have an identical size. + pub fn copy(storage: *Storage, origin: *const Storage) void { + assert(storage.size == origin.size); + + storage.ticks = origin.ticks; + + var it = origin.memory_written.iterator(.{}); + while (it.next()) |sector| { + stdx.copy_disjoint( + .exact, + u8, + storage.memory[sector * constants.sector_size ..][0..constants.sector_size], + origin.memory[sector * constants.sector_size ..][0..constants.sector_size], + ); + } + storage.memory_written.toggleSet(storage.memory_written); + storage.memory_written.toggleSet(origin.memory_written); + storage.faults.toggleSet(storage.faults); + storage.faults.toggleSet(origin.faults); + + storage.reads.items.len = 0; + for (origin.reads.items) |read| { + storage.reads.add(read) catch unreachable; + } + + storage.writes.items.len = 0; + for (origin.writes.items) |write| { + storage.writes.add(write) catch unreachable; + } + } + + pub fn step(storage: *Storage) bool { + var advanced = false; + + const read_ready_at_ns = + if (storage.reads.peek()) |read| read.ready_at.ns else std.math.maxInt(u64); + const write_ready_at_ns = + if (storage.writes.peek()) |write| write.ready_at.ns else std.math.maxInt(u64); + if (read_ready_at_ns <= storage.tick_instant().ns and + read_ready_at_ns <= write_ready_at_ns) + { + const read = storage.reads.remove(); + storage.read_sectors_finish(read); + advanced = true; + } else if (write_ready_at_ns <= storage.tick_instant().ns and + write_ready_at_ns <= read_ready_at_ns) + { + const write = storage.writes.remove(); + storage.write_sectors_finish(write); + advanced = true; + } + + // Process the queues in a single loop, since their callbacks may append to each other. + while (storage.next_tick_queue.pop()) |next_tick| { + advanced = true; + next_tick.callback(next_tick); + } + return advanced; + } + + pub fn run(storage: *Storage) void { + while (storage.step()) {} + storage.tick(); + } + + pub fn tick(storage: *Storage) void { + storage.ticks += 1; + } + + pub fn on_next_tick( + storage: *Storage, + source: NextTickSource, + callback: *const fn (next_tick: *Storage.NextTick) void, + next_tick: *Storage.NextTick, + ) void { + next_tick.* = .{ + .source = source, + .callback = callback, + }; + + storage.next_tick_queue.push(next_tick); + } + + pub fn reset_next_tick_lsm(storage: *Storage) void { + var next_tick_iterator = storage.next_tick_queue; + storage.next_tick_queue.reset(); + + while (next_tick_iterator.pop()) |next_tick| { + if (next_tick.source != .lsm) storage.next_tick_queue.push(next_tick); + } + } + + /// * Verifies that the read fits within the target sector. + /// * Verifies that the read targets sectors that have been written to. + pub fn read_sectors( + storage: *Storage, + callback: *const fn (read: *Storage.Read) void, + read: *Storage.Read, + buffer: []u8, + zone: vsr.Zone, + offset_in_zone: u64, + ) void { + zone.verify_iop(buffer, offset_in_zone); + assert(zone != .grid_padding); + + switch (zone) { + .superblock, + .wal_headers, + .wal_prepares, + => { + var sectors = SectorRange.from_zone(zone, offset_in_zone, buffer.len); + while (sectors.next()) |sector| assert(storage.memory_written.isSet(sector)); + }, + .grid_padding => unreachable, + .client_replies, .grid => { + // ClientReplies/Grid repairs can read blocks that have not ever been written. + // (The former case is possible if we sync to a new superblock and someone requests + // a client reply that we haven't repaired yet.) + }, + } + + read.* = .{ + .callback = callback, + .buffer = buffer, + .zone = zone, + .offset = offset_in_zone, + .ready_at = storage.tick_instant().add(storage.read_latency()), + .stack_trace = StackTrace.capture(), + }; + + // We ensure the capacity is sufficient for constants.iops_read_max in init() + storage.reads.add(read) catch unreachable; + } + + fn read_sectors_finish(storage: *Storage, read: *Storage.Read) void { + const offset_in_storage = read.zone.offset(read.offset); + stdx.copy_disjoint( + .exact, + u8, + read.buffer, + storage.memory[offset_in_storage..][0..read.buffer.len], + ); + + if (storage.prng.chance(storage.options.read_fault_probability)) { + if (storage.pick_faulty_sector(read.zone, read.offset, read.buffer.len)) |sector| { + storage.fault_sector(read.zone, sector); + } + } + + const faults_eligible = storage.read_sectors_fault_eligible(read); + + var sectors = SectorRange.from_zone(read.zone, read.offset, read.buffer.len); + const sectors_min = sectors.min; + while (sectors.next()) |sector| { + const sector_offset = (sector - sectors_min) * constants.sector_size; + const sector_bytes = read.buffer[sector_offset..][0..constants.sector_size]; + const sector_corrupt = faults_eligible != .none and storage.faults.isSet(sector); + const sector_uninitialized = !storage.memory_written.isSet(sector); + + if (sector_corrupt) { + // Rather than corrupting the entire sector, inject a localized error. + // (In some cases this will just corrupt sector padding.) + // Inject the fault at a deterministic position (by using the pristine bytes as + // consistent seed) so that read-retries don't resolve the corruption. + const corrupt_seed: u64 = @bitCast(sector_bytes[0..@sizeOf(u64)].*); + var corrupt_prng = stdx.PRNG.from_seed(corrupt_seed); + const corrupt_byte = corrupt_prng.index(sector_bytes); + sector_bytes[corrupt_byte] ^= corrupt_prng.bit(u8); + } + + if (sector_uninitialized) { + storage.prng.fill(sector_bytes); + } + } + + // Apply misdirected data. + if (faults_eligible == .corrupt_or_misdirect) { + var overlays_iterator = storage.overlays.iterate(); + while (overlays_iterator.next()) |overlay| { + if (overlay.zone == read.zone and + overlay.offset == read.offset) + { + log.debug("{}: read_sectors_finish: apply misdirect " ++ + "zone={s} offset={} size={}", .{ + storage.options.replica_index.?, + @tagName(overlay.zone), + overlay.offset, + overlay.size, + }); + + const overlay_index = storage.overlays.index(overlay); + const overlay_buffer = &storage.overlay_buffers[overlay_index]; + const overlay_target = overlay_buffer[0..@min(overlay.size, read.buffer.len)]; + stdx.copy_disjoint(.inexact, u8, read.buffer, overlay_target); + } + } + } + + read.callback(read); + } + + fn read_sectors_fault_eligible(storage: *const Storage, read: *const Storage.Read) enum { + none, + corrupt, + corrupt_or_misdirect, + } { + if (!storage.faulty) return .none; + + if (read.zone == .wal_prepares) { + const header_slot = @divExact(read.offset, constants.message_size_max); + const header_offset = vsr.sector_floor(header_slot * @sizeOf(vsr.Header)); + + { + // Don't fault a WAL prepare if the corresponding WAL header write was misdirected, + // to avoid a double-fault which the journal interprets as a torn prepare. + // TODO If in our fault tracking we distinguish between "torn writes" injected by + // reset() and simulated LSE's/bitrot, then we could allow the former in this case. + var overlays_iterator = storage.overlays.iterate_const(); + while (overlays_iterator.next()) |overlay| { + if (overlay.zone == .wal_headers and overlay.offset == header_offset) { + return .none; + } + } + } + + { + // Don't misdirect a WAL prepare if the corresponding WAL header doesn't match or is + // corrupt, to avoid a double-fault in which the journal tries to `fix` the old + // prepare. + const wal_header = &storage.wal_headers()[header_slot]; + const wal_prepare = &storage.wal_prepares()[header_slot]; + if (wal_header.checksum != wal_prepare.header.checksum) { + return .corrupt; + } + + const wal_sector = + @divFloor(vsr.Zone.wal_headers.start() + header_offset, constants.sector_size); + if (storage.faults.isSet(wal_sector)) { + return .corrupt; + } + } + } + + return .corrupt_or_misdirect; + } + + pub fn write_sectors( + storage: *Storage, + callback: *const fn (write: *Storage.Write) void, + write: *Storage.Write, + buffer: []const u8, + zone: vsr.Zone, + offset_in_zone: u64, + ) void { + zone.verify_iop(buffer, offset_in_zone); + maybe(zone == .grid_padding); // Padding is zeroed during format. + + // Verify that there are no concurrent overlapping writes. + for (storage.writes.items) |other| { + if (other.zone != zone) continue; + assert(offset_in_zone + buffer.len <= other.offset or + other.offset + other.buffer.len <= offset_in_zone); + } + + write.* = .{ + .callback = callback, + .buffer = buffer, + .zone = zone, + .offset = offset_in_zone, + .ready_at = storage.tick_instant().add(storage.write_latency()), + .stack_trace = StackTrace.capture(), + }; + + // We ensure the capacity is sufficient for constants.iops_write_max in init() + storage.writes.add(write) catch unreachable; + } + + fn write_sectors_finish(storage: *Storage, write: *Storage.Write) void { + storage.write_sectors_finish_overlay_misdirect(write); + + var sectors = SectorRange.from_zone(write.zone, write.offset, write.buffer.len); + while (sectors.next()) |sector| { + storage.faults.unset(sector); + storage.memory_written.set(sector); + } + + if (storage.prng.chance(storage.options.write_fault_probability)) { + if (storage.pick_faulty_sector(write.zone, write.offset, write.buffer.len)) |sector| { + storage.fault_sector(write.zone, sector); + } + } + + const offset_in_storage = write.zone.offset(write.offset); + stdx.copy_disjoint( + .exact, + u8, + storage.memory[offset_in_storage..][0..write.buffer.len], + write.buffer, + ); + + write.callback(write); + } + + fn write_sectors_finish_overlay_misdirect(storage: *Storage, write: *Storage.Write) void { + assert(storage.overlays.total() >= 2); + // Clean up old misdirects if they are overwritten. + var overlays_iterator = storage.overlays.iterate(); + while (overlays_iterator.next()) |overlay| { + if (overlay.zone == write.zone and + overlay.offset == write.offset) + { + storage.overlays.release(overlay); + } + } + + // Apply a new misdirect. + const misdirect = storage.overlays.available() >= 2 and + storage.pick_faulty_sector(write.zone, write.offset, write.buffer.len) != null and + storage.prng.chance(storage.options.write_misdirect_probability); + const misdirect_offset = if (misdirect) storage.pick_faulty_chunk_offset(write) else null; + if (misdirect_offset) |mistaken_offset| { + assert(mistaken_offset != write.offset); + + const overlay_mistaken = storage.overlays.acquire().?; + const overlay_intended = storage.overlays.acquire().?; + + const overlay_mistaken_index = storage.overlays.index(overlay_mistaken); + const overlay_intended_index = storage.overlays.index(overlay_intended); + + log.debug("{}: write_sectors_finish: misdirect zone={s} offset={}->{} size={}", .{ + storage.options.replica_index.?, + @tagName(write.zone), + write.offset, + mistaken_offset, + write.buffer.len, + }); + + const overlay_size: u32 = @intCast(write.buffer.len); + overlay_mistaken.* = + .{ .zone = write.zone, .offset = mistaken_offset, .size = overlay_size }; + overlay_intended.* = + .{ .zone = write.zone, .offset = write.offset, .size = overlay_size }; + + const overlay_mistaken_buffer = &storage.overlay_buffers[overlay_mistaken_index]; + const overlay_intended_buffer = &storage.overlay_buffers[overlay_intended_index]; + const target_intended_buffer = + storage.memory[write.zone.offset(write.offset)..][0..write.buffer.len]; + + stdx.copy_disjoint(.inexact, u8, overlay_mistaken_buffer, write.buffer); + stdx.copy_disjoint(.inexact, u8, overlay_intended_buffer, target_intended_buffer); + } + } + + fn read_latency(storage: *Storage) Duration { + return storage.latency( + storage.options.read_latency_min, + storage.options.read_latency_mean, + ); + } + + fn write_latency(storage: *Storage) Duration { + return storage.latency( + storage.options.write_latency_min, + storage.options.write_latency_mean, + ); + } + + fn tick_instant(storage: *const Storage) Instant { + return .{ + .ns = storage.ticks * constants.tick_ms * std.time.ns_per_ms, + }; + } + + fn latency(storage: *Storage, min: Duration, mean: Duration) Duration { + return .{ .ns = @max(min.ns, fuzz.random_int_exponential(&storage.prng, u64, mean.ns)) }; + } + + fn pick_faulty_sector( + storage: *Storage, + zone: vsr.Zone, + offset_in_zone: u64, + size: u64, + ) ?usize { + const atlas = storage.options.fault_atlas orelse return null; + return atlas.faulty_sector( + &storage.prng, + storage.options.replica_index.?, + zone, + offset_in_zone, + size, + ); + } + + fn pick_faulty_chunk_offset(storage: *Storage, write: *const Write) ?u64 { + const atlas = storage.options.fault_atlas orelse return null; + const offset = atlas.faulty_chunk_offset( + &storage.prng, + storage.options.replica_index.?, + write.zone, + write.buffer.len, + ); + // Don't misdirect to the same offset. + return if (offset == write.offset) null else offset; + } + + fn fault_sector(storage: *Storage, zone: vsr.Zone, sector: usize) void { + storage.faults.set(sector); + if (storage.options.replica_index) |replica_index| { + const offset = sector * constants.sector_size - zone.offset(0); + switch (zone) { + .superblock => { + log.debug( + "{}: corrupting sector at zone={} offset={}", + .{ replica_index, zone, offset }, + ); + }, + .wal_prepares, .client_replies => { + comptime assert(constants.message_size_max % constants.sector_size == 0); + const slot = @divFloor(offset, constants.message_size_max); + log.debug( + "{}: corrupting sector at zone={} offset={} slot={}", + .{ replica_index, zone, offset, slot }, + ); + }, + .wal_headers => { + comptime assert(constants.sector_size % @sizeOf(vsr.Header) == 0); + const slot_min = @divFloor(offset, @sizeOf(vsr.Header)); + const slot_max = slot_min + + @divExact(constants.sector_size, @sizeOf(vsr.Header)); + log.debug( + "{}: corrupting sector at zone={} offset={} slots={}...{}", + .{ replica_index, zone, offset, slot_min, slot_max }, + ); + }, + .grid_padding => unreachable, + .grid => { + comptime assert(constants.block_size % @sizeOf(vsr.Header) == 0); + const address = @divFloor(offset, constants.block_size) + 1; + log.debug( + "{}: corrupting sector at zone={} offset={} address={}", + .{ replica_index, zone, offset, address }, + ); + }, + } + } + } + + pub fn area_memory( + storage: *const Storage, + area: Area, + ) []align(constants.sector_size) const u8 { + const sectors = area.sectors(); + const area_min = sectors.min * constants.sector_size; + const area_max = sectors.max * constants.sector_size; + return @alignCast(storage.memory[area_min..area_max]); + } + + /// Returns whether any sector in the area is corrupt. + pub fn area_faulty(storage: *const Storage, area: Area) bool { + const sectors = area.sectors(); + var sector = sectors.min; + var faulty: bool = false; + while (sector < sectors.max) : (sector += 1) { + faulty = faulty or storage.faults.isSet(sector); + } + + var misdirected: bool = false; + var overlays = storage.overlays.iterate_const(); + while (overlays.next()) |overlay| { + misdirected = misdirected or + (overlay.zone == area and overlay.offset == area.offset_in_zone()); + } + return faulty or misdirected; + } + + pub fn superblock_header( + storage: *const Storage, + copy_: u8, + ) *const superblock.SuperBlockHeader { + const offset = + vsr.Zone.superblock.offset(@as(usize, copy_) * superblock.superblock_copy_size); + const bytes = storage.memory[offset..][0..@sizeOf(superblock.SuperBlockHeader)]; + return @alignCast(mem.bytesAsValue(superblock.SuperBlockHeader, bytes)); + } + + pub fn wal_headers(storage: *const Storage) []const vsr.Header.Prepare { + const offset = vsr.Zone.wal_headers.offset(0); + const size = vsr.Zone.wal_headers.size().?; + return @alignCast(mem.bytesAsSlice( + vsr.Header.Prepare, + storage.memory[offset..][0..size], + )); + } + + fn MessageRawType(comptime command: vsr.Command) type { + return extern struct { + const MessageRaw = @This(); + header: vsr.Header.Type(command), + body: [constants.message_size_max - @sizeOf(vsr.Header)]u8, + + comptime { + assert(@sizeOf(MessageRaw) == constants.message_size_max); + assert(stdx.no_padding(MessageRaw)); + } + }; + } + + pub fn wal_prepares(storage: *const Storage) []const MessageRawType(.prepare) { + const offset = vsr.Zone.wal_prepares.offset(0); + const size = vsr.Zone.wal_prepares.size().?; + return @alignCast(mem.bytesAsSlice( + MessageRawType(.prepare), + storage.memory[offset..][0..size], + )); + } + + pub fn client_replies(storage: *const Storage) []const MessageRawType(.reply) { + const offset = vsr.Zone.client_replies.offset(0); + const size = vsr.Zone.client_replies.size().?; + return @alignCast(mem.bytesAsSlice( + MessageRawType(.reply), + storage.memory[offset..][0..size], + )); + } + + pub fn grid_block( + storage: *const Storage, + address: u64, + ) ?*align(constants.sector_size) const [constants.block_size]u8 { + assert(address > 0); + + const block_offset = vsr.Zone.grid.offset((address - 1) * constants.block_size); + if (storage.memory_written.isSet(@divExact(block_offset, constants.sector_size))) { + const block_buffer = storage.memory[block_offset..][0..constants.block_size]; + const block_header = schema.header_from_block(@alignCast(block_buffer)); + assert(block_header.address == address); + + return @alignCast(block_buffer); + } else { + return null; + } + } + + pub fn log_pending_io(storage: *const Storage) void { + for (storage.reads.items) |read| { + log.debug("Pending read: {} {}\n{}", .{ read.offset, read.zone, read.stack_trace }); + } + for (storage.writes.items) |write| { + log.debug("Pending write: {} {}\n{}", .{ write.offset, write.zone, write.stack_trace }); + } + } + + pub fn assert_no_pending_reads(storage: *const Storage, zone: vsr.Zone) void { + var assert_failed = false; + + for (storage.reads.items) |read| { + if (read.zone == zone) { + log.err("Pending read: {} {}\n{}", .{ read.offset, read.zone, read.stack_trace }); + assert_failed = true; + } + } + + if (assert_failed) { + panic("Pending reads in zone: {}", .{zone}); + } + } + + pub fn assert_no_pending_writes(storage: *const Storage, zone: vsr.Zone) void { + var assert_failed = false; + + const writes = storage.writes; + for (writes.items) |write| { + if (write.zone == zone) { + log.err("Pending write: {} {}\n{}", .{ + write.offset, + write.zone, + write.stack_trace, + }); + assert_failed = true; + } + } + + if (assert_failed) { + panic("Pending writes in zone: {}", .{zone}); + } + } + + pub fn transition_to_liveness_mode(storage: *Storage) void { + storage.options.write_latency_mean = .ms(1); + storage.options.write_latency_min = .ms(1); + storage.options.read_latency_mean = .ms(1); + storage.options.read_latency_min = .ms(1); + storage.options.read_fault_probability = Ratio.zero(); + storage.options.write_fault_probability = Ratio.zero(); + storage.options.write_misdirect_probability = Ratio.zero(); + storage.options.crash_fault_probability = Ratio.zero(); + } +}; + +pub const Area = union(vsr.Zone) { + superblock: struct { copy: u8 }, + wal_headers: struct { sector: usize }, + wal_prepares: struct { slot: usize }, + client_replies: struct { slot: usize }, + grid_padding, + grid: struct { address: u64 }, + + fn offset_in_zone(area: Area) u64 { + return switch (area) { + .superblock => |data| vsr.superblock.superblock_copy_size * @as(u64, data.copy), + .wal_headers => |data| constants.sector_size * data.sector, + .wal_prepares => |data| constants.message_size_max * data.slot, + .client_replies => |data| constants.message_size_max * data.slot, + .grid_padding => unreachable, + .grid => |data| constants.block_size * (data.address - 1), + }; + } + + fn sectors(area: Area) SectorRange { + return SectorRange.from_zone(area, area.offset_in_zone(), switch (area) { + .superblock => vsr.superblock.superblock_copy_size, + .wal_headers => constants.sector_size, + .wal_prepares => constants.message_size_max, + .client_replies => constants.message_size_max, + .grid_padding => unreachable, + .grid => constants.block_size, + }); + } +}; + +const SectorRange = struct { + min: usize, // inclusive sector index + max: usize, // exclusive sector index + + fn from_zone( + zone: vsr.Zone, + offset_in_zone: u64, + size: usize, + ) SectorRange { + return from_offset(zone.offset(offset_in_zone), size); + } + + fn from_offset(offset_in_storage: u64, size: usize) SectorRange { + return .{ + .min = @divExact(offset_in_storage, constants.sector_size), + .max = @divExact(offset_in_storage + size, constants.sector_size), + }; + } + + fn random(range: SectorRange, prng: *stdx.PRNG) usize { + return prng.range_inclusive(usize, range.min, range.max - 1); + } + + fn next(range: *SectorRange) ?usize { + if (range.min == range.max) return null; + defer range.min += 1; + + return range.min; + } + + fn intersect(a: SectorRange, b: SectorRange) ?SectorRange { + if (a.max <= b.min) return null; + if (b.max <= a.min) return null; + return SectorRange{ + .min = @max(a.min, b.min), + .max = @min(a.max, b.max), + }; + } +}; + +/// To ensure the cluster can recover, each header/prepare/block must be valid (not faulty) at +/// a majority of replicas. +/// +/// We can't allow WAL storage faults for the same message in a majority of +/// the replicas as that would make recovery impossible. Instead, we only +/// allow faults in certain areas which differ between replicas. +pub const ClusterFaultAtlas = struct { + pub const Options = struct { + faulty_superblock: bool, + faulty_wal_headers: bool, + faulty_wal_prepares: bool, + faulty_client_replies: bool, + faulty_grid: bool, + }; + + const ReplicaSet = stdx.BitSetType(constants.replicas_max); + const headers_per_sector = @divExact(constants.sector_size, @sizeOf(vsr.Header)); + const members_max = constants.members_max; + + faulty_wal_header_sectors: [members_max]std.DynamicBitSetUnmanaged, + faulty_client_reply_slots: [members_max]std.DynamicBitSetUnmanaged, + /// Bit 0 corresponds to address 1. + faulty_grid_blocks: [members_max]std.DynamicBitSetUnmanaged, + + pub fn init( + allocator: std.mem.Allocator, + replica_count: u8, + prng: *stdx.PRNG, + options: Options, + ) !ClusterFaultAtlas { + if (replica_count == 1) { + // If there is only one replica in the cluster, WAL/Grid faults are not recoverable. + maybe(options.faulty_superblock); + assert(!options.faulty_wal_headers); + assert(!options.faulty_wal_prepares); + assert(!options.faulty_client_replies); + assert(!options.faulty_grid); + } + + // Currently these faulty areas are coupled together, so they should match. + assert(options.faulty_wal_headers == options.faulty_wal_prepares); + + const fault_bitset_sizes = [3]u32{ + @divExact(constants.journal_size_headers, constants.sector_size), // WAL headers. + constants.clients_max, // Client replies. + Storage.grid_blocks_max, // Grid. + }; + + var fault_bitsets_allocated: u32 = 0; + var fault_bitsets: [3 * members_max]std.DynamicBitSetUnmanaged = undefined; + errdefer for (fault_bitsets[0..fault_bitsets_allocated]) |*b| b.deinit(allocator); + + for (&fault_bitsets, 0..) |*fault_bitset, i| { + const fault_bitset_size = fault_bitset_sizes[@divFloor(i, members_max)]; + fault_bitset.* = try std.DynamicBitSetUnmanaged.initEmpty(allocator, fault_bitset_size); + fault_bitsets_allocated += 1; + } + + var atlas = ClusterFaultAtlas{ + .faulty_wal_header_sectors = fault_bitsets[0 * members_max ..][0..members_max].*, + .faulty_client_reply_slots = fault_bitsets[1 * members_max ..][0..members_max].*, + .faulty_grid_blocks = fault_bitsets[2 * members_max ..][0..members_max].*, + }; + + const quorums = vsr.quorums(replica_count); + const faults_max = quorums.replication - 1; + assert(faults_max < replica_count); + assert(faults_max < quorums.replication); + assert(faults_max < quorums.view_change); + assert(faults_max > 0 or replica_count == 1); + + for ([_]struct { bool, *[members_max]std.DynamicBitSetUnmanaged }{ + .{ options.faulty_wal_headers, &atlas.faulty_wal_header_sectors }, + .{ options.faulty_client_replies, &atlas.faulty_client_reply_slots }, + .{ options.faulty_grid, &atlas.faulty_grid_blocks }, + }) |zone| { + const faulty = zone.@"0"; + const chunks = zone.@"1"; + if (!faulty) continue; + + for (0..chunks[0].bit_length) |chunk| { + var replicas: ReplicaSet = .{}; + while (replicas.count() < faults_max) { + const replica_index = prng.int_inclusive(u8, replica_count - 1); + if (chunks[replica_index].count() + 1 < + chunks[replica_index].capacity()) + { + chunks[replica_index].set(chunk); + replicas.set(replica_index); + } else { + // Never corrupt all chunks of a particular replica. + // (For the WAL, this can cause error.WALInvalid). + } + } + } + } + + return atlas; + } + + pub fn deinit(atlas: *ClusterFaultAtlas, allocator: std.mem.Allocator) void { + for (&atlas.faulty_grid_blocks) |*b| b.deinit(allocator); + for (&atlas.faulty_client_reply_slots) |*b| b.deinit(allocator); + for (&atlas.faulty_wal_header_sectors) |*b| b.deinit(allocator); + } + + fn zone_chunks(atlas: *const ClusterFaultAtlas, zone: vsr.Zone) ?struct { + chunk_size: u32, + faulty: *const [members_max]std.DynamicBitSetUnmanaged, + } { + return switch (zone) { + // Don't inject additional read/write/misdirect faults into superblock headers. + // This prevents the quorum from being lost like so: + // - copy₀: B (ok) + // - copy₁: B (torn write) + // - copy₂: A (corrupt) + // - copy₃: A (ok) + // TODO Use hash-chaining to safely load copy₀, so that we can inject a superblock + // fault. + .superblock => null, + // We assert that the padding is never read, so there's no need to fault it. + .grid_padding => unreachable, + + .wal_headers => .{ + .chunk_size = constants.sector_size, + .faulty = &atlas.faulty_wal_header_sectors, + }, + .wal_prepares => .{ + .chunk_size = constants.message_size_max * headers_per_sector, + .faulty = &atlas.faulty_wal_header_sectors, + }, + .client_replies => .{ + .chunk_size = constants.message_size_max, + .faulty = &atlas.faulty_client_reply_slots, + }, + .grid => .{ + .chunk_size = constants.block_size, + .faulty = &atlas.faulty_grid_blocks, + }, + }; + } + + /// Given a write of `size` bytes to the given zone, find an interesting offset within the same + /// zone to target. (If we want to drop the latter condition, an alternate implementation + /// strategy is: on random writes, perform the write successfully, but save the target + /// zone/offset/size. Then on a future random write, misdirect to a compatible saved location.) + fn faulty_chunk_offset( + atlas: *const ClusterFaultAtlas, + prng: *stdx.PRNG, + replica_index: u8, + zone: vsr.Zone, + size: u64, + ) ?u64 { + const chunks = atlas.zone_chunks(zone) orelse return null; + + if (chunks.chunk_size < size) { + // When formatting the WAL, we may write many chunks simultaneously (to avoid a storm of + // tiny writes). + assert(zone == .wal_headers or zone == .wal_prepares); + assert(size % constants.sector_size == 0); + return null; + } + + const chunks_faulty = &chunks.faulty[replica_index]; + const chunk_count = chunks_faulty.bit_length; + const chunk_start = prng.int_inclusive(usize, chunk_count - 1); + for (0..chunk_count) |i| { + const chunk_index = (chunk_start + i) % chunk_count; + if (chunks_faulty.isSet(chunk_index)) { + // The chunk size of zone=wal_prepares is a multiple of the message_size_max, but + // misdirects in the wal_prepares zone always land on the first message of a chunk. + return chunk_index * chunks.chunk_size; + } + } + return null; + } + + fn faulty_sector( + atlas: *const ClusterFaultAtlas, + prng: *stdx.PRNG, + replica_index: u8, + zone: vsr.Zone, + offset_in_zone: u64, + size: u64, + ) ?usize { + const chunks = atlas.zone_chunks(zone) orelse return null; + + var fault_start: ?usize = null; + var fault_count: usize = 0; + + var chunk: usize = @divFloor(offset_in_zone, chunks.chunk_size); + while (chunk * chunks.chunk_size < offset_in_zone + size) : (chunk += 1) { + if (chunks.faulty[replica_index].isSet(chunk)) { + if (fault_start == null) fault_start = chunk; + fault_count += 1; + } else { + if (fault_start != null) break; + } + } + + if (fault_start) |start| { + return SectorRange.from_zone( + zone, + chunks.chunk_size * start, + chunks.chunk_size * fault_count, + ).intersect(SectorRange.from_zone(zone, offset_in_zone, size)).?.random(prng); + } else { + return null; + } + } +}; + +const StackTrace = struct { + addresses: [64]usize, + index: usize, + + fn capture() StackTrace { + var addresses: [64]usize = undefined; + var stack_trace = std.builtin.StackTrace{ + .instruction_addresses = &addresses, + .index = 0, + }; + std.debug.captureStackTrace(null, &stack_trace); + return StackTrace{ .addresses = addresses, .index = stack_trace.index }; + } + + pub fn format( + self: StackTrace, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + var addresses = self.addresses; + const stack_trace = std.builtin.StackTrace{ + .instruction_addresses = &addresses, + .index = self.index, + }; + try writer.print("{}", .{stack_trace}); + } +}; diff --git a/ocam/src/testing/table.zig b/ocam/src/testing/table.zig new file mode 100644 index 00000000..50ae15e5 --- /dev/null +++ b/ocam/src/testing/table.zig @@ -0,0 +1,250 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const stdx = @import("stdx"); + +/// Parse a "table" of data with the specified schema. +/// See test cases for example usage. +pub fn parse(comptime Row: type, table_string: []const u8) stdx.BoundedArrayType(Row, 128) { + var rows = stdx.BoundedArrayType(Row, 128){}; + var row_strings = std.mem.tokenizeScalar(u8, table_string, '\n'); + while (row_strings.next()) |row_string| { + // Ignore blank line. + if (row_string.len == 0) continue; + + var columns = std.mem.tokenizeScalar(u8, row_string, ' '); + const row = parse_data(Row, &columns); + rows.push(row); + + // Ignore trailing line comment. + if (columns.next()) |last| assert(std.mem.eql(u8, last, "//")); + } + return rows; +} + +fn parse_data(comptime Data: type, tokens: *std.mem.TokenIterator(u8, .scalar)) Data { + return switch (@typeInfo(Data)) { + .optional => |info| parse_data(info.child, tokens), + .@"enum" => field(Data, tokens.next().?), + .void => assert(tokens.next() == null), + .bool => { + const token = tokens.next().?; + inline for (.{ "0", "false", "F" }) |t| { + if (std.mem.eql(u8, token, t)) return false; + } + inline for (.{ "1", "true", "T" }) |t| { + if (std.mem.eql(u8, token, t)) return true; + } + std.debug.panic("Unknown boolean: {s}", .{token}); + }, + .int => |info| { + const max = std.math.maxInt(Data); + const token = tokens.next().?; + // If the first character is a letter ("a-zA-Z"), ignore it. (For example, "A1" → 1). + // This serves as a comment, to help visually disambiguate sequential integer columns. + const offset: usize = if (std.ascii.isAlphabetic(token[0])) 1 else 0; + // Negative unsigned values are computed relative to the maxInt. + if (info.signedness == .unsigned and token[offset] == '-') { + const negative = stdx.parse_int(Data, token[offset + 1 ..], .{}) catch unreachable; + return max - negative; + } + return stdx.parse_int(Data, token[offset..], .{}) catch unreachable; + }, + .@"struct" => { + var data: Data = undefined; + inline for (std.meta.fields(Data)) |value_field| { + const Field = value_field.type; + const value: Field = value: { + if (comptime value_field.default_value_ptr) |ptr| { + if (eat(tokens, "_")) { + const value_ptr: *const Field = @ptrCast(@alignCast(ptr)); + break :value value_ptr.*; + } + } + + break :value parse_data(Field, tokens); + }; + + @field(data, value_field.name) = value; + } + return data; + }, + .array => |info| { + var values: Data = undefined; + for (values[0..]) |*value| { + value.* = parse_data(info.child, tokens); + } + return values; + }, + .@"union" => |info| { + const variant_string = tokens.next().?; + inline for (info.fields) |variant_field| { + if (std.mem.eql(u8, variant_field.name, variant_string)) { + return @unionInit( + Data, + variant_field.name, + parse_data(variant_field.type, tokens), + ); + } + } + std.debug.panic("Unknown union variant: {s}", .{variant_string}); + }, + else => @compileError("Unimplemented column type: " ++ @typeName(Data)), + }; +} + +fn eat(tokens: *std.mem.TokenIterator(u8, .scalar), token: []const u8) bool { + const index_before = tokens.index; + if (std.mem.eql(u8, tokens.next().?, token)) return true; + tokens.index = index_before; + return false; +} + +/// TODO This function is a workaround for a comptime bug: +/// error: unable to evaluate constant expression +/// .@"enum" => @field(Column, column_string), +fn field(comptime Enum: type, name: []const u8) Enum { + inline for (std.meta.fields(Enum)) |variant| { + if (std.mem.eql(u8, variant.name, name)) { + return @field(Enum, variant.name); + } + } + std.debug.panic("Unknown field name={s} for type={}", .{ name, Enum }); +} + +fn test_parse( + comptime Row: type, + comptime rows_expect: []const Row, + comptime string: []const u8, +) !void { + const rows_actual = parse(Row, string).const_slice(); + try std.testing.expectEqual(rows_expect.len, rows_actual.len); + + for (rows_expect, 0..) |row, i| { + try std.testing.expectEqual(row, rows_actual[i]); + } +} + +test "comment" { + try test_parse(struct { + a: u8, + }, &.{ + .{ .a = 1 }, + }, + \\ + \\ 1 // Comment + \\ + ); +} + +test "enum" { + try test_parse(enum { a, b, c }, &.{ .c, .b, .a }, + \\ c + \\ b + \\ a + ); +} + +test "bool" { + try test_parse(struct { i: bool }, &.{ + .{ .i = false }, + .{ .i = true }, + .{ .i = false }, + .{ .i = true }, + .{ .i = false }, + .{ .i = true }, + }, + \\ 0 + \\ 1 + \\ false + \\ true + \\ F + \\ T + ); +} + +test "int" { + try test_parse(struct { i: usize }, &.{ + .{ .i = 1 }, + .{ .i = 2 }, + .{ .i = 3 }, + .{ .i = 4 }, + .{ .i = std.math.maxInt(usize) - 5 }, + .{ .i = std.math.maxInt(usize) }, + }, + \\ 1 + \\ 2 + \\ A3 + \\ a4 + // For unsigned integers, `-n` is interpreted as `maxInt(Int) - n`. + \\ -5 + \\ -0 + ); +} + +test "struct" { + try test_parse(struct { + c1: enum { a, b, c, d }, + c2: u8, + c3: u16 = 30, + c4: ?u32 = null, + c5: bool = false, + }, &.{ + .{ .c1 = .a, .c2 = 1, .c3 = 10, .c4 = 1000, .c5 = true }, + .{ .c1 = .b, .c2 = 2, .c3 = 20, .c4 = null, .c5 = true }, + .{ .c1 = .c, .c2 = 3, .c3 = 30, .c4 = null, .c5 = false }, + .{ .c1 = .d, .c2 = 4, .c3 = 30, .c4 = null, .c5 = false }, + }, + \\ a 1 10 1000 1 + \\ b 2 20 _ T + \\ c 3 _ _ F + \\ d 4 _ _ _ + ); +} + +test "struct (nested)" { + try test_parse(struct { + a: u32, + b: struct { + b1: u8, + b2: u8, + }, + c: u32, + }, &.{ + .{ .a = 1, .b = .{ .b1 = 2, .b2 = 3 }, .c = 4 }, + .{ .a = 5, .b = .{ .b1 = 6, .b2 = 7 }, .c = 8 }, + }, + \\ 1 2 3 4 + \\ 5 6 7 8 + ); +} + +test "array" { + try test_parse(struct { + a: u32, + b: [2]u32, + c: u32, + }, &.{ + .{ .a = 1, .b = .{ 2, 3 }, .c = 4 }, + .{ .a = 5, .b = .{ 6, 7 }, .c = 8 }, + }, + \\ 1 2 3 4 + \\ 5 6 7 8 + ); +} + +test "union" { + try test_parse(union(enum) { + a: struct { b: u8, c: i8 }, + d: u8, + e: void, + }, &.{ + .{ .a = .{ .b = 1, .c = -2 } }, + .{ .d = 3 }, + .{ .e = {} }, + }, + \\a 1 -2 + \\d 3 + \\e + ); +} diff --git a/ocam/src/testing/time.zig b/ocam/src/testing/time.zig new file mode 100644 index 00000000..bb8a04d6 --- /dev/null +++ b/ocam/src/testing/time.zig @@ -0,0 +1,98 @@ +const std = @import("std"); +const stdx = @import("stdx"); +const Time = @import("../time.zig").Time; + +pub const OffsetType = enum { + linear, + periodic, + step, + non_ideal, +}; + +pub const TimeSim = struct { + /// The duration of a single tick in nanoseconds. + resolution: u64, + + offset_type: OffsetType, + + /// Co-efficients to scale the offset according to the `offset_type`. + /// Linear offset is described as A * x + B: A is the drift per tick and B the initial offset. + /// Periodic is described as A * sin(x * pi / B): A controls the amplitude and B the period in + /// terms of ticks. + /// Step function represents a discontinuous jump in the wall-clock time. B is the period in + /// which the jumps occur. A is the amplitude of the step. + /// Non-ideal is similar to periodic except the phase is adjusted using a random number taken + /// from a normal distribution with mean=0, stddev=10. Finally, a random offset (up to + /// offset_coefficient_C) is added to the result. + offset_coefficient_A: i64, + offset_coefficient_B: i64, + offset_coefficient_C: u32 = 0, + + prng: stdx.PRNG = stdx.PRNG.from_seed(0), + + /// The number of ticks elapsed since initialization. + ticks: u64 = 0, + + /// The instant in time chosen as the origin of this time source. + epoch: i64 = 0, + + pub fn time(self: *TimeSim) Time { + return .{ + .context = self, + .vtable = &.{ + .monotonic = monotonic, + .realtime = realtime, + .tick = tick, + }, + }; + } + + fn monotonic(context: *anyopaque) u64 { + const self: *TimeSim = @ptrCast(@alignCast(context)); + + return self.ticks * self.resolution; + } + + fn realtime(context: *anyopaque) i64 { + const self: *TimeSim = @ptrCast(@alignCast(context)); + + return self.epoch + @as(i64, @intCast(monotonic(context))) - self.offset(self.ticks); + } + + pub fn offset(self: *TimeSim, ticks: u64) i64 { + switch (self.offset_type) { + .linear => { + const drift_per_tick = self.offset_coefficient_A; + return @as(i64, @intCast(ticks)) * drift_per_tick + @as( + i64, + @intCast(self.offset_coefficient_B), + ); + }, + .periodic => { + const unscaled = std.math.sin(@as(f64, @floatFromInt(ticks)) * 2 * std.math.pi / + @as(f64, @floatFromInt(self.offset_coefficient_B))); + const scaled = @as(f64, @floatFromInt(self.offset_coefficient_A)) * unscaled; + return @as(i64, @intFromFloat(std.math.floor(scaled))); + }, + .step => { + return if (ticks > self.offset_coefficient_B) self.offset_coefficient_A else 0; + }, + .non_ideal => { + const phase: f64 = @as(f64, @floatFromInt(ticks)) * 2 * std.math.pi / + (@as(f64, @floatFromInt(self.offset_coefficient_B)) + + std.Random.init(&self.prng, stdx.PRNG.fill).floatNorm(f64) * 10); + const unscaled = std.math.sin(phase); + const scaled = @as(f64, @floatFromInt(self.offset_coefficient_A)) * unscaled; + const offset_random: i64 = -@as(i64, @intCast(self.offset_coefficient_C)) + + @as(i64, @intCast(self.prng.int_inclusive(u64, 2 * self.offset_coefficient_C))); + return @as(i64, @intFromFloat(std.math.floor(scaled))) + offset_random; + }, + } + } + + fn tick(context: *anyopaque) void { + const self: *TimeSim = @ptrCast(@alignCast(context)); + + self.ticks += 1; + } +}; diff --git a/ocam/src/testing/tmp_tigerbeetle.zig b/ocam/src/testing/tmp_tigerbeetle.zig new file mode 100644 index 00000000..7e7c229a --- /dev/null +++ b/ocam/src/testing/tmp_tigerbeetle.zig @@ -0,0 +1,212 @@ +//! TmpTigerBeetle is an utility for integration tests, which spawns a single node TigerBeetle +//! cluster in a temporary directory. + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; + +const stdx = @import("stdx"); +const Shell = stdx.Shell; + +const MiB = stdx.MiB; + +const log = std.log.scoped(.tmptigerbeetle); + +const TmpTigerBeetle = @This(); + +/// Path to the executable. +tigerbeetle_exe: []const u8, +/// Port the TigerBeetle instance is listening on. +port: u16, +/// For convenience, the same port pre-converted to string. +port_str: []const u8, + +tmp_dir: std.testing.TmpDir, + +// A separate thread for reading process stderr without blocking it. The process must be terminated +// before stopping the StreamReader. +// +// StreamReader echoes process' stderr on exit unless explicitly instructed otherwise. +stderr_reader: *StreamReader, + +process: std.process.Child, + +pub fn init( + gpa: std.mem.Allocator, + options: struct { + development: bool, + prebuilt: ?[]const u8 = null, + }, +) !TmpTigerBeetle { + const shell = try Shell.create(gpa); + defer shell.destroy(); + + var from_source_path: ?[]const u8 = null; + defer if (from_source_path) |path| gpa.free(path); + + if (options.prebuilt == null) { + const tigerbeetle_exe = comptime "tigerbeetle" ++ builtin.target.exeFileExt(); + + // If tigerbeetle binary does not exist yet, build it. + // + // TODO: just run `zig build run` unconditionally here, when that doesn't do spurious + // rebuilds. + _ = shell.project_root.statFile(tigerbeetle_exe) catch { + log.info("building TigerBeetle", .{}); + try shell.exec_zig("build", .{}); + + _ = try shell.project_root.statFile(tigerbeetle_exe); + }; + + from_source_path = try shell.project_root.realpathAlloc(gpa, tigerbeetle_exe); + } + + const tigerbeetle_exe: []const u8 = try gpa.dupe( + u8, + options.prebuilt orelse from_source_path.?, + ); + errdefer gpa.free(tigerbeetle_exe); + assert(std.fs.path.isAbsolute(tigerbeetle_exe)); + + var tmp_dir = std.testing.tmpDir(.{}); + errdefer tmp_dir.cleanup(); + + const tmp_dir_path = try tmp_dir.dir.realpathAlloc(gpa, "."); + defer gpa.free(tmp_dir_path); + + const data_file: []const u8 = try std.fs.path.join(gpa, &.{ tmp_dir_path, "0_0.tigerbeetle" }); + defer gpa.free(data_file); + + try shell.exec( + "{tigerbeetle} format --cluster=0 --replica=0 --replica-count=1 {data_file}", + .{ .tigerbeetle = tigerbeetle_exe, .data_file = data_file }, + ); + + var reader_maybe: ?*StreamReader = null; + // Pass `--addresses=0` to let the OS pick a port for us. + var process = try shell.spawn( + .{ + .stdin_behavior = .Pipe, + .stdout_behavior = .Pipe, + .stderr_behavior = .Pipe, + }, + "{tigerbeetle} start --development={development} --addresses=0 {data_file}", + .{ + .tigerbeetle = tigerbeetle_exe, + .development = if (options.development) "true" else "false", + .data_file = data_file, + }, + ); + + errdefer { + if (reader_maybe) |reader| { + reader.stop(gpa, &process); // Will log stderr. + } else { + _ = process.kill() catch unreachable; + } + } + + reader_maybe = try StreamReader.start(gpa, process.stderr.?); + + const port = port: { + var exit_status: ?std.process.Child.Term = null; + errdefer log.err( + "failed to read port number from tigerbeetle process: {?}", + .{exit_status}, + ); + + var port_buf: [std.fmt.count("{}\n", .{std.math.maxInt(u16)})]u8 = undefined; + const port_buf_len = try process.stdout.?.readAll(&port_buf); + if (port_buf_len == 0) { + exit_status = try process.wait(); + return error.NoPort; + } + + break :port try stdx.parse_int(u16, port_buf[0 .. port_buf_len - 1], .{}); + }; + + const port_str = try std.fmt.allocPrint(gpa, "{d}", .{port}); + errdefer gpa.free(port_str); + + return TmpTigerBeetle{ + .tigerbeetle_exe = tigerbeetle_exe, + .port = port, + .port_str = port_str, + .tmp_dir = tmp_dir, + .stderr_reader = reader_maybe.?, + .process = process, + }; +} + +pub fn deinit(tb: *TmpTigerBeetle, gpa: std.mem.Allocator) void { + if (tb.stderr_reader.log_stderr.load(.seq_cst) == .on_early_exit) { + tb.stderr_reader.log_stderr.store(.no, .seq_cst); + } + assert(tb.process.term == null); + tb.stderr_reader.stop(gpa, &tb.process); + assert(tb.process.term != null); + gpa.free(tb.port_str); + tb.tmp_dir.cleanup(); + gpa.free(tb.tigerbeetle_exe); +} + +pub fn log_stderr(tb: *TmpTigerBeetle) void { + tb.stderr_reader.log_stderr.store(.yes, .seq_cst); +} + +const StreamReader = struct { + const LogStderr = std.atomic.Value(enum(u8) { no, yes, on_early_exit }); + + log_stderr: LogStderr = LogStderr.init(.on_early_exit), + thread: std.Thread, + file: std.fs.File, + + pub fn start(gpa: std.mem.Allocator, file: std.fs.File) !*StreamReader { + var result = try gpa.create(StreamReader); + errdefer gpa.destroy(result); + + result.* = .{ + .thread = undefined, + .file = file, + }; + + result.thread = try std.Thread.spawn(.{}, thread_main, .{result}); + return result; + } + + pub fn stop(self: *StreamReader, gpa: std.mem.Allocator, process: *std.process.Child) void { + // Shutdown sequence is tricky: + // 1. Terminate the process, but _don't_ close our side of the pipe. + // 2. Wait until the thread exits. + // 3. Close stderr file descriptor. + // TODO(Zig) https://github.com/ziglang/zig/issues/16820 + if (builtin.os.tag == .windows) { + const exit_code = 1; + std.os.windows.TerminateProcess(process.id, exit_code) catch {}; + } else { + std.posix.kill(process.id, std.posix.SIG.TERM) catch {}; + } + assert(process.stderr != null); + self.thread.join(); + _ = process.wait() catch unreachable; + assert(process.stderr == null); + gpa.destroy(self); + } + + fn thread_main(reader: *StreamReader) void { + // NB: Zig allocators are not thread safe, so use mmap directly to hold process' stderr. + const allocator = std.heap.page_allocator; + + var buffer = std.ArrayList(u8).init(allocator); + defer buffer.deinit(); + + // NB: don't use `readAllAlloc` to get partial output in case of errors. + reader.file.reader().readAllArrayList(&buffer, 100 * MiB) catch {}; + switch (reader.log_stderr.load(.seq_cst)) { + .on_early_exit, .yes => { + log.err("tigerbeetle stderr:\n++++\n{s}\n++++", .{buffer.items}); + }, + .no => {}, + } + } +}; diff --git a/ocam/src/testing/vortex/constants.zig b/ocam/src/testing/vortex/constants.zig new file mode 100644 index 00000000..92599958 --- /dev/null +++ b/ocam/src/testing/vortex/constants.zig @@ -0,0 +1,32 @@ +const std = @import("std"); +const constants = @This(); + +pub const vsr = @import("../../constants.zig"); + +pub const vortex = struct { + pub const cluster_id = 0; + // Maximum number of connections *per replica*. + // -1 since replicas don't connect to themselves. + // +1 for the single driver/client. + pub const connections_count_max = (vsr.replicas_max - 1) + 1; + + // We allow the cluster to not make progress processing requests for this amount of time. + // After that it's considered a test failure. + // TODO: This is long for a couple reasons: + // - CFO currently oversaturate s CPU. + // - Vortex's liveness check doesn't consider how long replicas have been up. e.g. if you have 3 + // replicas, and alternate stopping/starting the backups (such that there is always at least + // 2/3 replicas running) then as far as Supervisor is concerned, that cluster should be making + // progress, even if neither replica is up long enough to catch up to the primary. + pub const liveness_requirement_seconds = 180; + pub const liveness_requirement_micros = liveness_requirement_seconds * std.time.us_per_s; + + pub const replica_ports_actual = brk: { + var ports: [constants.vsr.replicas_max]u16 = undefined; + var replica_num: u16 = 0; + while (replica_num < constants.vsr.replicas_max) : (replica_num += 1) { + ports[replica_num] = 4000 + replica_num; + } + break :brk ports; + }; +}; diff --git a/ocam/src/testing/vortex/faulty_network.zig b/ocam/src/testing/vortex/faulty_network.zig new file mode 100644 index 00000000..26dd6206 --- /dev/null +++ b/ocam/src/testing/vortex/faulty_network.zig @@ -0,0 +1,585 @@ +//! The `Network` is a set of proxies used to inject network faults in a Vortex test cluster. We +//! create one `Proxy` per replica in the test cluster. Each proxy has a set of available +//! `Connection`s, which model the communication through the proxy (replica-to-replica or +//! client-to-replica). Each `Connection` has two pipes, connecting the peers' inputs and outputs +//! (recv and send). +//! +//! The _mappings_ are pairs of addresses: +//! +//! * _origin_: the address on which the proxy listens for connections from _origin_ peers +//! * _remote_: the address the proxy connects to, to communicate with the _remote_ peers +//! +//! A pipe runs, alternating recv and send, until it sees that the connection is no longer proxying +//! or if there's an error or EOF, in which case it also closes the parent connection. +//! +//! When the `Faults` struct is populated with non-null configurations, the pipes inject +//! corresponding faults according to the probabilities. +//! +//! NOTE: The pipe is not yet message-aware (and perhaps shouldn't be?), which means that we deal +//! with whatever chunks of bytes we receive immediately, instead of collecting a buffer with a +//! full message before piping it through. +const std = @import("std"); +const stdx = @import("stdx"); +const IO = @import("../../io.zig").IO; +const constants = @import("constants.zig"); + +const assert = std.debug.assert; +const log = std.log.scoped(.faulty_network); +const Ratio = stdx.PRNG.Ratio; + +const Faults = struct { + const Delay = struct { + time_ms: u32, + jitter_ms: u32, + }; + + delay: ?Delay = null, + corrupt: ?Ratio = null, + + // Others not implemented: duplication, reordering, rate + + pub fn heal(faults: *Faults) void { + faults.delay = null; + faults.corrupt = null; + } + + pub fn is_healed(faults: *const Faults) bool { + return faults.delay == null and faults.corrupt == null; + } +}; + +const Pipe = struct { + io: *IO, + connection: *Connection, + input: ?std.posix.socket_t = null, + output: ?std.posix.socket_t = null, + buffer: [constants.vsr.message_size_max]u8 = undefined, + status: enum { idle, recv, send, send_timeout } = .idle, + recv_size: u32 = 0, + send_size: u32 = 0, + + recv_completion: IO.Completion = undefined, + send_completion: IO.Completion = undefined, + + fn open( + pipe: *Pipe, + input: std.posix.socket_t, + output: std.posix.socket_t, + ) void { + assert(pipe.connection.state == .proxying); + assert(pipe.status == .idle); + assert(pipe.input == null); + assert(pipe.output == null); + assert(pipe.recv_size == 0); + assert(pipe.send_size == 0); + + pipe.input = input; + pipe.output = output; + + // Kick off the recv/send loop. + pipe.recv(); + } + + fn recv(pipe: *Pipe) void { + assert(pipe.send_size <= pipe.recv_size); + assert(pipe.connection.state == .proxying); + + assert(pipe.status == .idle); + pipe.status = .recv; + + pipe.recv_size = 0; + pipe.send_size = 0; + + // We don't need to recv a certain count of bytes, because whatever we recv, we send along. + pipe.connection.io.recv( + *Pipe, + pipe, + recv_callback, + &pipe.recv_completion, + pipe.input.?, + pipe.buffer[0..], + ); + } + + fn recv_callback(pipe: *Pipe, _: *IO.Completion, result: IO.RecvError!usize) void { + assert(pipe.recv_size == 0); + assert(pipe.send_size == 0); + assert(pipe.connection.state != .free); + assert(pipe.connection.state != .accepting); + assert(pipe.connection.state != .connecting); + + assert(pipe.status == .recv); + pipe.status = .idle; + + if (pipe.connection.state != .proxying) return; + + const recv_size = result catch |err| { + log.warn("recv error ({d},{d}): {any}", .{ + pipe.connection.replica_index, + pipe.connection.connection_index, + err, + }); + return pipe.connection.try_close(); + }; + + pipe.recv_size = @intCast(recv_size); + if (pipe.recv_size == 0) { + // Zero bytes means EOF. + return pipe.connection.try_close(); + } + + if (pipe.connection.network.faults.corrupt) |corrupt| { + if (pipe.connection.network.prng.chance(corrupt)) { + switch (pipe.connection.network.prng.enum_uniform(enum { shuffle, zero })) { + .shuffle => { + log.debug("shuffling {d} bytes ({d},{d})", .{ + pipe.recv_size, + pipe.connection.replica_index, + pipe.connection.connection_index, + }); + pipe.connection.network.prng.shuffle(u8, pipe.buffer[0..pipe.recv_size]); + }, + .zero => { + log.debug("zeroing {d} bytes ({d},{d})", .{ + pipe.recv_size, + pipe.connection.replica_index, + pipe.connection.connection_index, + }); + @memset(pipe.buffer[0..pipe.recv_size], 0); + }, + } + } + } + + if (pipe.connection.network.faults.delay) |delay| { + assert(delay.time_ms > 0); + assert(delay.jitter_ms <= delay.time_ms); + const jitter_size = pipe.connection.network.prng.int_inclusive( + u32, + delay.jitter_ms, + ); + const jitter_diff_ms: i32 = @as(i32, @intCast(jitter_size)) * + (if (pipe.connection.network.prng.boolean()) @as(i32, 1) else -1); + // timeout(0) is banned - 1ns is close enough. + const timeout_duration_ns = @as( + u63, + @intCast(@as(i32, @intCast(delay.time_ms)) + jitter_diff_ms), + ) * std.time.ns_per_ms + 1; + assert(timeout_duration_ns > 0); + + log.debug("delaying {} ({d},{d})", .{ + std.fmt.fmtDuration(timeout_duration_ns), + pipe.connection.replica_index, + pipe.connection.connection_index, + }); + + assert(pipe.status == .idle); + pipe.status = .send_timeout; + pipe.io.timeout( + *Pipe, + pipe, + timeout_callback, + &pipe.send_completion, + timeout_duration_ns, + ); + } else { + pipe.send(); + } + } + + fn timeout_callback(pipe: *Pipe, _: *IO.Completion, result: IO.TimeoutError!void) void { + assert(pipe.status == .send_timeout); + assert(pipe.connection.state != .free); + assert(pipe.connection.state != .accepting); + assert(pipe.connection.state != .connecting); + pipe.status = .idle; + + if (pipe.connection.state != .proxying) return; + + result catch @panic("timeout error"); + pipe.send(); + } + + fn send(pipe: *Pipe) void { + assert(pipe.connection.state == .proxying); + assert(pipe.send_size < pipe.recv_size); + + assert(pipe.status == .idle); + pipe.status = .send; + + pipe.io.send( + *Pipe, + pipe, + send_callback, + &pipe.send_completion, + pipe.output.?, + pipe.buffer[pipe.send_size..pipe.recv_size], + ); + } + + fn send_callback(pipe: *Pipe, _: *IO.Completion, result: IO.SendError!usize) void { + assert(pipe.send_size < pipe.recv_size); + assert(pipe.connection.state != .free); + assert(pipe.connection.state != .accepting); + assert(pipe.connection.state != .connecting); + + assert(pipe.status == .send); + pipe.status = .idle; + + if (pipe.connection.state != .proxying) return; + + const send_size = result catch |err| { + log.warn("send error ({d},{d}): {any}", .{ + pipe.connection.replica_index, + pipe.connection.connection_index, + err, + }); + return pipe.connection.try_close(); + }; + pipe.send_size += @intCast(send_size); + + if (pipe.send_size < pipe.recv_size) { + pipe.send(); + } else { + assert(pipe.send_size == pipe.recv_size); + pipe.recv(); + } + } +}; + +const Connection = struct { + io: *IO, + network: *Network, + state: enum { + free, + accepting, + connecting, + proxying, + closing, + closing_origin, + closing_remote, + } = .free, + + replica_index: usize, + connection_index: usize, + + origin_fd: ?std.posix.socket_t = null, + remote_fd: ?std.posix.socket_t = null, + + origin_to_remote_pipe: Pipe, + remote_to_origin_pipe: Pipe, + + remote_address: ?stdx.SocketAddress = null, + + accept_completion: IO.Completion = undefined, + connect_completion: IO.Completion = undefined, + close_completion: IO.Completion = undefined, + + fn accept_callback( + connection: *Connection, + _: *IO.Completion, + result: IO.AcceptError!std.posix.socket_t, + ) void { + assert(connection.state == .accepting); + assert(connection.origin_fd == null); + assert(connection.remote_fd == null); + assert(connection.remote_address != null); + + const fd = result catch |err| { + log.warn("accept failed ({d},{d}): {}", .{ + connection.replica_index, + connection.connection_index, + err, + }); + return connection.try_close(); + }; + connection.origin_fd = fd; + + const remote_fd = connection.io.open_socket_tcp( + connection.remote_address.?.ip.family(), + tcp_options, + ) catch |err| { + log.warn("couldn't open socket for remote ({d},{d}): {}", .{ + connection.replica_index, + connection.connection_index, + err, + }); + return connection.try_close(); + }; + + connection.remote_fd = remote_fd; + connection.state = .connecting; + + connection.io.connect( + *Connection, + connection, + Connection.connect_callback, + &connection.connect_completion, + connection.remote_fd.?, + connection.remote_address.?, + ); + } + + fn connect_callback( + connection: *Connection, + _: *IO.Completion, + result: IO.ConnectError!void, + ) void { + assert(connection.state == .connecting); + assert(connection.origin_fd != null); + assert(connection.remote_fd != null); + + result catch |err| { + log.warn("connect failed ({d},{d}): {}", .{ + connection.replica_index, + connection.connection_index, + err, + }); + return connection.try_close(); + }; + connection.state = .proxying; + connection.origin_to_remote_pipe.open(connection.origin_fd.?, connection.remote_fd.?); + connection.remote_to_origin_pipe.open(connection.remote_fd.?, connection.origin_fd.?); + } + + fn try_close(connection: *Connection) void { + assert(connection.state != .free); + + if (connection.state == .closing_origin or + connection.state == .closing_remote) return; + + const has_inflight_operations = + connection.origin_to_remote_pipe.status != .idle or + connection.remote_to_origin_pipe.status != .idle; + + if (connection.state != .closing and has_inflight_operations) { + log.debug("try_close ({d},{d}): marking connection as closing", .{ + connection.replica_index, + connection.connection_index, + }); + connection.state = .closing; + std.posix.shutdown(connection.origin_fd.?, .both) catch |err| switch (err) { + error.SocketNotConnected => {}, + else => log.warn("shutdown origin_fd ({d},{d}) failed: {}", .{ + connection.replica_index, connection.connection_index, err, + }), + }; + std.posix.shutdown(connection.remote_fd.?, .both) catch |err| switch (err) { + error.SocketNotConnected => {}, + else => log.warn("shutdown remote_fd ({d},{d}) failed: {}", .{ + connection.replica_index, connection.connection_index, err, + }), + }; + } + + if (has_inflight_operations) { + // Network.tick() will keep calling try_close(). + assert(connection.state == .closing); + } else { + // Kick off the close sequence. + connection.state = .closing_origin; + connection.io.close( + *Connection, + connection, + close_origin_callback, + &connection.close_completion, + connection.origin_fd.?, + ); + } + } + + fn close_origin_callback( + connection: *Connection, + _: *IO.Completion, + result: IO.CloseError!void, + ) void { + assert(connection.state == .closing_origin); + defer assert(connection.state == .closing_remote); + + assert(connection.origin_fd != null); + defer assert(connection.origin_fd == null); + + result catch |err| { + log.warn("close_origin_callback ({d},{d}) error: {any}", .{ + connection.replica_index, + connection.connection_index, + err, + }); + }; + + connection.state = .closing_remote; + connection.origin_fd = null; + connection.io.close( + *Connection, + connection, + close_remote_callback, + &connection.close_completion, + connection.remote_fd.?, + ); + } + + fn close_remote_callback( + connection: *Connection, + _: *IO.Completion, + result: IO.CloseError!void, + ) void { + assert(connection.state == .closing_remote); + defer assert(connection.state == .free); + + assert(connection.remote_fd != null); + defer assert(connection.remote_fd == null); + + result catch |err| { + log.warn("close_remote_callback ({d},{d}) error: {any}", .{ + connection.replica_index, + connection.connection_index, + err, + }); + }; + + log.debug("close_remote_callback ({d},{d}): marking connection as free", .{ + connection.replica_index, + connection.connection_index, + }); + connection.state = .free; + connection.remote_fd = null; + connection.remote_address = null; + connection.origin_to_remote_pipe = .{ .io = connection.io, .connection = connection }; + connection.remote_to_origin_pipe = .{ .io = connection.io, .connection = connection }; + } +}; + +const Proxy = struct { + io: *IO, + accept_fd: std.posix.socket_t, + origin_address: stdx.SocketAddress, // The proxy's address. + remote_address: stdx.SocketAddress, // The replica's address. + connections: [constants.vortex.connections_count_max]Connection, + + fn deinit(proxy: *Proxy) void { + proxy.io.close_socket(proxy.accept_fd); + proxy.* = undefined; + } +}; + +const tcp_options: IO.TCPOptions = .{ + .rcvbuf = 0, + .sndbuf = 0, + .keepalive = null, + .user_timeout_ms = 0, + .nodelay = false, +}; + +pub const Network = struct { + io: *IO, + prng: *stdx.PRNG, + proxies: []Proxy, + faults: Faults, + + pub fn listen( + allocator: std.mem.Allocator, + prng: *stdx.PRNG, + io: *IO, + replica_ports: []const u16, + ) !*Network { + const network = try allocator.create(Network); + errdefer allocator.destroy(network); + + const proxies = try allocator.alloc(Proxy, replica_ports.len); + errdefer allocator.free(proxies); + + network.* = .{ + .io = io, + .prng = prng, + .proxies = proxies, + .faults = std.mem.zeroes(Faults), + }; + + var proxies_initialized: usize = 0; + errdefer for (proxies[0..proxies_initialized]) |*proxy| proxy.deinit(); + + // Proxies get an unused port from the ephemeral port range (usually 32768-60999; see + // /proc/sys/net/ipv4/ip_local_port_range) by listening on port=0. + // We assume that replicas' ports are from outside of that range and cannot conflict. + for (proxies, replica_ports, 0..) |*proxy, replica_port, replica_index| { + const replica_address: stdx.SocketAddress = .{ + .ip = .@"127.0.0.1", + .port = replica_port, + }; + const listen_address: stdx.SocketAddress = .{ .ip = .@"127.0.0.1", .port = 0 }; + const listen_fd = try io.open_socket_tcp(.IPv4, tcp_options); + errdefer io.close_socket(listen_fd); + + const origin_address = try io.listen(listen_fd, listen_address, .{ .backlog = 64 }); + proxy.* = .{ + .io = io, + .accept_fd = listen_fd, + .origin_address = origin_address, + .remote_address = replica_address, + .connections = undefined, + }; + + for (&proxy.connections, 0..) |*connection, connection_index| { + connection.* = .{ + .io = io, + .network = network, + .state = .free, + .replica_index = replica_index, + .connection_index = connection_index, + .origin_to_remote_pipe = .{ .io = io, .connection = connection }, + .remote_to_origin_pipe = .{ .io = io, .connection = connection }, + }; + } + proxies_initialized += 1; + + log.debug("proxying {any} -> {any}", .{ origin_address, replica_address }); + } + + return network; + } + + pub fn destroy(network: *Network, allocator: std.mem.Allocator) void { + for (network.proxies) |*proxy| proxy.deinit(); + allocator.free(network.proxies); + allocator.destroy(network); + } + + pub fn tick(network: *Network) void { + for (network.proxies, 0..) |*proxy, replica_index| { + for (&proxy.connections) |*connection| { + assert(connection.replica_index == replica_index); + + if (connection.state == .closing) { + connection.try_close(); + continue; + } + // This proxy tries to accept with connections that are free. The pipes must also + // have no outstanding IO submissions racing with reusing the pipes for new + // connections. + if (connection.state == .free) { + assert(connection.origin_to_remote_pipe.status == .idle); + assert(connection.remote_to_origin_pipe.status == .idle); + assert(connection.origin_fd == null); + assert(connection.remote_fd == null); + assert(connection.remote_address == null); + + log.debug("accepting ({d},{d})", .{ + connection.replica_index, + connection.connection_index, + }); + + connection.state = .accepting; + connection.remote_address = proxy.remote_address; + + network.io.accept( + *Connection, + connection, + Connection.accept_callback, + &connection.accept_completion, + proxy.accept_fd, + ); + } + } + } + } +}; diff --git a/ocam/src/testing/vortex/java_driver/.gitignore b/ocam/src/testing/vortex/java_driver/.gitignore new file mode 100644 index 00000000..5356a95b --- /dev/null +++ b/ocam/src/testing/vortex/java_driver/.gitignore @@ -0,0 +1,10 @@ +build +target +src/main/resources/lib/** +examples/build +examples/target +*tigerbeetle.benchmark +*tigerbeetle.examples +*tigerbeetle.tests +*.log +lib/ diff --git a/ocam/src/testing/vortex/java_driver/README.md b/ocam/src/testing/vortex/java_driver/README.md new file mode 100644 index 00000000..7c37eb57 --- /dev/null +++ b/ocam/src/testing/vortex/java_driver/README.md @@ -0,0 +1,14 @@ +# Vortex Java Driver + +This implements a driver for Vortex, using the Java client. + +Run the following to test with this driver: + +``` +./zig/zig build clients:java +(cd src/clients/java && mvn package) +(cd src/testing/vortex/java_driver && mvn package) +CLASS_PATH="src/clients/java/target/tigerbeetle-java-0.0.1-SNAPSHOT.jar" +CLASS_PATH="${CLASS_PATH}:src/testing/vortex/java_driver/target/vortex-driver-java-0.0.1-SNAPSHOT.jar" + zig build vortex -- --driver-command=java\ -cp\ $CLASS_PATH\ Main +``` diff --git a/ocam/src/testing/vortex/java_driver/ci.zig b/ocam/src/testing/vortex/java_driver/ci.zig new file mode 100644 index 00000000..8d8bac20 --- /dev/null +++ b/ocam/src/testing/vortex/java_driver/ci.zig @@ -0,0 +1,39 @@ +const builtin = @import("builtin"); +const std = @import("std"); +const log = std.log; +const assert = std.debug.assert; + +const Shell = @import("stdx").Shell; + +pub fn tests(shell: *Shell, gpa: std.mem.Allocator) !void { + _ = gpa; + + assert(shell.file_exists("pom.xml")); + + // NB: This expects the TB java driver to have been installed with `mvn install`. + try shell.exec("mvn --batch-mode --file pom.xml --quiet package", .{}); + + // NB: This expects the vortex bin to be available. + if (builtin.target.os.tag == .linux) { + const base_path = "../../../../"; + const vortex_bin = base_path ++ "zig-out/bin/vortex"; + const class_path_driver = base_path ++ + "src/clients/java/target/tigerbeetle-java-0.0.1-SNAPSHOT.jar"; + const class_path = class_path_driver ++ ":" ++ base_path ++ + "src/testing/vortex/java_driver/target/vortex-driver-java-0.0.1-SNAPSHOT.jar"; + const driver_command = "java -cp " ++ class_path ++ " Main"; + try shell.exec( + "{vortex_bin} " ++ + "--driver-command={driver_command} " ++ + "--replica-count=1 " ++ + "--disable-faults " ++ + "--test-duration=1s", + .{ + .vortex_bin = vortex_bin, + .driver_command = driver_command, + }, + ); + } else { + log.warn("Not testing vortex java on OS {}", .{builtin.target.os.tag}); + } +} diff --git a/ocam/src/testing/vortex/java_driver/pom.xml b/ocam/src/testing/vortex/java_driver/pom.xml new file mode 100644 index 00000000..27c1313a --- /dev/null +++ b/ocam/src/testing/vortex/java_driver/pom.xml @@ -0,0 +1,45 @@ + + 4.0.0 + + com.tigerbeetle.vortex + vortex-driver-java + 0.0.1-SNAPSHOT + + + 11 + 11 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all,-options,-path + + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + Main + + + + + + + + com.tigerbeetle + tigerbeetle-java + 0.0.1-SNAPSHOT + + + diff --git a/ocam/src/testing/vortex/java_driver/src/main/java/Main.java b/ocam/src/testing/vortex/java_driver/src/main/java/Main.java new file mode 100644 index 00000000..1715c055 --- /dev/null +++ b/ocam/src/testing/vortex/java_driver/src/main/java/Main.java @@ -0,0 +1,733 @@ +import java.io.IOException; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import com.tigerbeetle.AccountBatch; +import com.tigerbeetle.AccountFlags; +import com.tigerbeetle.Client; +import com.tigerbeetle.CreateAccountStatus; +import com.tigerbeetle.CreateAccountResultBatch; +import com.tigerbeetle.CreateTransferStatus; +import com.tigerbeetle.CreateTransferResultBatch; +import com.tigerbeetle.IdBatch; +import com.tigerbeetle.TransferBatch; +import com.tigerbeetle.TransferFlags; +import com.tigerbeetle.UInt128; + +/** + * A Vortex driver using the Java language client for TigerBeetle. + */ +public final class Main { + public static void main(String[] args) throws Exception { + if (args.length != 2) { + throw new IllegalArgumentException( + "java driver requires two positional command-line arguments"); + } + + byte[] clusterID = UInt128.asBytes(Long.parseLong(args[0])); + + var replicaAddressesArg = args[1]; + String[] replicaAddresses = replicaAddressesArg.split(","); + if (replicaAddresses.length == 0) { + throw new IllegalArgumentException( + "REPLICAS must list at least one address (comma-separated)"); + } + + try (var client = new Client(clusterID, replicaAddresses)) { + var reader = new Driver.Reader(Channels.newChannel(System.in)); + var writer = new Driver.Writer(Channels.newChannel(System.out)); + var driver = new Driver(client, reader, writer); + while (true) { + driver.next(); + } + } + } +} + +class Driver { + private final Client client; + private final Reader reader; + private final Writer writer; + + public Driver(Client client, Reader reader, Writer writer) { + this.client = client; + this.reader = reader; + this.writer = writer; + } + + public Client client() { return client; } + public Reader reader() { return reader; } + public Writer writer() { return writer; } + static ByteOrder BYTE_ORDER = ByteOrder.nativeOrder(); + static { + // We require little-endian architectures everywhere for efficient network + // deserialization: + if (BYTE_ORDER != ByteOrder.LITTLE_ENDIAN) { + throw new RuntimeException("Native byte order LITTLE_ENDIAN expected"); + } + } + + /** + * Reads the next operation from stdin, runs it, collects the results, and + * writes them back to + * stdout. + * + * @throws ExecutionException + * @throws InterruptedException + */ + void next() throws IOException, InterruptedException, ExecutionException { + reader.read(1 + 4); // operation + count + var operation = Operation.fromValue(reader.u8()); + var count = reader.u32(); + + // Maybe process asynchronously for testing multi-batch requests. + // While async calls can potentially split the batch into multiple requests, + // the goal is to stress concurrent `submit` calls with multi-batched operations. + // In the end, all async requests are re-joined and replied to as a single batch. + final var random = new Random(); + final boolean isAsync = random.nextBoolean(); + + switch (operation) { + case CREATE_ACCOUNTS: + if (isAsync) + createAccountsAsync(reader, writer, count); + else + createAccounts(reader, writer, count); + break; + case CREATE_TRANSFERS: + if (isAsync) + createTransfersAsync(reader, writer, count); + else + createTransfers(reader, writer, count); + break; + case LOOKUP_ACCOUNTS: + if (isAsync) + lookupAccountsAsync(reader, writer, count); + else + lookupAccounts(reader, writer, count); + break; + case LOOKUP_TRANSFERS: + if (isAsync) + lookupTransfersAsync(reader, writer, count); + else + lookupTransfers(reader, writer, count); + break; + case GET_ACCOUNT_BALANCES: + case GET_ACCOUNT_TRANSFERS: + case QUERY_ACCOUNTS: + case QUERY_TRANSFERS: + // The Vortex workload currently does not request these operations, so this driver doesn't + // support them (yet). + throw new RuntimeException("unsupported operation: " + operation.name()); + } + } + + void createAccounts(Reader reader, Writer writer, int count) throws IOException, InterruptedException { + reader.read(Driver.Operation.CREATE_ACCOUNTS.eventSize() * count); + var batch = new AccountBatch(count); + for (int index = 0; index < count; index++) { + batch.add(); + batch.setId(reader.u128()); + reader.u128(); // `debits_pending` + reader.u128(); // `debits_posted` + reader.u128(); // `credits_pending` + reader.u128(); // `credits_posted` + batch.setUserData128(reader.u128()); + batch.setUserData64(reader.u64()); + batch.setUserData32(reader.u32()); + reader.u32(); // `reserved` + batch.setLedger(reader.u32()); + batch.setCode(reader.u16()); + batch.setFlags(reader.u16()); + reader.u64(); // `timestamp` + } + var results = client.createAccounts(batch); + writer.allocate(4 + (Driver.Operation.CREATE_ACCOUNTS.resultSize() * results.getLength())); + writer.u32(results.getLength()); + while (results.next()) { + writer.u64(results.getTimestamp()); + writer.u32(results.getStatus().value); + writer.u32(0); + } + writer.flush(); + } + + void createAccountsAsync(Reader reader, Writer writer, int count) + throws IOException, InterruptedException, ExecutionException { + reader.read(Driver.Operation.CREATE_ACCOUNTS.eventSize() * count); + + final var requests = new ArrayList>(count); + var batch = new AccountBatch(count); + for (int index = 0; index < count; index++) { + batch.add(); + batch.setId(reader.u128()); + reader.u128(); // `debits_pending` + reader.u128(); // `debits_posted` + reader.u128(); // `credits_pending` + reader.u128(); // `credits_posted` + batch.setUserData128(reader.u128()); + batch.setUserData64(reader.u64()); + batch.setUserData32(reader.u32()); + reader.u32(); // `reserved` + batch.setLedger(reader.u32()); + batch.setCode(reader.u16()); + batch.setFlags(reader.u16()); + reader.u64(); // `timestamp` + + if (!AccountFlags.hasLinked(batch.getFlags())) { + requests.add(client.createAccountsAsync(batch)); + batch = new AccountBatch(count - index); + } + } + + // Sending any eventual non-closed linked chain. + if (batch.getLength() > 0) { + requests.add(client.createAccountsAsync(batch)); + } + + class Result { + private final long timestamp; + private final CreateAccountStatus status; + + public Result(long timestamp, CreateAccountStatus status) { + this.timestamp = timestamp; + this.status = status; + } + } + var results = new ArrayList(count); + + // Wait for all tasks. + for (final var request : requests) { + final var result = request.get(); + while (result.next()) { + results.add(new Result(result.getTimestamp(), result.getStatus())); + } + } + + writer.allocate(4 + (Driver.Operation.CREATE_ACCOUNTS.resultSize() * results.size())); + writer.u32(results.size()); + for (final var result : results) { + writer.u64(result.timestamp); + writer.u32(result.status.value); + writer.u32(0); + } + writer.flush(); + } + + void createTransfers(Reader reader, Writer writer, int count) throws IOException, InterruptedException { + reader.read(Driver.Operation.CREATE_TRANSFERS.eventSize() * count); + var batch = new TransferBatch(count); + for (int index = 0; index < count; index++) { + batch.add(); + batch.setId(reader.u128()); + batch.setDebitAccountId(reader.u128()); + batch.setCreditAccountId(reader.u128()); + batch.setAmount(reader.u64(), reader.u64()); + batch.setPendingId(reader.u128()); + batch.setUserData128(reader.u128()); + batch.setUserData64(reader.u64()); + batch.setUserData32(reader.u32()); + batch.setTimeout(reader.u32()); + batch.setLedger(reader.u32()); + batch.setCode(reader.u16()); + batch.setFlags(reader.u16()); + batch.setTimestamp(reader.u64()); + } + var results = client.createTransfers(batch); + writer.allocate(4 + (Driver.Operation.CREATE_TRANSFERS.resultSize() * results.getLength())); + writer.u32(results.getLength()); + while (results.next()) { + writer.u64(results.getTimestamp()); + writer.u32(results.getStatus().value); + writer.u32(0); + } + writer.flush(); + } + + void createTransfersAsync(Reader reader, Writer writer, int count) + throws IOException, InterruptedException, ExecutionException { + reader.read(Driver.Operation.CREATE_TRANSFERS.eventSize() * count); + + final var requests = new ArrayList>(count); + var batch = new TransferBatch(count); + for (int index = 0; index < count; index++) { + batch.add(); + batch.setId(reader.u128()); + batch.setDebitAccountId(reader.u128()); + batch.setCreditAccountId(reader.u128()); + batch.setAmount(reader.u64(), reader.u64()); + batch.setPendingId(reader.u128()); + batch.setUserData128(reader.u128()); + batch.setUserData64(reader.u64()); + batch.setUserData32(reader.u32()); + batch.setTimeout(reader.u32()); + batch.setLedger(reader.u32()); + batch.setCode(reader.u16()); + batch.setFlags(reader.u16()); + batch.setTimestamp(reader.u64()); + + if (!TransferFlags.hasLinked(batch.getFlags())) { + requests.add(client.createTransfersAsync(batch)); + batch = new TransferBatch(count - index); + } + } + + // Sending any eventual non-closed linked chain. + if (batch.getLength() > 0) { + requests.add(client.createTransfersAsync(batch)); + } + + class Result { + private final long timestamp; + private final CreateTransferStatus status; + + public Result(long timestamp, CreateTransferStatus status) { + this.timestamp = timestamp; + this.status = status; + } + } + var results = new ArrayList(count); + + // Wait for all tasks. + for (final var request : requests) { + final var result = request.get(); + while (result.next()) { + results.add(new Result(result.getTimestamp(), result.getStatus())); + } + } + + writer.allocate(4 + (Driver.Operation.CREATE_TRANSFERS.resultSize() * results.size())); + writer.u32(results.size()); + for (final var result : results) { + writer.u64(result.timestamp); + writer.u32(result.status.value); + writer.u32(0); + } + writer.flush(); + } + + void lookupAccounts(Reader reader, Writer writer, int count) throws IOException, InterruptedException { + reader.read(Driver.Operation.LOOKUP_ACCOUNTS.eventSize() * count); + var batch = new IdBatch(count); + for (int index = 0; index < count; index++) { + batch.add(); + batch.setId(reader.u128()); + } + var results = client.lookupAccounts(batch); + writer.allocate(4 + (Driver.Operation.LOOKUP_ACCOUNTS.resultSize() * results.getLength())); + writer.u32(results.getLength()); + while (results.next()) { + writer.u128(results.getId()); + writer.u128(UInt128.asBytes(results.getDebitsPending())); + writer.u128(UInt128.asBytes(results.getDebitsPosted())); + writer.u128(UInt128.asBytes(results.getCreditsPending())); + writer.u128(UInt128.asBytes(results.getCreditsPosted())); + writer.u128(results.getUserData128()); + writer.u64(results.getUserData64()); + writer.u32(results.getUserData32()); + writer.u32(0); // `reserved` + writer.u32(results.getLedger()); + writer.u16(results.getCode()); + writer.u16(results.getFlags()); + writer.u64(results.getTimestamp()); + } + writer.flush(); + } + + void lookupAccountsAsync(Reader reader, Writer writer, int count) + throws IOException, InterruptedException, ExecutionException { + reader.read(Driver.Operation.LOOKUP_ACCOUNTS.eventSize() * count); + + final var requests = new ArrayList>(count); + for (int index = 0; index < count; index++) { + var batch = new IdBatch(1); + batch.add(); + batch.setId(reader.u128()); + + requests.add(client.lookupAccountsAsync(batch)); + } + + class Result { + private final byte[] id; + private final BigInteger debitsPending; + private final BigInteger debitsPosted; + private final BigInteger creditsPending; + private final BigInteger creditsPosted; + private final byte[] userData128; + private final long userData64; + private final int userData32; + private final int ledger; + private final int code; + private final int flags; + private final long timestamp; + + public Result(byte[] id, BigInteger debitsPending, BigInteger debitsPosted, + BigInteger creditsPending, BigInteger creditsPosted, + byte[] userData128, long userData64, int userData32, + int ledger, int code, int flags, long timestamp) { + this.id = id; + this.debitsPending = debitsPending; + this.debitsPosted = debitsPosted; + this.creditsPending = creditsPending; + this.creditsPosted = creditsPosted; + this.userData128 = userData128; + this.userData64 = userData64; + this.userData32 = userData32; + this.ledger = ledger; + this.code = code; + this.flags = flags; + this.timestamp = timestamp; + } + } + var results = new ArrayList(count); + + // Wait for all tasks. + for (final var request : requests) { + final var result = request.get(); + + if (result.next()) { + results.add(new Result( + result.getId(), + result.getDebitsPending(), + result.getDebitsPosted(), + result.getCreditsPending(), + result.getCreditsPosted(), + result.getUserData128(), + result.getUserData64(), + result.getUserData32(), + result.getLedger(), + result.getCode(), + result.getFlags(), + result.getTimestamp())); + } + } + + writer.allocate(4 + (Driver.Operation.LOOKUP_ACCOUNTS.resultSize() * results.size())); + writer.u32(results.size()); + for (final var result : results) { + writer.u128(result.id); + writer.u128(UInt128.asBytes(result.debitsPending)); + writer.u128(UInt128.asBytes(result.debitsPosted)); + writer.u128(UInt128.asBytes(result.creditsPending)); + writer.u128(UInt128.asBytes(result.creditsPosted)); + writer.u128(result.userData128); + writer.u64(result.userData64); + writer.u32(result.userData32); + writer.u32(0); // `reserved` + writer.u32(result.ledger); + writer.u16(result.code); + writer.u16(result.flags); + writer.u64(result.timestamp); + } + writer.flush(); + } + + void lookupTransfers(Reader reader, Writer writer, int count) throws IOException, InterruptedException { + reader.read(Driver.Operation.LOOKUP_TRANSFERS.eventSize() * count); + var batch = new IdBatch(count); + for (int index = 0; index < count; index++) { + batch.add(); + batch.setId(reader.u128()); + } + var results = client.lookupTransfers(batch); + writer.allocate(4 + (Driver.Operation.LOOKUP_TRANSFERS.resultSize() * results.getLength())); + writer.u32(results.getLength()); + while (results.next()) { + writer.u128(results.getId()); + writer.u128(results.getDebitAccountId()); + writer.u128(results.getCreditAccountId()); + writer.u128(UInt128.asBytes(results.getAmount())); + writer.u128(results.getPendingId()); + writer.u128(results.getUserData128()); + writer.u64(results.getUserData64()); + writer.u32(results.getUserData32()); + writer.u32(results.getTimeout()); + writer.u32(results.getLedger()); + writer.u16(results.getCode()); + writer.u16(results.getFlags()); + writer.u64(results.getTimestamp()); + } + writer.flush(); + } + + void lookupTransfersAsync(Reader reader, Writer writer, int count) + throws IOException, InterruptedException, ExecutionException { + reader.read(Driver.Operation.LOOKUP_TRANSFERS.eventSize() * count); + + final var requests = new ArrayList>(count); + for (int index = 0; index < count; index++) { + var batch = new IdBatch(count); + batch.add(); + batch.setId(reader.u128()); + + requests.add(client.lookupTransfersAsync(batch)); + } + + class Result { + private final byte[] id; + private final byte[] debitAccountId; + private final byte[] creditAccountId; + private final BigInteger amount; + private final byte[] pendingId; + private final byte[] userData128; + private final long userData64; + private final int userData32; + private final int timeout; + private final int ledger; + private final int code; + private final int flags; + private final long timestamp; + + public Result(byte[] id, byte[] debitAccountId, byte[] creditAccountId, + BigInteger amount, byte[] pendingId, + byte[] userData128, long userData64, int userData32, + int timeout, int ledger, int code, int flags, long timestamp) { + this.id = id; + this.debitAccountId = debitAccountId; + this.creditAccountId = creditAccountId; + this.amount = amount; + this.pendingId = pendingId; + this.userData128 = userData128; + this.userData64 = userData64; + this.userData32 = userData32; + this.timeout = timeout; + this.ledger = ledger; + this.code = code; + this.flags = flags; + this.timestamp = timestamp; + } + } + var results = new ArrayList(count); + + // Wait for all tasks. + for (final var request : requests) { + final var result = request.get(); + + if (result.next()) { + results.add(new Result( + result.getId(), + result.getDebitAccountId(), + result.getCreditAccountId(), + result.getAmount(), + result.getPendingId(), + result.getUserData128(), + result.getUserData64(), + result.getUserData32(), + result.getTimeout(), + result.getLedger(), + result.getCode(), + result.getFlags(), + result.getTimestamp())); + } + } + + writer.allocate(4 + (Driver.Operation.LOOKUP_TRANSFERS.resultSize() * results.size())); + writer.u32(results.size()); + for (final var result : results) { + writer.u128(result.id); + writer.u128(result.debitAccountId); + writer.u128(result.creditAccountId); + writer.u128(UInt128.asBytes(result.amount)); + writer.u128(result.pendingId); + writer.u128(result.userData128); + writer.u64(result.userData64); + writer.u32(result.userData32); + writer.u32(result.timeout); + writer.u32(result.ledger); + writer.u16(result.code); + writer.u16(result.flags); + writer.u64(result.timestamp); + } + writer.flush(); + } + + // Based off `Operation` in `src/state_machine.zig`. + enum Operation { + CREATE_ACCOUNTS(146), + CREATE_TRANSFERS(147), + LOOKUP_ACCOUNTS(140), + LOOKUP_TRANSFERS(141), + GET_ACCOUNT_TRANSFERS(142), + GET_ACCOUNT_BALANCES(143), + QUERY_ACCOUNTS(144), + QUERY_TRANSFERS(145); + + int value; + + Operation(int value) { + this.value = value; + } + + static Map BY_VALUE = new HashMap<>(); + static { + for (var element : values()) { + BY_VALUE.put(element.value, element); + } + } + + static Operation fromValue(int value) { + var result = BY_VALUE.get(value); + if (result == null) { + throw new RuntimeException("invalid operation: " + value); + } + return result; + } + + int eventSize() { + switch (this) { + case CREATE_ACCOUNTS: + return 128; + case CREATE_TRANSFERS: + return 128; + case LOOKUP_ACCOUNTS: + return 16; + case LOOKUP_TRANSFERS: + return 16; + case GET_ACCOUNT_BALANCES: + case GET_ACCOUNT_TRANSFERS: + case QUERY_ACCOUNTS: + case QUERY_TRANSFERS: + default: + throw new RuntimeException("unsupported operation: " + name()); + } + } + + int resultSize() { + switch (this) { + case CREATE_ACCOUNTS: + return 16; + case CREATE_TRANSFERS: + return 16; + case LOOKUP_ACCOUNTS: + return 128; + case LOOKUP_TRANSFERS: + return 128; + case GET_ACCOUNT_BALANCES: + case GET_ACCOUNT_TRANSFERS: + case QUERY_ACCOUNTS: + case QUERY_TRANSFERS: + default: + throw new RuntimeException("unsupported operation: " + name()); + } + } + } + + /** + * Reads sized chunks into a buffer, and uses that to convert from + * the Vortex driver binary protocol data to natively typed values. + * + * The entire `read` buffer must be consumed before calling `read` again. + */ + static class Reader { + ReadableByteChannel input; + ByteBuffer buffer = null; + + Reader(ReadableByteChannel input) { + this.input = input; + } + + void read(int count) throws IOException { + if (this.buffer != null && this.buffer.hasRemaining()) { + throw new RuntimeException(String.format("existing read buffer has %d bytes remaining", + this.buffer.remaining())); + } + this.buffer = ByteBuffer.allocateDirect(count).order(BYTE_ORDER); + int read = 0; + while (read < count) { + read += input.read(this.buffer); + } + this.buffer.rewind(); + } + + int u8() throws IOException { + return Byte.toUnsignedInt(buffer.get()); + } + + int u16() throws IOException { + return Short.toUnsignedInt(buffer.getShort()); + } + + int u32() throws IOException { + return (int) Integer.toUnsignedLong(buffer.getInt()); + } + + long u64() throws IOException { + return buffer.getLong(); + } + + byte[] u128() throws IOException { + var result = new byte[16]; + buffer.get(result, 0, 16); + return result; + } + } + + /** + * Allocates a buffer of a certain size, and writes natively typed values as + * Vortex driver binary protocol data. + * + * The entire allocated buffer must be filled before writing or allocating a + * new buffer. + */ + static class Writer { + WritableByteChannel output; + ByteBuffer buffer = null; + + Writer(WritableByteChannel output) { + this.output = output; + } + + void allocate(int size) { + if (this.buffer != null && this.buffer.hasRemaining()) { + throw new RuntimeException(String.format("existing buffer has %d bytes remaining", + this.buffer.remaining())); + } + this.buffer = ByteBuffer.allocateDirect(size).order(BYTE_ORDER).position(0); + } + + /** + * Writes the buffer to the output channel. The buffer must be filled. + */ + void flush() throws IOException { + if (this.buffer != null && this.buffer.hasRemaining()) { + throw new RuntimeException(String.format("buffer has %d bytes remaining, refusing to write", + this.buffer.remaining())); + } + buffer.rewind(); + while (buffer.hasRemaining()) { + output.write(buffer); + } + } + + void u8(int value) throws IOException { + buffer.put((byte) value); + } + + void u16(int value) throws IOException { + buffer.putShort((short) value); + } + + void u32(int value) throws IOException { + buffer.putInt(value); + } + + void u64(long value) throws IOException { + buffer.putLong(value); + } + + void u128(byte[] value) throws IOException { + buffer.put(value); + } + + } +} diff --git a/ocam/src/testing/vortex/rust_driver/.gitignore b/ocam/src/testing/vortex/rust_driver/.gitignore new file mode 100644 index 00000000..2c96eb1b --- /dev/null +++ b/ocam/src/testing/vortex/rust_driver/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/ocam/src/testing/vortex/rust_driver/Cargo.toml b/ocam/src/testing/vortex/rust_driver/Cargo.toml new file mode 100644 index 00000000..3f68d80a --- /dev/null +++ b/ocam/src/testing/vortex/rust_driver/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "vortex-driver-rust" +version = "0.1.0" +edition = "2021" + +[dependencies] +tigerbeetle.path = "../../../clients/rust" +futures = { version = "0.3.31", default-features = false, features = ["executor"] } +anyhow = "1.0.93" diff --git a/ocam/src/testing/vortex/rust_driver/README.md b/ocam/src/testing/vortex/rust_driver/README.md new file mode 100644 index 00000000..5c81d629 --- /dev/null +++ b/ocam/src/testing/vortex/rust_driver/README.md @@ -0,0 +1,13 @@ +# Vortex Rust Driver + +This implements a driver for Vortex, using the Rust client. + +Run the following to test with this driver: + +``` +./zig/zig build clients:rust +(cd src/clients/rust && cargo build) +(cd src/testing/vortex/rust_driver && cargo build) +zig build vortex -- \ + --driver-command='./src/testing/vortex/rust_driver/target/debug/vortex-driver-rust' +``` diff --git a/ocam/src/testing/vortex/rust_driver/ci.zig b/ocam/src/testing/vortex/rust_driver/ci.zig new file mode 100644 index 00000000..b6a1320d --- /dev/null +++ b/ocam/src/testing/vortex/rust_driver/ci.zig @@ -0,0 +1,34 @@ +const builtin = @import("builtin"); +const std = @import("std"); +const log = std.log; + +const Shell = @import("stdx").Shell; + +pub fn tests(shell: *Shell, gpa: std.mem.Allocator) !void { + _ = gpa; + + try shell.exec("cargo build", .{}); + try shell.exec("cargo fmt --check", .{}); + try shell.exec("cargo clippy -- -D clippy::all", .{}); + + // NB: This expects the vortex bin to be available. + if (builtin.target.os.tag == .linux) { + const base_path = "../../../../"; + const vortex_bin = base_path ++ "zig-out/bin/vortex"; + const driver_command = base_path ++ + "src/testing/vortex/rust_driver/target/debug/vortex-driver-rust"; + try shell.exec( + "{vortex_bin} " ++ + "--driver-command={driver_command} " ++ + "--replica-count=1 " ++ + "--disable-faults " ++ + "--test-duration=1s", + .{ + .vortex_bin = vortex_bin, + .driver_command = driver_command, + }, + ); + } else { + log.warn("Not testing vortex rust driver on OS {}", .{builtin.target.os.tag}); + } +} diff --git a/ocam/src/testing/vortex/rust_driver/src/main.rs b/ocam/src/testing/vortex/rust_driver/src/main.rs new file mode 100644 index 00000000..d0c9a22f --- /dev/null +++ b/ocam/src/testing/vortex/rust_driver/src/main.rs @@ -0,0 +1,240 @@ +#![allow(unused)] +// This code reads better if all protocol byte conversions are transmutes - +// rustc would prefer us to use safe conversions for the u128s. + +use anyhow::Result as AnyResult; +use anyhow::{bail, Context}; +use futures::executor::block_on; +use std::mem; +use std::str::FromStr; +use tb::tb_client as tbc; +use tigerbeetle as tb; + +struct CliArgs { + cluster_id: u128, + addresses: String, +} + +fn main() -> AnyResult<()> { + let args = std::env::args(); + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + + let args = CliArgs::parse(args)?; + let mut input = Input::from(stdin); + let mut output = Output::from(stdout); + + let mut client = tb::Client::new(args.cluster_id, &args.addresses)?; + + while let Some(op) = input.receive()? { + let result = execute(&mut client, op)?; + output.send(result)?; + } + + Ok(()) +} + +fn execute(client: &mut tb::Client, op: Request) -> AnyResult { + match op { + Request::CreateAccounts(accounts) => { + let response = client.create_accounts(&accounts)?; + let response = block_on(response)?; + Ok(Reply::CreateAccounts(response)) + } + Request::CreateTransfers(transfers) => { + let response = client.create_transfers(&transfers)?; + let response = block_on(response)?; + Ok(Reply::CreateTransfers(response)) + } + Request::LookupAccounts(account_ids) => { + let response = client.lookup_accounts(&account_ids)?; + let response = block_on(response)?; + Ok(Reply::LookupAccounts(response)) + } + Request::LookupTransfers(transfer_ids) => { + let response = client.lookup_transfers(&transfer_ids)?; + let response = block_on(response)?; + Ok(Reply::LookupTransfers(response)) + } + } +} + +impl CliArgs { + fn parse(mut args: std::env::Args) -> AnyResult { + let _arg0 = args.next(); + let arg1 = args.next(); + let arg2 = args.next(); + let (arg1, arg2) = match (arg1, arg2) { + (Some(arg1), Some(arg2)) => (arg1, arg2), + _ => bail!("two arguments required"), + }; + + let cluster_id: u128 = + u128::from_str(&arg1).context("cluster id (argument 1) must be u128")?; + let addresses = arg2; + + Ok(CliArgs { + cluster_id, + addresses, + }) + } +} + +enum Request { + CreateAccounts(Vec), + CreateTransfers(Vec), + LookupAccounts(Vec), + LookupTransfers(Vec), +} + +enum Reply { + CreateAccounts(Vec), + CreateTransfers(Vec), + LookupAccounts(Vec), + LookupTransfers(Vec), +} + +struct Input { + reader: Box, +} + +impl From for Input { + fn from(stdin: std::io::Stdin) -> Input { + Input { + reader: Box::new(stdin), + } + } +} + +impl Input { + fn receive(&mut self) -> AnyResult> { + let op = { + let mut bytes = [0; 1]; + if let Err(e) = self.reader.read_exact(&mut bytes) { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + return Ok(None); + } else { + return Err(e.into()); + } + } + u8::from_le_bytes(bytes) + }; + + let event_count = { + let mut bytes = [0; 4]; + self.reader.read_exact(&mut bytes)?; + u32::from_le_bytes(bytes) + }; + + match op { + tbc::TB_OPERATION_TB_OPERATION_CREATE_ACCOUNTS => { + let mut events = Vec::with_capacity(event_count as usize); + for i in 0..event_count { + let mut bytes = [0; mem::size_of::()]; + self.reader.read_exact(&mut bytes)?; + let event: tb::Account = unsafe { mem::transmute(bytes) }; + events.push(event); + } + Ok(Some(Request::CreateAccounts(events))) + } + tbc::TB_OPERATION_TB_OPERATION_CREATE_TRANSFERS => { + let mut events = Vec::with_capacity(event_count as usize); + for i in 0..event_count { + let mut bytes = [0; mem::size_of::()]; + self.reader.read_exact(&mut bytes)?; + let event: tb::Transfer = unsafe { mem::transmute(bytes) }; + events.push(event); + } + Ok(Some(Request::CreateTransfers(events))) + } + tbc::TB_OPERATION_TB_OPERATION_LOOKUP_ACCOUNTS => { + let mut events = Vec::with_capacity(event_count as usize); + for i in 0..event_count { + let mut bytes = [0; mem::size_of::()]; + self.reader.read_exact(&mut bytes)?; + let event: u128 = unsafe { u128::from_ne_bytes(bytes) }; + events.push(event); + } + Ok(Some(Request::LookupAccounts(events))) + } + tbc::TB_OPERATION_TB_OPERATION_LOOKUP_TRANSFERS => { + let mut events = Vec::with_capacity(event_count as usize); + for i in 0..event_count { + let mut bytes = [0; mem::size_of::()]; + self.reader.read_exact(&mut bytes)?; + let event: u128 = unsafe { u128::from_ne_bytes(bytes) }; + events.push(event); + } + Ok(Some(Request::LookupTransfers(events))) + } + _ => todo!("{op}"), + } + } +} + +struct Output { + writer: Box, +} + +impl From for Output { + fn from(stdout: std::io::Stdout) -> Output { + Output { + writer: Box::new(stdout), + } + } +} + +impl Output { + fn send(&mut self, result: Reply) -> AnyResult<()> { + match result { + Reply::CreateAccounts(results) => { + let results_length = u32::try_from(results.len())?; + self.writer.write_all(&results_length.to_le_bytes())?; + for result in results { + let result = tbc::tb_create_account_result_t { + timestamp: result.timestamp, + status: u32::from(result.status), + reserved: 0, + }; + let bytes: [u8; mem::size_of::()] = + unsafe { mem::transmute(result) }; + self.writer.write_all(&bytes)?; + } + } + Reply::CreateTransfers(results) => { + let results_length = u32::try_from(results.len())?; + self.writer.write_all(&results_length.to_le_bytes())?; + for result in results { + let result = tbc::tb_create_transfer_result_t { + timestamp: result.timestamp, + status: u32::from(result.status), + reserved: 0, + }; + let bytes: [u8; mem::size_of::()] = + unsafe { mem::transmute(result) }; + self.writer.write_all(&bytes)?; + } + } + Reply::LookupAccounts(results) => { + let results_length = u32::try_from(results.len())?; + self.writer.write_all(&results_length.to_le_bytes())?; + for result in results { + let bytes: [u8; mem::size_of::()] = + unsafe { mem::transmute(result) }; + self.writer.write_all(&bytes)?; + } + } + Reply::LookupTransfers(results) => { + let results_length = u32::try_from(results.len())?; + self.writer.write_all(&results_length.to_le_bytes())?; + for result in results { + let bytes: [u8; mem::size_of::()] = + unsafe { mem::transmute(result) }; + self.writer.write_all(&bytes)?; + } + } + } + self.writer.flush()?; + Ok(()) + } +} diff --git a/ocam/src/testing/vortex/supervisor.zig b/ocam/src/testing/vortex/supervisor.zig new file mode 100644 index 00000000..d687b65c --- /dev/null +++ b/ocam/src/testing/vortex/supervisor.zig @@ -0,0 +1,1118 @@ +//! The Vortex _supervisor_ is a program that runs: +//! +//! * a set of TigerBeetle replicas, forming a cluster +//! * a workload that runs commands and queries against the cluster, verifying its correctness +//! (whatever that means is up to the workload) +//! +//! The replicas and driver run as child processes, while the supervisor restarts terminated +//! replicas and injects crashes and network faults. After some configurable amount of time, the +//! supervisor terminates the driver and replicas, unless the driver exits on its own or if +//! any of the replicas exit unexpectedly. +//! +//! If no replicas (or the driver) crash, the vortex exits successfully. +//! +//! To launch a one-second smoke test, run this command from the repository root: +//! +//! $ zig build test:integration -- "vortex smoke" +//! +//! If you need more control, you can run this program directly. +//! +//! $ zig build vortex +//! +//! Other options: +//! +//! * Set the test duration by adding the `--test-duration=XmYs` option (it's 1 minute by default). +//! * Enable replica debug logging with `--log-debug`. +//! +//! If you have permissions troubles with unshare and Ubuntu, see: +//! https://github.com/YoYoGames/GameMaker-Bugs/issues/6015#issuecomment-2135552784 +//! +//! Further possible work: +//! +//! * full partitioning +//! * filesystem faults +//! * clock faults +//! * upgrade clients +//! * multiple drivers? could use a special multiplexer driver that delegates to others + +const std = @import("std"); +const stdx = @import("stdx"); +const builtin = @import("builtin"); +const IO = @import("../../io.zig").IO; +const RingBufferType = stdx.RingBufferType; +const Network = @import("./faulty_network.zig").Network; +const constants = @import("constants.zig"); +const ratio = stdx.PRNG.ratio; +const Shell = stdx.Shell; + +const assert = std.debug.assert; +const maybe = stdx.maybe; +const log = std.log.scoped(.supervisor); +const Release = @import("../../multiversion.zig").Release; + +const dependencies_path: []const u8 = @import("vortex_options").dependencies_path; +const dependencies_count: u32 = @import("vortex_options").dependencies_count; + +/// Executables/releases are ordered from oldest to newest. +/// All paths are absolute. +fn configuration(shell: *Shell) !struct { + server_executables: [dependencies_count][]const u8, + driver_executables: [dependencies_count][]const u8, + releases: [dependencies_count]Release, +} { + comptime assert(dependencies_count > 0); + const server_executables: [dependencies_count][]const u8 = comptime array: { + var executables: [dependencies_count][]const u8 = undefined; + for (&executables, 0..) |*server, i| { + server.* = std.fmt.comptimePrint( + "{s}/tigerbeetle-{d}", + .{ dependencies_path, dependencies_count - i - 1 }, + ); + } + break :array executables; + }; + const driver_executables: [dependencies_count][]const u8 = comptime array: { + var executables: [dependencies_count][]const u8 = undefined; + for (&executables, 0..) |*server, i| { + server.* = std.fmt.comptimePrint( + "{s}/vortex-driver-zig-{d}", + .{ dependencies_path, dependencies_count - i - 1 }, + ); + } + break :array executables; + }; + + const releases: [dependencies_count]Release = array: { + var release_list: [dependencies_count]Release = undefined; + for (&release_list, server_executables) |*release, executable| { + const output = try shell.exec_stdout("{exe} version", .{ .exe = executable }); + const prefix = "TigerBeetle version "; + const suffix = std.mem.indexOfScalar(u8, output, '+'); + assert(std.mem.startsWith(u8, output, prefix)); + + release.* = try Release.parse(output[prefix.len..suffix.?]); + } + break :array release_list; + }; + + for (server_executables) |path| assert(std.fs.path.isAbsolute(path)); + for (driver_executables) |path| assert(std.fs.path.isAbsolute(path)); + if (dependencies_count > 1) { + for (releases[0 .. releases.len - 1], releases[1..]) |release_old, release_new| { + assert(release_old.value < release_new.value); + } + } + return .{ + .server_executables = server_executables, + .driver_executables = driver_executables, + .releases = releases, + }; +} + +pub const Supervisor = struct { + allocator: std.mem.Allocator, + prng: stdx.PRNG, + io: *IO, + shell: *Shell, + network: *Network, + workload: ?*Workload = null, + options: Options, + + server_executables: [dependencies_count][]const u8, + driver_executables: [dependencies_count][]const u8, + releases: [dependencies_count]Release, + release_count: u32, + + output_directory: []const u8, + replica_datafiles: []const []const u8, + replicas: []*Replica, + + /// This represents the start timestamp of a period where we have an acceptable number of + /// process faults, such that we require liveness (that requests are finished within a + /// certain time period). If null, it means we're in a period of too many faults, thus + /// enforcing no such requirement. + acceptable_faults_start_ns: ?u64 = null, + + const Options = struct { + seed: u64, + replica_count: u8, + faulty: bool, + log_debug: bool, + }; + + pub fn create(allocator: std.mem.Allocator, options: Options) !*Supervisor { + // Vortex currently only supports Linux. + assert(builtin.os.tag == .linux); + + const shell = try Shell.create(allocator); + errdefer shell.destroy(); + + const dependencies = try configuration(shell); + assert(dependencies.releases.len == dependencies.server_executables.len); + assert(dependencies.releases.len == dependencies.driver_executables.len); + assert(options.replica_count > 0); + + const output_directory = try shell.create_tmp_dir(); + errdefer { + shell.cwd.deleteTree(output_directory) catch |err| { + log.err("error deleting tree: {}", .{err}); + }; + } + + var prng = stdx.PRNG.from_seed(options.seed); + + var io = try allocator.create(IO); + errdefer allocator.destroy(io); + + io.* = try IO.init(128, 0); + errdefer io.deinit(); + + const replica_ports_actual = + constants.vortex.replica_ports_actual[0..options.replica_count]; + var network = try Network.listen(allocator, &prng, io, replica_ports_actual); + errdefer network.destroy(allocator); + + const replica_datafiles = try allocator.alloc([]const u8, options.replica_count); + errdefer allocator.free(replica_datafiles); + + for (replica_datafiles, 0..) |*datafile, replica_index| { + datafile.* = try shell.fmt( + "{s}/{d}_{d}.tigerbeetle", + .{ output_directory, constants.vortex.cluster_id, replica_index }, + ); + } + + const replicas = try allocator.alloc(*Replica, options.replica_count); + errdefer allocator.free(replicas); + + for (replicas, 0..) |*replica, replica_index| { + errdefer for (replicas[0..replica_index]) |r| r.destroy(); + + var replica_ports: [constants.vsr.replicas_max]u16 = undefined; + for (replica_ports[0..options.replica_count], 0..) |*replica_port, i| { + if (replica_index == i) { + replica_port.* = network.proxies[i].remote_address.port; + } else { + replica_port.* = network.proxies[i].origin_address.port; + } + } + + replica.* = try Replica.create( + allocator, + try shell.fmt("{s}/tigerbeetle-R{d:0>2}", .{ output_directory, replica_index }), + options.replica_count, + @intCast(replica_index), + replica_ports, + ); + } + + const supervisor = try allocator.create(Supervisor); + errdefer allocator.destroy(supervisor); + + supervisor.* = .{ + .allocator = allocator, + .prng = prng, + .io = io, + .shell = shell, + .network = network, + .options = options, + .output_directory = output_directory, + .server_executables = dependencies.server_executables, + .driver_executables = dependencies.driver_executables, + .releases = dependencies.releases, + .release_count = @intCast(dependencies.releases.len), + .replicas = replicas, + .replica_datafiles = replica_datafiles, + }; + return supervisor; + } + + pub fn destroy(supervisor: *Supervisor) void { + if (supervisor.workload) |workload| { + workload.destroy(supervisor.allocator); + } + + for (supervisor.replicas, 0..) |replica, replica_index| { + // We might have terminated the replica and never restarted it, + // so we need to check its state. + if (replica.state != .terminated) { + supervisor.replica_terminate(@intCast(replica_index)) catch {}; + } + replica.destroy(); + } + supervisor.allocator.free(supervisor.replicas); + supervisor.allocator.free(supervisor.replica_datafiles); + supervisor.network.destroy(supervisor.allocator); + + supervisor.io.deinit(); + supervisor.allocator.destroy(supervisor.io); + + supervisor.shell.cwd.deleteTree(supervisor.output_directory) catch |err| { + log.err("error deleting tree: {}", .{err}); + }; + supervisor.shell.destroy(); + supervisor.allocator.destroy(supervisor); + } + + pub fn tick(supervisor: *Supervisor) !void { + supervisor.network.tick(); + try supervisor.io.run_for_ns(constants.vsr.tick_ms * std.time.ns_per_ms); + try supervisor.tick_check_liveness(); + + if (supervisor.options.faulty) try supervisor.tick_faults(); + + // Check for replicas that have exited. + for (supervisor.replicas, 0..) |replica, replica_index| { + if (replica.state != .terminated) { + if (replica.wait_nonblocking()) |term| { + // Replicas shouldn't exit on their own, even with code=0. + maybe(std.meta.eql(term, .{ .Exited = 0 })); + + log.err( + "{}: replica terminated unexpectedly with {}", + .{ replica_index, term }, + ); + if (std.meta.eql(term, .{ .Signal = std.posix.SIG.KILL })) { + // If one of the replica dies to SIGKILL, it is likely an OOM. + // Bubble that up to CFO so that this Vortex run is counted as neither a + // success or failure. + std.posix.exit(@intCast(128 + term.Signal)); + } else { + fatal(.replica_exit_result, "replica exited with: {}", .{term}); + } + } + } + } + + if (supervisor.workload) |workload| { + // Driver subprocess should never exit on its own. + const result = std.posix.waitpid(workload.driver.id, std.posix.W.NOHANG); + if (result.pid != 0) { + assert(result.pid == workload.driver.id); + + const term = stdx.term_from_status(result.status); + fatal(.workload_exit_early, "workload exited with: {}", .{term}); + } + } + } + + fn tick_check_liveness(supervisor: *Supervisor) !void { + const workload = supervisor.workload orelse return; + if (supervisor.acceptable_faults_start_ns) |start_ns| { + const now: u64 = @intCast(std.time.nanoTimestamp()); + const deadline = start_ns + constants.vortex.liveness_requirement_seconds * + std.time.ns_per_s; + // If we've been in a state with an acceptable number of faults for the required + // amount of time, we should have seen finished requests. + const no_finished_requests = + now > deadline and workload.requests_finished.empty(); + // Also, those that do finish should not have too long durations, counting from the + // start of the acceptably-faulty period. + const too_slow_request = workload.find_slow_request_since(start_ns); + + if (no_finished_requests) { + fatal(.liveness, "liveness check: no finished requests after {d} seconds", .{ + constants.vortex.liveness_requirement_seconds, + }); + } + + if (too_slow_request) |_| { + fatal(.request_slow, "liveness check: too slow request", .{}); + } + } + + const faulty_replica_count = count: { + var count: u32 = 0; + for (supervisor.replicas) |replica| { + count += @intFromBool(replica.state != .running); + } + break :count count; + }; + + // How many replicas can be faulty while still expecting the cluster to + // make progress (based on 2f+1). + const liveness_faulty_replicas_max = @divFloor(supervisor.replicas.len - 1, 2); + // Check if `acceptable_faults_start_ns` should change state. If so, we reset the max + // request duration too. + // NOTE: Network faults are currently global, so we relax the requirement in such cases. + if (faulty_replica_count <= liveness_faulty_replicas_max and + supervisor.network.faults.is_healed()) + { + // We have an acceptable number of faults, so we require liveness (after some time). + if (supervisor.acceptable_faults_start_ns == null) { + supervisor.acceptable_faults_start_ns = @intCast(std.time.nanoTimestamp()); + workload.requests_finished.clear(); + } + } else { + // We have too many faults to require liveness. + if (supervisor.acceptable_faults_start_ns) |_| { + supervisor.acceptable_faults_start_ns = null; + workload.requests_finished.clear(); + } + } + } + + fn tick_faults(supervisor: *Supervisor) !void { + assert(supervisor.options.faulty); + + const prng = &supervisor.prng; + var replicas_running_buffer: [constants.vsr.replicas_max]u8 = undefined; + var replicas_terminated_buffer: [constants.vsr.replicas_max]u8 = undefined; + var replicas_paused_buffer: [constants.vsr.replicas_max]u8 = undefined; + + const replicas_running = + replicas_in_state(supervisor.replicas, &replicas_running_buffer, .running); + const replicas_terminated = + replicas_in_state(supervisor.replicas, &replicas_terminated_buffer, .terminated); + const replicas_paused = + replicas_in_state(supervisor.replicas, &replicas_paused_buffer, .paused); + + const Action = enum { + none, + replica_terminate, + replica_restart, + replica_pause, + replica_resume, + replica_upgrade, + cluster_upgrade, + network_delay, + network_corrupt, + network_heal, + heal, + }; + + const cluster_release_ = supervisor.cluster_release(); + // Since "none" dominates the others, the fault values can be thought of as + // "expected number of occurrences per 2 minutes". + const minute_ticks = 60 * (std.time.ms_per_s / constants.vsr.tick_ms); + switch (supervisor.prng.enum_weighted(Action, .{ + .none = 2 * minute_ticks, + .replica_terminate = if (replicas_running.len > 0) 2 else 0, + .replica_restart = if (replicas_terminated.len > 0) 4 else 0, + .replica_pause = if (replicas_running.len > 0) 2 else 0, + .replica_resume = if (replicas_paused.len > 0) 10 else 0, + .replica_upgrade = if (supervisor.cluster_upgrading()) |_| 15 else 0, + .cluster_upgrade = if (cluster_release_ + 1 < supervisor.release_count) 2 else 0, + .network_delay = if (supervisor.network.faults.delay == null) 2 else 0, + .network_corrupt = if (supervisor.network.faults.corrupt == null) 2 else 0, + .network_heal = if (supervisor.network.faults.is_healed()) 0 else 10, + .heal = 10, + })) { + .none => {}, + .replica_terminate => { + try supervisor.replica_terminate(replicas_running[prng.index(replicas_running)]); + }, + .replica_restart => { + try supervisor.replica_start(replicas_terminated[prng.index(replicas_terminated)]); + }, + .replica_pause => { + try supervisor.replica_pause(replicas_running[prng.index(replicas_running)]); + }, + .replica_resume => { + try supervisor.replica_unpause(replicas_paused[prng.index(replicas_paused)]); + }, + .replica_upgrade => { + try supervisor.replica_install(supervisor.cluster_upgrading().?, cluster_release_); + }, + .cluster_upgrade => { + assert(cluster_release_ + 1 < supervisor.release_count); + const release_max = supervisor.release_count - 1; + const release_target = prng.range_inclusive(u32, cluster_release_ + 1, release_max); + const replica_index: u8 = @intCast(prng.index(supervisor.replicas)); + try supervisor.replica_install(replica_index, release_target); + }, + .network_delay => { + const time_ms = prng.range_inclusive(u32, 10, 500); + supervisor.network.faults.delay = .{ + .time_ms = time_ms, + .jitter_ms = @min(time_ms, 50), + }; + log.info("injecting network delays: {any}", .{supervisor.network.faults}); + }, + .network_corrupt => { + supervisor.network.faults.corrupt = ratio(prng.range_inclusive(u8, 1, 10), 100); + log.info("injecting network corruption: {any}", .{supervisor.network.faults}); + }, + .network_heal => { + log.info("healing network faults", .{}); + supervisor.network.faults.heal(); + }, + .heal => { + log.info("healing all faults", .{}); + supervisor.network.faults.heal(); + for (replicas_paused) |index| try supervisor.replica_unpause(index); + for (replicas_terminated) |index| try supervisor.replica_start(index); + }, + } + } + + fn cluster_release(supervisor: *const Supervisor) u32 { + var release_max: u32 = 0; + for (supervisor.replicas) |replica| { + release_max = @max(release_max, replica.executable_index); + } + return release_max; + } + + fn cluster_upgrading(supervisor: *Supervisor) ?u8 { + const cluster_release_ = supervisor.cluster_release(); + const index_base = supervisor.prng.index(supervisor.replicas); + for (0..supervisor.replicas.len) |index_offset| { + const replica_index = (index_base + index_offset) % supervisor.replicas.len; + const replica = supervisor.replicas[replica_index]; + if (replica.executable_index < cluster_release_) { + return @intCast(replica_index); + } + } + return null; + } + + pub fn replica_format(supervisor: *Supervisor, replica_index: u8) !void { + assert(supervisor.replicas[replica_index].state == .terminated); + + const release_index = supervisor.replicas[replica_index].executable_index; + const server_executable = supervisor.server_executables[release_index]; + supervisor.shell.exec( + \\{tigerbeetle_executable} format + \\ --cluster={cluster} + \\ --replica={replica_index} + \\ --replica-count={replica_count} + \\ {datafile} + , .{ + .tigerbeetle_executable = server_executable, + .cluster = constants.vortex.cluster_id, + .replica_index = replica_index, + .replica_count = supervisor.replicas.len, + .datafile = supervisor.replica_datafiles[replica_index], + }) catch |err| { + log.err("{}: failed formatting datafile: {}", .{ replica_index, err }); + return err; + }; + } + + pub fn replica_reformat(supervisor: *Supervisor, replica_index: u8) !void { + assert(supervisor.replicas[replica_index].state == .terminated); + + log.info("{}: reformatting replica", .{replica_index}); + + supervisor.shell.cwd.deleteFile(supervisor.replica_datafiles[replica_index]) catch |err| { + log.err("{}: failed deleting datafile: {}", .{ replica_index, err }); + return err; + }; + + const release_index = supervisor.replicas[replica_index].executable_index; + const server_executable = supervisor.server_executables[release_index]; + const child = supervisor.shell.spawn(.{ .stderr_behavior = .Inherit }, + \\{tigerbeetle} recover + \\ --cluster={cluster_id} + \\ --replica={replica} + \\ --replica-count={replica_count} + \\ --addresses={addresses} + \\ {datafile} + , .{ + .tigerbeetle = server_executable, + .cluster_id = constants.vortex.cluster_id, + .replica = replica_index, + .replica_count = supervisor.replicas.len, + .addresses = supervisor.replicas[replica_index].addresses, + .datafile = supervisor.replica_datafiles[replica_index], + }) catch |err| { + log.err("{}: failed reformatting datafile: {}", .{ replica_index, err }); + return err; + }; + + // Tick supervisor since reformatting requires network progress. + // (The tick limit is an arbitrary safety counter.) + const ticks_max = 1500; + for (0..ticks_max) |_| { + const result = std.posix.waitpid(child.id, std.posix.W.NOHANG); + if (result.pid == 0) { + try supervisor.tick(); + } else { + assert(result.pid == child.id); + + const status = stdx.term_from_status(result.status); + if (std.meta.eql(status, .{ .Exited = 0 })) { + break; + } else { + log.err("{}: reformat failed: {}", .{ replica_index, status }); + return error.ReformatFailed; + } + } + } else { + log.err("{}: reformat did not complete within {} ticks", .{ replica_index, ticks_max }); + return error.ReformatFailed; + } + } + + pub fn replica_start(supervisor: *Supervisor, replica_index: u8) !void { + log.info("{}: starting replica", .{replica_index}); + + const replica = supervisor.replicas[replica_index]; + assert(replica.state == .terminated); + defer assert(replica.state == .running); + + var addresses_buffer: [128]u8 = undefined; + const addresses_arg = try std.fmt.bufPrint( + addresses_buffer[0..], + "--addresses={s}", + .{supervisor.replicas[replica_index].addresses}, + ); + + var argv: stdx.BoundedArrayType([]const u8, 16) = .{}; + argv.push_slice(&.{ replica.executable_target, "start" }); + if (supervisor.options.log_debug) { + argv.push_slice(&.{ "--log-debug", "--experimental" }); + } + argv.push_slice(&.{ addresses_arg, supervisor.replica_datafiles[replica_index] }); + + assert(replica.process == null); + replica.state = .running; + replica.process = std.process.Child.init(argv.const_slice(), supervisor.allocator); + replica.process.?.stdin_behavior = .Ignore; + replica.process.?.stdout_behavior = .Ignore; + replica.process.?.stderr_behavior = .Inherit; + + try replica.process.?.spawn(); + errdefer _ = replica.process.?.kill() catch {}; + } + + pub fn replica_terminate(supervisor: *Supervisor, replica_index: u8) !void { + log.info("{}: terminating replica", .{replica_index}); + + const replica = supervisor.replicas[replica_index]; + assert(replica.state == .running or replica.state == .paused); + + try std.posix.kill(replica.process.?.id, std.posix.SIG.KILL); + + const term = try replica.process.?.wait(); + assert(std.meta.eql(term, .{ .Signal = std.posix.SIG.KILL })); + + replica.process = null; + replica.state = .terminated; + } + + pub fn replica_pause(supervisor: *Supervisor, replica_index: u8) !void { + comptime assert(builtin.os.tag != .windows); + log.info("{}: pausing replica", .{replica_index}); + + const replica = supervisor.replicas[replica_index]; + assert(replica.state == .running); + + try std.posix.kill(replica.process.?.id, std.posix.SIG.STOP); + replica.state = .paused; + } + + pub fn replica_unpause(supervisor: *Supervisor, replica_index: u8) !void { + comptime assert(builtin.os.tag != .windows); + log.info("{}: unpausing replica", .{replica_index}); + + const replica = supervisor.replicas[replica_index]; + assert(replica.state == .paused); + + try std.posix.kill(replica.process.?.id, std.posix.SIG.CONT); + replica.state = .running; + } + + pub fn replica_install(supervisor: *Supervisor, replica_index: u8, release_index: u32) !void { + assert(release_index < supervisor.release_count); + + log.info( + "{}: installing replica release: {} ... {}", + .{ + replica_index, + supervisor.releases[supervisor.replicas[replica_index].executable_index], + supervisor.releases[release_index], + }, + ); + supervisor.replicas[replica_index].executable_index = release_index; + + const upgrade_requires_restart = builtin.os.tag != .linux and + supervisor.replicas[replica_index].state != .terminated; + if (upgrade_requires_restart) { + try supervisor.replica_terminate(replica_index); + } + + try std.fs.copyFileAbsolute( + supervisor.server_executables[release_index], + supervisor.replicas[replica_index].executable_target, + .{}, + ); + + if (upgrade_requires_restart) { + try supervisor.replica_start(replica_index); + } + } + + pub fn workload_start( + supervisor: *Supervisor, + driver: union(enum) { + command: []const u8, + release: u32, + }, + options: struct { transfer_count: u32 }, + ) !void { + assert(supervisor.workload == null); + + var proxy_ports_all: [constants.vsr.replicas_max]u16 = undefined; + for (proxy_ports_all[0..supervisor.options.replica_count], 0..) |*port, i| { + port.* = supervisor.network.proxies[i].origin_address.port; + } + const proxy_ports = proxy_ports_all[0..supervisor.options.replica_count]; + + // TODO Take client_release_min into account for driver. + const workload_driver = switch (driver) { + .command => |command| command, + .release => |release_index| supervisor.driver_executables[release_index], + }; + const workload_driver_release = supervisor.releases[ + switch (driver) { + .command => |_| supervisor.driver_executables.len - 1, + .release => |release_index| release_index, + } + ]; + log.info( + "launching workload with driver: {s} (release={})", + .{ workload_driver, workload_driver_release }, + ); + + const workload = try Workload.create( + supervisor.allocator, + supervisor.io, + proxy_ports, + workload_driver, + workload_driver_release, + .{ .seed = supervisor.prng.int(u64) }, + ); + errdefer workload.destroy(supervisor.allocator); + + try workload.start(.{ .transfer_count = options.transfer_count }); + supervisor.workload = workload; + } + + pub fn workload_done(supervisor: *Supervisor) bool { + return supervisor.workload.?.done(); + } + + pub fn workload_terminate(supervisor: *Supervisor) void { + supervisor.workload.?.destroy(supervisor.allocator); + supervisor.workload = null; + } +}; + +fn replicas_in_state( + replicas: []const *Replica, + replica_index_buffer: []u8, + state: ReplicaState, +) []u8 { + var count: u8 = 0; + for (replicas, 0..) |replica, index| { + if (replica.state == state) { + replica_index_buffer[count] = @intCast(index); + count += 1; + } + } + return replica_index_buffer[0..count]; +} + +fn comma_separate_ports(allocator: std.mem.Allocator, ports: []const u16) ![]const u8 { + assert(ports.len > 0); + + var out = std.ArrayList(u8).init(allocator); + errdefer out.deinit(); + + const writer = out.writer(); + try writer.print("{d}", .{ports[0]}); + for (ports[1..]) |port| try writer.print(",{d}", .{port}); + + return out.toOwnedSlice(); +} + +test comma_separate_ports { + const formatted = try comma_separate_ports(std.testing.allocator, &.{ 3000, 3001, 3002 }); + defer std.testing.allocator.free(formatted); + + try std.testing.expectEqualStrings("3000,3001,3002", formatted); +} + +const ReplicaState = enum { running, paused, terminated }; + +const Replica = struct { + allocator: std.mem.Allocator, + executable_index: u32, + /// The path of this replica's executable. + /// Executables from `server_executables` are copied to this location. + executable_target: []const u8, + replica_count: u8, + replica_index: u8, + replica_ports: [constants.vsr.replicas_max]u16, + addresses: []const u8, + process: ?std.process.Child, + state: ReplicaState, + + pub fn create( + allocator: std.mem.Allocator, + executable_target: []const u8, + replica_count: u8, + replica_index: u8, + replica_ports: [constants.vsr.replicas_max]u16, + ) !*Replica { + assert(replica_index < replica_count); + assert(std.fs.path.isAbsolute(executable_target)); + + const addresses = try comma_separate_ports(allocator, replica_ports[0..replica_count]); + errdefer allocator.free(addresses); + + const self = try allocator.create(Replica); + errdefer allocator.destroy(self); + + self.* = .{ + .allocator = allocator, + .executable_target = executable_target, + .executable_index = 0, + .replica_count = replica_count, + .replica_index = replica_index, + .replica_ports = replica_ports, + .addresses = addresses, + .process = null, + .state = .terminated, + }; + return self; + } + + pub fn destroy(self: *Replica) void { + assert(self.state == .terminated); + const allocator = self.allocator; + allocator.free(self.addresses); + allocator.destroy(self); + } + + /// If the process has exited, reap it and return the exit code. + /// Otherwise, return null. + pub fn wait_nonblocking(self: *Replica) ?std.process.Child.Term { + assert(self.state == .running or self.state == .paused); + + const result = std.posix.waitpid(self.process.?.id, std.posix.W.NOHANG); + if (result.pid == 0) return null; + assert(result.pid == self.process.?.id); + + self.state = .terminated; + return stdx.term_from_status(result.status); + } +}; + +const Workload = struct { + const Model = @import("./workload.zig").Model; + const Generator = @import("./workload.zig").Generator; + const Command = @import("./workload.zig").Command; + + const RequestInfo = struct { + timestamp_start_micros: u64, + timestamp_end_micros: u64, + }; + + const RequestsFinished = RingBufferType(RequestInfo, .slice); + + io: *IO, + model: Model, + generator: Generator, + driver: std.process.Child, + + status: union(enum) { + idle, + busy: struct { transfers_max: u32 }, + } = .idle, + + command: ?Command = null, + request_buffer: []u8, + request_size: ?u32 = null, + request_written: ?u32 = null, + request_start: ?stdx.InstantUnix = null, + reply_buffer: []u8, + + completion: IO.Completion = undefined, + read_progress: usize = 0, + + requests_finished: RequestsFinished, + requests_finished_count: std.enums.EnumMap(Command, u32) = .initFull(0), + + pub fn create( + allocator: std.mem.Allocator, + io: *IO, + proxy_ports: []const u16, + driver_command: []const u8, + driver_release: Release, + options: struct { seed: u64 }, + ) !*Workload { + assert(std.mem.indexOfScalar(u8, driver_command, '"') == null); + + const arg_cluster = std.fmt.comptimePrint("{d}", .{constants.vortex.cluster_id}); + const arg_addresses = try comma_separate_ports(allocator, proxy_ports); + defer allocator.free(arg_addresses); + + var driver_argv = std.ArrayList([]const u8).init(allocator); + defer driver_argv.deinit(); + + var driver_command_parts = std.mem.splitScalar(u8, driver_command, ' '); + while (driver_command_parts.next()) |part| try driver_argv.append(part); + try driver_argv.append(arg_cluster); + try driver_argv.append(arg_addresses); + + var driver = std.process.Child.init(driver_argv.items, allocator); + driver.stdin_behavior = .Pipe; + driver.stdout_behavior = .Pipe; + driver.stderr_behavior = .Inherit; + try driver.spawn(); + errdefer _ = driver.kill() catch {}; + + var model = try Model.init(allocator); + errdefer model.deinit(allocator); + + const buffer_size = @sizeOf(u8) + @sizeOf(u32) + Generator.buffer_size; + const request_buffer = try allocator.alloc(u8, buffer_size); + errdefer allocator.free(request_buffer); + + const reply_buffer = try allocator.alloc(u8, buffer_size); + errdefer allocator.free(reply_buffer); + + var requests_finished = try RequestsFinished.init(allocator, 1024 * 16); + errdefer requests_finished.deinit(allocator); + + const workload = try allocator.create(Workload); + errdefer allocator.destroy(workload); + + workload.* = .{ + .io = io, + .model = model, + .generator = Generator.init(options.seed, driver_release), + .driver = driver, + .request_buffer = request_buffer, + .reply_buffer = reply_buffer, + .requests_finished = requests_finished, + }; + return workload; + } + + pub fn destroy(workload: *Workload, allocator: std.mem.Allocator) void { + const workload_result = workload.driver.kill() catch |err| { + fatal(.workload_exit_result, "workload: error killing driver: {any}", .{err}); + }; + if (!std.meta.eql(workload_result, .{ .Signal = std.posix.SIG.TERM }) and + !std.meta.eql(workload_result, .{ .Exited = 128 + std.posix.SIG.TERM })) + { + fatal(.workload_exit_result, "workload: unexpected term: {any}", .{workload_result}); + } + + workload.requests_finished.deinit(allocator); + allocator.free(workload.reply_buffer); + allocator.free(workload.request_buffer); + workload.model.deinit(allocator); + allocator.destroy(workload); + } + + pub fn done(workload: *Workload) bool { + return workload.status == .idle; + } + + pub fn start(workload: *Workload, options: struct { transfer_count: u32 }) !void { + assert(workload.status == .idle); + + workload.status = .{ .busy = .{ .transfers_max = options.transfer_count } }; + workload.driver_request(); + } + + fn driver_request(workload: *Workload) void { + assert(workload.status == .busy); + assert(workload.command == null); + assert(workload.request_written == null); + assert(workload.request_size == null); + assert(workload.request_start == null); + + const command = workload.generator.random_command(&workload.model); + const operation = command.operation(); + var stream = std.io.fixedBufferStream(workload.request_buffer); + stream.writer().writeInt(u8, @intFromEnum(operation), .little) catch unreachable; + + const request_body_size = workload.generator.random_request( + &workload.model, + command, + workload.request_buffer[stream.pos + @sizeOf(u32) ..], + ); + const request_body_events_count: u32 = + @intCast(@divExact(request_body_size, operation.event_size())); + stream.writer().writeInt(u32, request_body_events_count, .little) catch unreachable; + + log.debug( + "workload: request start: command={s} body={}", + .{ @tagName(command), request_body_size }, + ); + + workload.command = command; + workload.request_written = 0; + workload.request_size = @intCast(stream.pos + request_body_size); + workload.request_start = stdx.InstantUnix.now(); + workload.driver_request_write(); + } + + fn driver_request_write(workload: *Workload) void { + assert(workload.status == .busy); + assert(workload.command != null); + assert(workload.request_written.? < workload.request_size.?); + + workload.io.write( + *Workload, + workload, + driver_request_write_callback, + &workload.completion, + workload.driver.stdin.?.handle, + workload.request_buffer[workload.request_written.?..workload.request_size.?], + 0, + ); + } + + fn driver_request_write_callback( + workload: *Workload, + completion: *IO.Completion, + result: IO.WriteError!usize, + ) void { + assert(workload.status == .busy); + assert(&workload.completion == completion); + assert(workload.command != null); + assert(workload.read_progress == 0); + assert(workload.request_written.? < workload.request_size.?); + + const bytes_written = result catch |err| { + fatal(.driver_request_error, "error sending to driver: {}", .{err}); + }; + workload.request_written.? += @intCast(bytes_written); + + assert(workload.request_written.? <= workload.request_size.?); + if (workload.request_written.? == workload.request_size.?) { + workload.request_written = null; + workload.driver_response_read(); + } else { + workload.driver_request_write(); + } + } + + fn driver_response_read(workload: *Workload) void { + assert(workload.status == .busy); + assert(workload.command != null); + + workload.io.read( + *Workload, + workload, + driver_response_read_callback, + &workload.completion, + workload.driver.stdout.?.handle, + workload.reply_buffer[workload.read_progress..], + 0, + ); + } + + fn driver_response_read_callback( + workload: *Workload, + completion: *IO.Completion, + result: IO.ReadError!usize, + ) void { + assert(workload.status == .busy); + assert(workload.command != null); + assert(&workload.completion == completion); + + const read_size = result catch |err| { + fatal(.driver_response_error, "error receiving from driver: {}", .{err}); + }; + workload.read_progress += read_size; + + const read_buffer = workload.reply_buffer[0..workload.read_progress]; + var read_stream = std.io.fixedBufferStream(read_buffer); + const reader = read_stream.reader(); + + if (workload.read_progress < @sizeOf(u32)) return workload.driver_response_read(); + const results_count = reader.readInt(u32, .little) catch unreachable; + const results_size = results_count * workload.command.?.operation().result_size(); + if (workload.read_progress < read_stream.pos + results_size) { + return workload.driver_response_read(); + } + + const results_buffer = workload.reply_buffer[read_stream.pos..][0..results_size]; + workload.model.reconcile( + workload.command.?, + workload.request_buffer[(@sizeOf(u8) + @sizeOf(u32))..workload.request_size.?], + results_buffer, + ) catch |err| { + fatal(.workload_reconcile, "model reconcile error: {}", .{err}); + }; + + const request_commence_us = workload.request_start.?.ns / std.time.ns_per_us; + const request_complete_us = stdx.InstantUnix.now().ns / std.time.ns_per_us; + workload.requests_finished.push(.{ + .timestamp_start_micros = request_commence_us, + .timestamp_end_micros = request_complete_us, + }) catch log.warn("requests_finished is full", .{}); + + workload.requests_finished_count.put( + workload.command.?, + workload.requests_finished_count.getAssertContains(workload.command.?) + 1, + ); + + log.info( + "workload: request done: command={s} duration={}us " ++ + "(accounts_created={d} transfers_created={d})", + .{ + @tagName(workload.command.?), + request_complete_us -| request_commence_us, + workload.model.accounts.count(), + workload.model.transfers_created, + }, + ); + + workload.command = null; + workload.request_size = null; + workload.request_start = null; + workload.read_progress = 0; + if (workload.model.transfers_created < workload.status.busy.transfers_max) { + workload.driver_request(); + } else { + workload.status = .idle; + } + } + + fn find_slow_request_since(workload: *const Workload, start_ns: u64) ?RequestInfo { + var it = workload.requests_finished.iterator(); + while (it.next()) |request| { + assert(request.timestamp_start_micros < request.timestamp_end_micros); + // If a request started before the acceptably-faulty period, + // we ignore that part of its duration. + const duration_adjusted_micros = request.timestamp_end_micros -| + @max(request.timestamp_start_micros, @divFloor(start_ns, 1000)); + if (duration_adjusted_micros > constants.vortex.liveness_requirement_micros) { + return request; + } + } + return null; + } +}; + +const FatalReason = enum(u8) { + workload_exit_early = 10, + workload_exit_result = 11, + workload_read_error = 12, + workload_reconcile = 13, + replica_exit_result = 14, + driver_request_error = 15, + driver_response_error = 16, + liveness = 17, + request_slow = 18, + + pub fn exit_status(reason: FatalReason) u8 { + return @intFromEnum(reason); + } +}; + +fn fatal(reason: FatalReason, comptime fmt: []const u8, args: anytype) noreturn { + log.err(fmt, args); + const status = reason.exit_status(); + assert(status != 0); + std.process.exit(status); +} diff --git a/ocam/src/testing/vortex/workload.zig b/ocam/src/testing/vortex/workload.zig new file mode 100644 index 00000000..e5625f41 --- /dev/null +++ b/ocam/src/testing/vortex/workload.zig @@ -0,0 +1,281 @@ +//! This workload generates requests, and reconciles replies with a model, tracking account +//! balances. +//! +//! After every operation, all accounts are queried, and basic invariants are checked. +//! +//! The workload and drivers communicate with a binary protocol over stdio. The protocol is based +//! on the extern structs in `src/tigerbeetle.zig` and `src/state_machine.zig`, and it works like +//! this: +//! +//! 1. Workload sends a request, which is: +//! * the _operation_ (1 byte), +//! * the _event count_ (4 bytes), and +//! * the events (event count * size of event). +//! 2. The driver uses its client to submit those events. When receiving results, it sends them +//! back on its stdout as: +//! * the _operation_ (1 byte) +//! * the _result count_ (4 bytes), and +//! * the results (result count * size of result pair), where each pair holds an index and a +//! result enum value (see `src/tigerbeetle.zig`) +//! 3. The workload receives the results, and expects them to be of the same operation type as +//! originally requested. + +const std = @import("std"); +const stdx = @import("stdx"); +const tb = @import("../../tigerbeetle.zig"); +const Operation = tb.Operation; +const ratio = stdx.PRNG.ratio; +const Release = @import("../../multiversion.zig").Release; + +const log = std.log.scoped(.workload); +const assert = std.debug.assert; +const testing = std.testing; + +const events_count_max = 8189; +const accounts_count_max = 128; + +pub const Command = enum { + create_accounts, + create_accounts_sparse, + create_transfers, + create_transfers_sparse, + lookup_accounts, + + pub fn operation(command: Command) Operation { + return switch (command) { + .create_accounts => .create_accounts, + .create_transfers => .create_transfers, + .create_accounts_sparse => .deprecated_create_accounts_sparse, + .create_transfers_sparse => .deprecated_create_transfers_sparse, + .lookup_accounts => .lookup_accounts, + }; + } +}; + +/// Tracks information about the accounts and transfers created by the workload. +pub const Model = struct { + accounts: std.AutoArrayHashMapUnmanaged(u128, tb.Account), + transfers_created: u64 = 0, + + pub fn init(allocator: std.mem.Allocator) !Model { + var accounts = std.AutoArrayHashMapUnmanaged(u128, tb.Account).empty; + errdefer accounts.deinit(allocator); + + try accounts.ensureTotalCapacity(allocator, accounts_count_max); + return .{ .accounts = accounts }; + } + + pub fn deinit(model: *Model, allocator: std.mem.Allocator) void { + model.accounts.deinit(allocator); + } + + pub fn reconcile( + model: *Model, + command: Command, + request: []const u8, + result: []const u8, + ) !void { + return switch (command) { + .create_accounts => model.reconcile_create_accounts(request, result), + .create_accounts_sparse => model.reconcile_create_accounts_sparse(request, result), + .create_transfers => model.reconcile_create_transfers(request, result), + .create_transfers_sparse => model.reconcile_create_transfers_sparse(request, result), + .lookup_accounts => model.reconcile_lookup_accounts(request, result), + }; + } + + fn reconcile_create_accounts(model: *Model, request: []const u8, result: []const u8) !void { + const accounts = std.mem.bytesAsSlice(tb.Account, request); + const account_results = std.mem.bytesAsSlice(tb.CreateAccountResult, result); + assert(account_results.len == accounts.len); + + for (accounts, account_results, 0..) |account, account_result, index| { + if (account_result.status == .created) { + model.accounts.putAssumeCapacityNoClobber(account.id, account); + } else { + log.err("got status {s} for event {d}: {any}", .{ + @tagName(account_result.status), + index, + account, + }); + return error.TestFailed; + } + } + } + + fn reconcile_create_accounts_sparse( + model: *Model, + request: []const u8, + result: []const u8, + ) !void { + const accounts = std.mem.bytesAsSlice(tb.Account, request); + const account_results = std.mem.bytesAsSlice(tb.CreateAccountErrorResult, result); + assert(account_results.len == 0); + + for (accounts) |account| { + model.accounts.putAssumeCapacityNoClobber(account.id, account); + } + } + + fn reconcile_create_transfers(model: *Model, request: []const u8, result: []const u8) !void { + const transfers = std.mem.bytesAsSlice(tb.Transfer, request); + const transfer_results = std.mem.bytesAsSlice(tb.CreateTransferResult, result); + assert(transfer_results.len == transfers.len); + + for (transfers, transfer_results) |transfer, transfer_result| { + // No further validation needed for failed transfers. + if (transfer_result.status != .created) { + continue; + } + + const debit_account = model.accounts.getPtr(transfer.debit_account_id).?; + const credit_account = model.accounts.getPtr(transfer.credit_account_id).?; + debit_account.debits_posted += transfer.amount; + credit_account.credits_posted += transfer.amount; + } + + model.transfers_created += transfers.len; + } + + fn reconcile_create_transfers_sparse( + model: *Model, + request: []const u8, + result: []const u8, + ) !void { + const transfers = std.mem.bytesAsSlice(tb.Transfer, request); + const transfer_results = std.mem.bytesAsSlice(tb.CreateTransferErrorResult, result); + assert(transfer_results.len == 0); + + for (transfers) |transfer| { + const debit_account = model.accounts.getPtr(transfer.debit_account_id).?; + const credit_account = model.accounts.getPtr(transfer.credit_account_id).?; + debit_account.debits_posted += transfer.amount; + credit_account.credits_posted += transfer.amount; + } + + model.transfers_created += transfers.len; + } + + fn reconcile_lookup_accounts(model: *Model, request: []const u8, result: []const u8) !void { + const account_ids = std.mem.bytesAsSlice(u128, request); + const accounts_found = std.mem.bytesAsSlice(tb.Account, result); + assert(accounts_found.len == account_ids.len); + + for (account_ids, accounts_found) |account_id, account| { + const account_expect = model.accounts.getPtr(account_id).?; + try testing.expectEqual(account.id, account_id); + try testing.expectEqual(account.debits_pending, account_expect.debits_pending); + try testing.expectEqual(account.debits_posted, account_expect.debits_posted); + try testing.expectEqual(account.credits_pending, account_expect.credits_pending); + try testing.expectEqual(account.credits_posted, account_expect.credits_posted); + } + } +}; + +pub const Generator = struct { + prng: stdx.PRNG, + release: Release, + + pub const buffer_size = events_count_max * @max(@sizeOf(tb.Account), @sizeOf(tb.Account)); + + pub fn init(seed: u64, release: Release) Generator { + return .{ + .prng = stdx.PRNG.from_seed(seed), + .release = release, + }; + } + + pub fn random_command(generator: *Generator, model: *const Model) Command { + // Mostly send create_transfers, to fill up the LSM. + const dense_create_release_min = Release.from(.{ .major = 0, .minor = 17, .patch = 0 }); + if (dense_create_release_min.value <= generator.release.value) { + return generator.prng.enum_weighted(Command, .{ + .create_accounts = if (model.accounts.count() < accounts_count_max) 1 else 0, + .create_accounts_sparse = 0, + .create_transfers = if (model.accounts.count() > 2) 20 else 0, + .create_transfers_sparse = 0, + .lookup_accounts = if (model.accounts.count() > 0) 1 else 0, + }); + } else { + return generator.prng.enum_weighted(Command, .{ + .create_accounts = 0, + .create_accounts_sparse = if (model.accounts.count() < accounts_count_max) 1 else 0, + .create_transfers = 0, + .create_transfers_sparse = if (model.accounts.count() > 2) 20 else 0, + .lookup_accounts = if (model.accounts.count() > 0) 1 else 0, + }); + } + } + + pub fn random_request( + generator: *Generator, + model: *const Model, + command: Command, + buffer: []u8, + ) u32 { + assert(buffer.len == buffer_size); + + return switch (command) { + .create_accounts => generator.random_create_accounts(model, buffer), + .create_accounts_sparse => generator.random_create_accounts(model, buffer), + .create_transfers => generator.random_create_transfers(model, buffer), + .create_transfers_sparse => generator.random_create_transfers(model, buffer), + .lookup_accounts => generator.random_lookup_accounts(model, buffer), + }; + } + + fn random_create_accounts(generator: *Generator, model: *const Model, buffer: []u8) u32 { + const events_count = + generator.prng.range_inclusive(usize, 1, accounts_count_max - model.accounts.count()); + assert(events_count <= events_count_max); + + const events_buffer = buffer[0..(events_count * @sizeOf(tb.Account))]; + const events = std.mem.bytesAsSlice(tb.Account, events_buffer); + for (events) |*event| { + event.* = std.mem.zeroInit(tb.Account, .{ + .id = generator.prng.range_inclusive(u128, 1, std.math.maxInt(u128)), + .ledger = 1, + .code = generator.prng.range_inclusive(u16, 1, 100), + .flags = .{ .history = generator.prng.chance(ratio(1, 10)) }, + }); + } + return @intCast(events_buffer.len); + } + + fn random_create_transfers(generator: *Generator, model: *const Model, buffer: []u8) u32 { + const events_count = generator.prng.range_inclusive(usize, 1, events_count_max); + assert(events_count <= events_count_max); + + const events_buffer = buffer[0..(events_count * @sizeOf(tb.Transfer))]; + const events = std.mem.bytesAsSlice(tb.Transfer, events_buffer); + for (events) |*event| { + const debit_account_id = + model.accounts.values()[generator.prng.index(model.accounts.values())].id; + var credit_account_id: u128 = 0; + while (credit_account_id == 0 or credit_account_id == debit_account_id) { + credit_account_id = + model.accounts.values()[generator.prng.index(model.accounts.values())].id; + } + + event.* = std.mem.zeroInit(tb.Transfer, .{ + .id = generator.prng.int(u128) +| 1, + .ledger = 1, + .debit_account_id = debit_account_id, + .credit_account_id = credit_account_id, + .amount = generator.prng.int_inclusive(u128, 1 << 32), + .code = generator.prng.range_inclusive(u16, 1, 100), + }); + } + return @intCast(events_buffer.len); + } + + fn random_lookup_accounts(generator: *Generator, model: *const Model, buffer: []u8) u32 { + const events_count = @min(events_count_max, model.accounts.count()); + const events_buffer = buffer[0..(events_count * @sizeOf(u128))]; + const events = std.mem.bytesAsSlice(u128, events_buffer); + for (events) |*event| { + event.* = model.accounts.values()[generator.prng.index(model.accounts.values())].id; + } + return @intCast(events_buffer.len); + } +}; diff --git a/ocam/src/testing/vortex/zig_driver.zig b/ocam/src/testing/vortex/zig_driver.zig new file mode 100644 index 00000000..08cb82ad --- /dev/null +++ b/ocam/src/testing/vortex/zig_driver.zig @@ -0,0 +1,179 @@ +const std = @import("std"); +const stdx = @import("stdx"); +const vsr = @import("vsr"); +const constants = vsr.constants; +const Operation = vsr.tigerbeetle.Operation; + +// We could have used the idiomatic Zig API exposed by `vsr.tb_client`, +// but we want to test the actual FFI exposed by `libtb_client`. +const c = @cImport({ + @cInclude("tb_client.h"); +}); + +const assert = std.debug.assert; + +const log = std.log.scoped(.zig_driver); +const events_count_max = 8189; +const events_buffer_size_max = size: { + var event_size_max = 0; + for (std.enums.values(Operation)) |operation| { + event_size_max = @max(event_size_max, operation.event_size()); + } + break :size event_size_max * events_count_max; +}; + +pub const CLIArgs = struct { + @"--": void, + cluster: u128, + addresses: []const u8, +}; + +pub fn main() !void { + var gpa_allocator = std.heap.GeneralPurposeAllocator(.{}){}; + defer switch (gpa_allocator.deinit()) { + .ok => {}, + .leak => @panic("memory leak"), + }; + + const allocator = gpa_allocator.allocator(); + var flags = stdx.Flags.init(allocator); + defer flags.deinit(allocator); + + const args = flags.parse(CLIArgs); + log.info("addresses: {s}", .{args.addresses}); + + var tb_client: c.tb_client_t = undefined; + const init_status = c.tb_client_init( + &tb_client, + std.mem.asBytes(&args.cluster), + args.addresses.ptr, + @intCast(args.addresses.len), + 0, + on_complete, + ); + if (init_status != c.TB_INIT_SUCCESS) { + return error.ClientInitError; + } + defer { + const client_status = c.tb_client_deinit(&tb_client); + assert(client_status == c.TB_CLIENT_OK); + } + + const stdin = std.io.getStdIn().reader().any(); + const stdout = std.io.getStdOut().writer().any(); + + while (true) { + var events_buffer: [events_buffer_size_max]u8 = undefined; + const operation, const events = receive(stdin, events_buffer[0..]) catch |err| { + switch (err) { + error.EndOfStream => break, + else => return err, + } + }; + + var context = RequestContext{}; + + { + context.lock.lock(); + defer context.lock.unlock(); + + var packet: c.tb_packet_t = undefined; + packet.operation = @intFromEnum(operation); + packet.user_data = @constCast(@ptrCast(&context)); + packet.data = @constCast(events.ptr); + packet.data_size = @intCast(events.len); + packet.user_tag = 0; + packet.status = c.TB_PACKET_OK; + + const client_status = c.tb_client_submit(&tb_client, &packet); + assert(client_status == c.TB_CLIENT_OK); + + while (!context.completed) { + context.condition.wait(&context.lock); + } + } + + write_results(stdout, operation, context.result[0..context.result_size]) catch |err| { + switch (err) { + error.BrokenPipe => { + log.info("stdout is closed, exiting", .{}); + break; + }, + else => return err, + } + }; + } +} + +const RequestContext = struct { + lock: std.Thread.Mutex = .{}, + condition: std.Thread.Condition = .{}, + completed: bool = false, + result: [constants.message_body_size_max]u8 = undefined, + result_size: u32 = 0, +}; + +pub fn on_complete( + tb_context: usize, + tb_packet: [*c]c.tb_packet_t, + timestamp: u64, + result: ?[*]const u8, + result_size: u32, +) callconv(.c) void { + _ = tb_context; + _ = timestamp; + const context: *RequestContext = @ptrCast(@alignCast(tb_packet.*.user_data.?)); + + context.lock.lock(); + defer context.lock.unlock(); + + assert(tb_packet.*.status == c.TB_PACKET_OK); + assert(result != null); + + stdx.copy_disjoint(.exact, u8, context.result[0..result_size], result.?[0..result_size]); + context.result_size = result_size; + context.completed = true; + context.condition.signal(); +} + +fn write_results( + writer: std.io.AnyWriter, + operation: Operation, + result: []const u8, +) !void { + switch (operation) { + inline else => |operation_comptime| { + const result_size = operation_comptime.result_size(); + if (result_size > 0) { + const count = @divExact(result.len, result_size); + try writer.writeInt(u32, @intCast(count), .little); + try writer.writeAll(result); + } else { + log.err( + "unexpected size {d} for op: {s}", + .{ result_size, @tagName(operation_comptime) }, + ); + unreachable; + } + }, + } +} + +fn receive(reader: std.io.AnyReader, buffer: []u8) !struct { Operation, []const u8 } { + const operation = try reader.readEnum(Operation, .little); + const count = try reader.readInt(u32, .little); + + return switch (operation) { + inline else => |operation_comptime| { + assert(count <= events_count_max); + + const response_size = operation_comptime.event_size() * count; + assert(buffer.len >= response_size); + + const read_total_size = try reader.readAtLeast(buffer, response_size); + assert(read_total_size == response_size); + + return .{ operation_comptime, buffer[0..response_size] }; + }, + }; +} diff --git a/ocam/src/tidy.zig b/ocam/src/tidy.zig new file mode 100644 index 00000000..ef751282 --- /dev/null +++ b/ocam/src/tidy.zig @@ -0,0 +1,1463 @@ +//! Checks for various non-functional properties of the code itself. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const fs = std.fs; +const mem = std.mem; +const Ast = std.zig.Ast; + +const stdx = @import("stdx"); +const Shell = stdx.Shell; + +const Snap = stdx.Snap; +const module_path = "src"; +const snap = Snap.snap_fn(module_path); + +const MiB = stdx.MiB; + +test "tidy" { + const gpa = std.testing.allocator; + + var errors: Errors = .{}; + + const shell = try Shell.create(gpa); + defer shell.destroy(); + + var counter: IdentifierCounter = try .init(gpa); + defer counter.deinit(gpa); + + var dead_files_detector = DeadFilesDetector.init(gpa); + defer dead_files_detector.deinit(gpa); + + // NB: all checks are intentionally implemented in a streaming fashion, + // such that we only need to read the files once. + const file_buffer = try gpa.alloc(u8, 1 * MiB); + defer gpa.free(file_buffer); + + const paths = try list_file_paths(shell); + for (paths) |file_path| { + const source_file = try SourceFile.read(file_path, file_buffer); + try tidy_file(gpa, &counter, source_file, &errors); + + if (source_file.has_extension(".zig")) { + try dead_files_detector.visit(source_file); + } + } + + dead_files_detector.finish(&errors); + + if (errors.count > 0) return error.Untidy; + assert(errors.count == 0); +} + +const Errors = struct { + count: u32 = 0, + captured: ?std.ArrayListUnmanaged(u8) = null, // For tests. + + pub fn add_control_character( + errors: *Errors, + file: SourceFile, + offset: usize, + character: u8, + ) void { + errors.emit( + "{s}:{d}: error: control character code={}\n", + .{ file.path, file.line_number(offset), character }, + ); + } + + pub fn add_banned( + errors: *Errors, + file: SourceFile, + offset: usize, + banned_item: []const u8, + replacement: []const u8, + ) void { + errors.emit( + "{s}:{d}: error: {s} is banned, use {s}\n", + .{ file.path, file.line_number(offset), banned_item, replacement }, + ); + } + + pub fn add_banned_reminder( + errors: *Errors, + file: SourceFile, + offset: usize, + banned_item: []const u8, + ) void { + errors.emit( + "{s}:{d}: error: leftover {s}, remove before merge\n", + .{ file.path, file.line_number(offset), banned_item }, + ); + } + + pub fn add_long_line(errors: *Errors, file: SourceFile, line_index: usize) void { + const line_number = line_index + 1; + errors.emit( + "{s}:{d}: error: line exceeds 100 columns\n", + .{ file.path, line_number }, + ); + } + + pub fn add_trailing_whitespace(errors: *Errors, file: SourceFile, line_index: usize) void { + errors.emit( + "{s}:{d}: error: trailing whitespace\n", + .{ file.path, line_index + 1 }, + ); + } + + pub fn add_bad_type_function_name( + errors: *Errors, + file: SourceFile, + line_index: usize, + function_name: []const u8, + ) void { + const line_number = line_index + 1; + errors.emit( + "{s}:{d}: error: type function name '{s}' should end in 'Type'\n", + .{ file.path, line_number, function_name }, + ); + } + + pub fn add_long_function(errors: *Errors, file: SourceFile, line_index: usize) void { + const line_number = line_index + 1; + errors.emit( + "{s}:{d}: error: functions exceeds 70 lines\n", + .{ file.path, line_number }, + ); + } + + pub fn add_ambiguous_precedence(errors: *Errors, file: SourceFile, line_index: usize) void { + const line_number = line_index + 1; + errors.emit( + "{s}:{d}: error: ambiguous operator precedence, add parenthesis\n", + .{ file.path, line_number }, + ); + } + + pub fn add_dead_declaration(errors: *Errors, file: SourceFile, declaration: []const u8) void { + errors.emit("{s}: error: '{s}' is dead code\n", .{ file.path, declaration }); + } + + pub fn add_defer_newline(errors: *Errors, file: SourceFile, line_index: usize) void { + const line_number = line_index + 1; + errors.emit( + "{s}:{d}: error: defer must be followed by a blank line\n", + .{ file.path, line_number }, + ); + } + + pub fn add_invalid_markdown_title(errors: *Errors, file: SourceFile) void { + errors.emit( + "{s}: error: document should have exactly one top-level '# Title'\n", + .{file.path}, + ); + } + + pub fn add_file_untracked(errors: *Errors, file: []const u8) void { + errors.emit( + "{s}: error: imported file untracked by git\n", + .{file}, + ); + } + + pub fn add_file_dead(errors: *Errors, file: []const u8) void { + errors.emit( + "{s}: error: file never imported\n", + .{file}, + ); + } + + pub fn add_tracking(errors: *Errors, file: SourceFile, line_index: usize) void { + errors.emit( + "{s}:{d}: error: remove '?si=...' tracking parameter from URL\n", + .{ file.path, line_index }, + ); + } + + fn emit(errors: *Errors, comptime fmt: []const u8, args: anytype) void { + comptime assert(fmt[fmt.len - 1] == '\n'); + errors.count += 1; + if (errors.captured) |*captured| { + captured.writer(std.testing.allocator).print(fmt, args) catch @panic("OOM"); + } else { + std.debug.print(fmt, args); + } + } +}; + +const SourceFile = struct { + path: []const u8, + text: [:0]const u8, + + // NB: The return value borrows both path and buffer. + fn read(path: []const u8, buffer: []u8) !SourceFile { + const bytes_read = (try std.fs.cwd().readFile(path, buffer)).len; + if (bytes_read >= buffer.len - 1) return error.FileTooLong; + buffer[bytes_read] = 0; + return .{ + .path = path, + .text = buffer[0..bytes_read :0], + }; + } + + fn has_extension(file: SourceFile, extension: []const u8) bool { + assert(extension.len > 0); + assert(extension[0] == '.'); + return std.mem.endsWith(u8, file.path, extension); + } + + // O(N), but only invoked on the cold path (when there are errors). + fn line_number(file: SourceFile, offset: usize) usize { + assert(offset <= file.text.len); + // +1: Line _index_ is zero-based, line _number_ is one-based. + return std.mem.count(u8, file.text[0..offset], "\n") + 1; + } +}; + +fn tidy_file( + gpa: Allocator, + counter: *IdentifierCounter, + file: SourceFile, + errors: *Errors, +) Allocator.Error!void { + tidy_control_characters(file, errors); + if (file.has_extension(".zig")) { + tidy_banned(file, errors); + tidy_lines(file, errors); + tidy_type_functions(file, errors); + + var tree = try std.zig.Ast.parse(gpa, file.text, .zig); + defer tree.deinit(gpa); + + tidy_dead_declarations(file, &tree, counter, errors); + tidy_ast(file, &tree, errors); + } + if (file.has_extension(".md")) { + tidy_markdown_title(file, errors); + } +} + +fn check_tidy_file(file_path: []const u8, file_text: [:0]const u8, want: Snap) !void { + const gpa = std.testing.allocator; + + var counter: IdentifierCounter = try .init(gpa); + defer counter.deinit(gpa); + + var errors: Errors = .{ .captured = .{} }; + defer errors.captured.?.deinit(std.testing.allocator); + + try tidy_file(gpa, &counter, .{ .path = file_path, .text = file_text }, &errors); + const got = errors.captured.?.items; + + try want.diff(got); + assert(errors.count == std.mem.count(u8, got, "\n")); +} + +fn tidy_control_characters(file: SourceFile, errors: *Errors) void { + const binary_file_extensions: []const []const u8 = &.{ ".ico", ".png", ".webp" }; + for (binary_file_extensions) |extension| { + if (file.has_extension(extension)) return; + } + + const allowed = .{ + .@"\r" = file.has_extension(".bat"), + + // Visual Studio insists on \t, taking the best from `make`. + // Go uses tabs. + .@"\t" = file.has_extension(".sln") or + (file.has_extension(".go") or + (file.has_extension(".md") and mem.indexOf(u8, file.text, "```go") != null)), + }; + + var remaining = file.text; + while (mem.indexOfAny(u8, remaining, "\r\t")) |index| { + const offset = index + (file.text.len - remaining.len); + inline for (comptime std.meta.fieldNames(@TypeOf(allowed))) |field| { + if (remaining[index] == field[0]) { + if (!@field(allowed, field)) { + errors.add_control_character(file, offset, field[0]); + } + break; + } + } else unreachable; + + remaining = remaining[index + 1 ..]; + } +} + +test tidy_control_characters { + try check_tidy_file( + "hello.txt", + "Hello\t\nWorld\r\n", + snap(@src(), + \\hello.txt:1: error: control character code=9 + \\hello.txt:2: error: control character code=13 + \\ + ), + ); +} + +fn tidy_banned(file: SourceFile, errors: *Errors) void { + // Vendored code is exempt from bans. + if (std.mem.eql(u8, file.path, "src/stdx/vendored/aegis.zig")) return; + // Don't ban ourselves! + if (std.mem.eql(u8, file.path, "src/tidy.zig")) return; + + const ban_list: []const struct { []const u8, []const u8 } = &.{ + // Functionality provided by stdx: + .{ "std.BoundedArray", "stdx.BoundedArrayType" }, + .{ "StaticBitSet", "stdx.BitSetType" }, + .{ "std.time.Duration", "stdx.Duration" }, + .{ "std.time.Instant", "stdx.Instant" }, + .{ "hasUniqueRepresentation", "stdx.has_unique_representation" }, + .{ "@memcpy(", "stdx.copy_disjoint" }, + .{ "mem.copyForwards(", "stdx.copy_left" }, + .{ "mem.copyBackwards(", "stdx.copy_right" }, + .{ "uintLessThan", "stdx.PRNG" }, + .{ "intRangeLessThan", "stdx.PRNG" }, + .{ "intRangeAtMost", "stdx.PRNG" }, + .{ "intRangeAtMostBiased", "stdx.PRNG" }, + .{ "parseInt", "stdx.parse_int" }, + .{ "parseUnsigned", "stdx.parse_int" }, + + // Library footguns: + .{ "unexpectedErrno", "stdx.unexpected_errno" }, + .{ "posix.send(", "posix.sendto to avoid connection race condition" }, + + // Language footguns: + .{ "== error.", "switch to avoid silent anyerror upcast" }, + .{ "!= error.", "switch to avoid silent anyerror upcast" }, + + // Everything else: + .{ "debug.assert(", "unqualified assert" }, + .{ "Self = @This()", "proper type name" }, + .{ "!comptime", "! inside comptime" }, + .{ "usingnamespace", "something else" }, + }; + + for (ban_list) |ban_item| { + const banned, const replacement = ban_item; + if (std.mem.indexOf(u8, file.text, banned)) |offset| { + errors.add_banned(file, offset, banned, replacement); + } + } + + // Reminders: + // Do use FIXME comments proactively while iterating on the code when you want to make sure + // something is revisited before getting into the main branch. + inline for (.{ "FIXME", "dbg(" }) |banned| { + if (std.mem.indexOf(u8, file.text, banned)) |offset| { + if (std.mem.startsWith(u8, file.text[offset..], "dbg(prefix: []const u8")) { + // Allow fn dbg( function definition. + + } else { + errors.add_banned_reminder(file, offset, banned); + } + } + } +} + +test tidy_banned { + try check_tidy_file( + \\banned.zig + , + \\//FIXME: use copy_disjoint: + \\@memcpy(foo, bar) + , + snap(@src(), + \\banned.zig:2: error: @memcpy( is banned, use stdx.copy_disjoint + \\banned.zig:1: error: leftover FIXME, remove before merge + \\ + ), + ); +} + +fn tidy_lines(file: SourceFile, errors: *Errors) void { + if (std.mem.endsWith(u8, file.path, "low_level_hash_vectors.zig")) return; + + var line_iterator = mem.splitScalar(u8, file.text, '\n'); + var line_index: u32 = 0; + while (line_iterator.next()) |line| : (line_index += 1) { + tidy_line(file, line, line_index, errors); + } +} + +fn tidy_line(file: SourceFile, line: []const u8, line_index: usize, errors: *Errors) void { + const line_length = tidy_line_length(line); + if (line_length <= 100) return; + + if (tidy_line_link(line)) return; + + // Journal recovery table + if (std.mem.indexOf(u8, line, "Case.init(") != null) return; + + // For multiline strings, we care that the _result_ fits 100 characters, + // but we don't mind indentation in the source. + if (tidy_line_raw_literal(line)) |string_value| { + const string_value_length = tidy_line_length(string_value); + if (string_value_length <= 100) return; + + if (std.mem.endsWith(u8, file.path, "state_machine_tests.zig") and + (std.mem.startsWith(u8, string_value, " account A") or + std.mem.startsWith(u8, string_value, " transfer T") or + std.mem.startsWith(u8, string_value, " transfer "))) + { + // Table tests from state_machine.zig. They are intentionally wide. + return; + } + + // vsr.zig's Checkpoint ops diagram. + if (std.mem.endsWith(u8, file.path, "vsr.zig") and + std.mem.startsWith(u8, string_value, "OPS: ")) return; + + // trace.zig's JSON snapshot test. + if (std.mem.endsWith(u8, file.path, "trace.zig") and + std.mem.startsWith(u8, string_value, "{\"pid\":1,\"tid\":")) return; + + // AMQP JSON snapshot test. + if (std.mem.endsWith(u8, file.path, "cdc/runner.zig") and + std.mem.startsWith(u8, string_value, "{\"timestamp\":")) return; + + // Message formatting tests. + if (std.mem.endsWith(u8, file.path, "message_header.zig") and + std.mem.startsWith(u8, string_value, "Prepare{")) return; + + // Flag snapshot test. + if (std.mem.endsWith(u8, file.path, "flags.zig") and + std.mem.startsWith(u8, string_value, "error: subcommand required")) return; + + // Inspect constants snapshot test. + if (std.mem.endsWith(u8, file.path, "inspect_snapshot.zig")) return; + + // REPL parser snapshot tests. + if (std.mem.endsWith(u8, file.path, "parser.zig")) return; + } + + errors.add_long_line(file, line_index); +} + +fn tidy_line_length(line: []const u8) usize { + // Count codepoints for simplicity, even if it is wrong. + return std.unicode.utf8CountCodepoints(line) catch @panic("invalid utf-8"); +} + +/// Heuristically checks if a `line` contains an URL. +fn tidy_line_link(line: []const u8) bool { + return std.mem.indexOf(u8, line, "https://") != null; +} + +/// If a line is a `\\` string literal, extract its value. +fn tidy_line_raw_literal(line: []const u8) ?[]const u8 { + const indentation, const value = stdx.cut(line, "\\\\") orelse return null; + for (indentation) |c| if (c != ' ') return null; + return value; +} + +test tidy_lines { + try check_tidy_file( + \\lines.zig + , + "" ++ + "pub const x = 92;\n" ++ + "pub const x = " ++ ("9" ** 199) ++ ";\n" ++ + "pub const url = \"https://example." ++ ("0" ** 199) ++ " \";\n" ++ + " \\\\" ++ ("9" ** 99) ++ "\n" ++ + " \"" ++ ("9" ** 99) ++ "\"\n", + snap(@src(), + \\lines.zig:2: error: line exceeds 100 columns + \\lines.zig:5: error: line exceeds 100 columns + \\ + ), + ); +} + +/// All functions using the `CamelCase` naming convention return a type, +/// so we enforce that the function name also ends with the `Type` suffix. +fn tidy_type_functions(file: SourceFile, errors: *Errors) void { + var line_index: u32 = 0; + var it = std.mem.splitScalar(u8, file.text, '\n'); + while (it.next()) |line| : (line_index += 1) { + // Zig fmt enforces that the pattern `fn Foo(` is not split across multiple lines. + + const prefix, const suffix = stdx.cut(line, "fn ") orelse continue; + // Not all `fn ` tokens are functions, some may be `callback_fn` for example. + // Functions appear at the beginning of a line or after a whitespace. + if (prefix.len > 0 and prefix[prefix.len - 1] != ' ') continue; + const function_name, _ = stdx.cut(suffix, "(") orelse continue; + if (function_name.len == 0) continue; // E.g: `*const fn (*anyopaque) void`. + assert(function_name.len > 0); + + // Skipping naming convention that requires upper-case functions. + if (std.mem.startsWith(u8, function_name, "JNI_")) continue; + // Windows use CamelCase functions. + if (std.mem.indexOf(u8, line, "extern \"kernel32\"") != null) continue; + + if (std.ascii.isUpper(function_name[0])) { + if (!std.mem.endsWith(u8, function_name, "Type")) { + errors.add_bad_type_function_name(file, line_index, function_name); + } + } + } +} + +test tidy_type_functions { + try check_tidy_file( + \\type_functions.zig + , + \\pub fn MyArrayType() type { } + ++ "\npub fn" ++ " MyArray() type { }" ++ "\n" ++ + \\ pub const callback = *const fn (*anyopaque) void; + , + snap(@src(), + \\type_functions.zig:2: error: type function name 'MyArray' should end in 'Type' + \\ + ), + ); +} + +const IdentifierCounter = struct { + const file_identifier_count_max = 100_000; + + map: std.StringHashMapUnmanaged(struct { count: u32, offset: u32 }) = .{}, + + pub fn init(gpa: Allocator) !IdentifierCounter { + var counter: IdentifierCounter = .{}; + try counter.map.ensureTotalCapacity(gpa, file_identifier_count_max + 1); + return counter; + } + + pub fn deinit(counter: *IdentifierCounter, gpa: Allocator) void { + counter.map.deinit(gpa); + counter.* = undefined; + } + + pub fn empty(counter: *const IdentifierCounter) bool { + return counter.map.count() == 0; + } + + pub fn clear(counter: *IdentifierCounter) void { + counter.map.clearRetainingCapacity(); + } + + pub fn record( + counter: *IdentifierCounter, + tree: *const Ast, + token_text: []const u8, + token_offset: u32, + ) void { + const gop = counter.map.getOrPutAssumeCapacity(token_text); + if (counter.map.count() > file_identifier_count_max) @panic("file too large"); + + if (gop.found_existing) { + // Count occurrences on a single line as one, as a special case for imports: + // const foo = std.foo; + const between_tokens_text = tree.source[gop.value_ptr.offset..token_offset]; + const same_line_occurrence = mem.indexOfScalar(u8, between_tokens_text, '\n') == null; + if (same_line_occurrence) return; + } + + if (!gop.found_existing) gop.value_ptr.* = .{ .count = 0, .offset = 0 }; + gop.value_ptr.count += 1; + gop.value_ptr.offset = token_offset; + } + + pub fn get(counter: *const IdentifierCounter, token_text: []const u8) u32 { + return counter.map.get(token_text).?.count; + } +}; + +/// Detects unused constants and functions. +/// +/// This is a one-side heuristic: there might be false negatives, but no false positives. +/// +/// Current algorithm: +/// - Two passes. +/// - Pass 1: count how many times each identifier is mentioned in the file. +/// - Pass 2: warn about any unique identifier which is a non-public declaration. +/// +/// At the moment, this is implemented using only the lexer, without looking at the AST, as that +/// seemed simpler. +fn tidy_dead_declarations( + file: SourceFile, + tree: *const Ast, + counter: *IdentifierCounter, + errors: *Errors, +) void { + assert(counter.empty()); + defer counter.clear(); + + var identifier_start: ?Ast.ByteOffset = 0; + inline for (.{ .fill, .check }) |phase| { + next_token: for ( + tree.tokens.items(.tag), + tree.tokens.items(.start), + 0.., + ) |tag, start, index_usize| { + const index: Ast.TokenIndex = @intCast(index_usize); + const identifier_start_previous = identifier_start; + identifier_start = switch (tag) { + .identifier => start, + else => null, + }; + + const start_previous = identifier_start_previous orelse continue :next_token; + const token_text = std.mem.trim( + u8, + tree.source[start_previous..start], + &std.ascii.whitespace, + ); + + switch (phase) { + .fill => counter.record(tree, token_text, start), + .check => { + const usages = counter.get(token_text); + assert(usages >= 1); + if (usages == 1) { + if (tidy_dead_declarations_is_private_declaration(tree, index - 1)) { + errors.add_dead_declaration(file, token_text); + } + } + }, + else => comptime unreachable, + } + } + } +} + +// Checks if the given identifier token refers to non-public declaration. +fn tidy_dead_declarations_is_private_declaration( + tree: *const Ast, + token_index: Ast.TokenIndex, +) bool { + assert(tree.tokens.items(.tag)[token_index] == .identifier); + var declaration_keyword = false; + for (0..4) |context_offset| { + const context_tag = if (token_index - context_offset < 1) + .eof + else + tree.tokens.get(token_index - context_offset - 1).tag; + + if (!declaration_keyword) { + switch (context_tag) { + .keyword_fn, .keyword_const => declaration_keyword = true, + // Not a declaration. + else => return false, + } + } else { + switch (context_tag) { + .keyword_inline, .keyword_extern, .string_literal => {}, + // Public declaration can be used in a different file. + .keyword_pub, .keyword_export => return false, + // []const u8, or *const u8, or align(...), not a declaration. + .r_bracket, .r_paren, .asterisk => return false, + // Non public declarations, never used. + else => return true, + } + } + } else unreachable; +} + +test tidy_dead_declarations { + try check_tidy_file( + \\dead.zig + , + \\ const std = @import("std"); + \\ const import_unused = std.import_unused; + \\ pub fn public_used() void { private_used(); } + \\ fn private_used() void {} + \\ fn private_unused() void {} + , + snap(@src(), + \\dead.zig: error: 'import_unused' is dead code + \\dead.zig: error: 'private_unused' is dead code + \\ + ), + ); +} + +fn tidy_ast( + file: SourceFile, + tree: *const Ast, + errors: *Errors, +) void { + if (std.mem.eql(u8, file.path, "build.zig")) return; + if (std.mem.endsWith(u8, file.path, "build_multiversion.zig")) return; + if (std.mem.endsWith(u8, file.path, "bindings.zig")) return; + + const tags = tree.nodes.items(.tag); + const datas = tree.nodes.items(.data); + // We can implement this in a streaming fashion, but its more convenient to materialize all + // functions. 1k functions per file should be enough even for TigerBeetle! + var functions: [1024]struct { + line_opening: usize, + line_closing: usize, + } = undefined; + var functions_count: u32 = 0; + + for (tags, datas, 0..) |tag, data, node| { + if (tag == .fn_decl) { // Check function length. + const node_body = data.rhs; + + const token_opening = tree.firstToken(@intCast(node)); + const token_closing = tree.lastToken(@intCast(node_body)); + + const line_opening = tree.tokenLocation(0, token_opening).line; + const line_closing = tree.tokenLocation(0, token_closing).line; + + functions[functions_count] = .{ + .line_opening = line_opening, + .line_closing = line_closing, + }; + functions_count += 1; + } + if (is_bin_op(tag)) { // Forbid mixing bitops and arithmetics without parentheses. + inline for (.{ data.lhs, data.rhs }) |child| { + const tag_child = tags[child]; + if ((is_bin_op_bitwise(tag) and is_bin_op_arithmetic(tag_child)) or + (is_bin_op_arithmetic(tag) and is_bin_op_bitwise(tag_child))) + { + const token_opening = tree.firstToken(@intCast(node)); + const line_opening = tree.tokenLocation(0, token_opening).line; + errors.add_ambiguous_precedence(file, line_opening); + } + } + } + } + + tidy_defer_newlines(file, tree, errors); + + // We ratchet 70-lines-per-function TigerStyle rule from the bottom up. Some functions want + // to be really long, and that is big. The most values is in preventing originally small + // functions to grow over time. + const function_length_red_zone = .{ + .min = 70, // NB: both are exclusive, so red zone is intentionally empty to start! + .max = 73, + }; + + for (functions[0..functions_count], 0..) |f, index| { + // Functions are sorted by the start line. + if (index > 0) assert(functions[index - 1].line_opening < f.line_opening); + + if (index == functions_count - 1 or + functions[index + 1].line_opening > f.line_closing) + { + const function_length = f.line_closing - f.line_opening + 1; + if (function_length_red_zone.min < function_length and + function_length < function_length_red_zone.max) + { + errors.add_long_function(file, f.line_opening); + } + } + } +} + +fn tidy_defer_newlines(file: SourceFile, tree: *const Ast, errors: *Errors) void { + const tags = tree.tokens.items(.tag); + + var index: usize = 0; + while (index < tags.len) : (index += 1) { + const tag = tags[index]; + if (tag != .keyword_defer) continue; + + const semicolon_index = tidy_defer_statement_end(tags, index) orelse continue; + const semicolon_line = tree.tokenLocation(0, @intCast(semicolon_index)).line; + + var next_index = semicolon_index + 1; + while (next_index < tags.len and + tidy_is_comment_token(tags[next_index])) : (next_index += 1) + {} + + if (next_index >= tags.len) break; + if (tags[next_index] == .eof) continue; + + const next_line = tree.tokenLocation(0, @intCast(next_index)).line; + const next_tag = tags[next_index]; + if (next_tag == .r_brace or next_tag == .keyword_errdefer or next_tag == .keyword_defer) { + continue; + } + + if (next_line <= semicolon_line + 1) { + errors.add_defer_newline(file, semicolon_line); + } + } +} + +fn tidy_defer_statement_end(tags: []const std.zig.Token.Tag, defer_index: usize) ?usize { + var depth: i32 = 0; + + var index: usize = defer_index + 1; + while (index < tags.len) : (index += 1) { + switch (tags[index]) { + .l_paren, .l_brace, .l_bracket => depth += 1, + .r_paren, .r_brace, .r_bracket => depth -= 1, + else => {}, + } + + if (depth == 0) { + switch (tags[index]) { + .semicolon => return index, + .r_brace => { + if (index + 1 < tags.len) { + if (tags[index + 1] == .semicolon) return index + 1; + if (tags[index + 1] == .keyword_else) continue; + } + return index; // defer { ... } without trailing semicolon. + }, + else => {}, + } + } else if (depth < 0) { + // Unterminated defer; bail out. + return null; + } + } + + return null; +} + +fn tidy_is_comment_token(tag: std.zig.Token.Tag) bool { + return switch (tag) { + .doc_comment, .container_doc_comment => true, + else => false, + }; +} + +fn is_bin_op(tag: Ast.Node.Tag) bool { + return is_bin_op_bitwise(tag) or is_bin_op_arithmetic(tag); +} + +fn is_bin_op_bitwise(tag: Ast.Node.Tag) bool { + return switch (tag) { + .shl, .shl_sat => true, + .shr => true, + .bit_xor, .bit_or, .bit_and => true, + else => false, + }; +} + +fn is_bin_op_arithmetic(tag: Ast.Node.Tag) bool { + return switch (tag) { + .add, .add_sat, .add_wrap => true, + .sub, .sub_sat, .sub_wrap => true, + .mul, .mul_sat, .mul_wrap => true, + .div, .mod => true, + else => false, + }; +} + +test tidy_ast { + try check_tidy_file( + \\precedence.zig + , + \\ pub const confusing = 1 + foo << 3; + \\ pub const ok = 1 + (foo << 3); + , + snap(@src(), + \\precedence.zig:1: error: ambiguous operator precedence, add parenthesis + \\ + ), + ); +} + +test tidy_defer_newlines { + try check_tidy_file( + \\defer_newline.zig + , + \\pub fn foo() void { + \\ defer bar(); + \\ baz(); + \\} + \\pub fn bar() void {} + \\pub fn baz() void {} + , + snap(@src(), + \\defer_newline.zig:2: error: defer must be followed by a blank line + \\ + ), + ); + + try check_tidy_file( + \\defer_newline_ok.zig + , + \\pub fn foo() void { + \\ defer bar(); + \\ + \\ baz(); + \\} + \\pub fn bar() void {} + \\pub fn baz() void {} + , + snap(@src(), + \\ + ), + ); + + try check_tidy_file( + \\defer_end_of_scope_ok.zig + , + \\pub fn foo() void { + \\ defer bar(); + \\} + \\pub fn bar() void {} + , + snap(@src(), + \\ + ), + ); + + try check_tidy_file( + \\defer_block_ok.zig + , + \\pub fn foo() void { + \\ defer { + \\ _ = bar() catch {}; + \\ } + \\ + \\ baz(); + \\} + \\pub fn bar() anyerror!void { return; } + \\pub fn baz() void {} + , + snap(@src(), + \\ + ), + ); + + try check_tidy_file( + \\defer_block_missing_blank_line.zig + , + \\pub fn foo() void { + \\ defer { + \\ _ = bar() catch {}; + \\ } + \\ baz(); + \\} + \\pub fn bar() anyerror!void { return; } + \\pub fn baz() void {} + , + snap(@src(), + \\defer_block_missing_blank_line.zig:4: error: defer must be followed by a blank line + \\ + ), + ); + + try check_tidy_file( + \\defer_followed_by_errdefer_ok.zig + , + \\pub fn foo() !void { + \\ var tmp: i32 = undefined; + \\ defer tmp_deinit(); + \\ errdefer tmp_log(); + \\ return tmp_use(tmp); + \\} + \\pub fn tmp_deinit() void {} + \\pub fn tmp_log() void {} + \\pub fn tmp_use(_: i32) !void { return; } + , + snap(@src(), + \\ + ), + ); + + try check_tidy_file( + \\defer_group_ok.zig + , + \\pub fn foo() void { + \\ defer stdout(); + \\ defer stderr(); + \\ + \\ baz(); + \\} + \\pub fn stdout() void {} + \\pub fn stderr() void {} + \\pub fn baz() void {} + , + snap(@src(), + \\ + ), + ); + + try check_tidy_file( + \\defer_inline_if_blank_line_ok.zig + , + \\pub fn foo() void { + \\ defer if (bar()) { + \\ baz(); + \\ }; + \\ + \\ baz(); + \\} + \\pub fn bar() bool { return true; } + \\pub fn baz() void {} + , + snap(@src(), + \\ + ), + ); + + try check_tidy_file( + \\defer_inline_if_else_no_blank_line_ok.zig + , + \\pub fn foo() void { + \\ defer if (bar()) { + \\ baz(); + \\ } else { + \\ qux(); + \\ }; + \\ // else must be part of the defer statement; no blank line required here. + \\ zap(); + \\} + \\pub fn bar() bool { return true; } + \\pub fn baz() void {} + \\pub fn qux() void {} + \\pub fn zap() void {} + , + snap(@src(), + \\ + ), + ); + + try check_tidy_file( + \\defer_inline_block_with_comment_ok.zig + , + \\pub fn foo() void { + \\ defer if (bar()) { + \\ baz(); + \\ }; + \\ + \\ // next statements + \\ qux(); + \\} + \\pub fn bar() bool { return true; } + \\pub fn baz() void {} + \\pub fn qux() void {} + , + snap(@src(), + \\ + ), + ); + + try check_tidy_file( + \\defer_switch_ok.zig + , + \\pub fn foo() void { + \\ defer switch (bar()) { + \\ .ok => {}, + \\ .leak => {}, + \\ }; + \\ + \\ baz(); + \\} + \\pub fn bar() enum { ok, leak } { return .ok; } + \\pub fn baz() void {} + , + snap(@src(), + \\ + ), + ); + + try check_tidy_file( + \\defer_inline_for_ok.zig + , + \\pub fn foo() void { + \\ defer inline for (.{1, 2}) |s| { + \\ _ = s; + \\ }; + \\ + \\ baz(); + \\} + \\pub fn baz() void {} + , + snap(@src(), + \\ + ), + ); + + try check_tidy_file( + \\defer_with_catch_ok.zig + , + \\pub fn foo() void { + \\ defer bar() catch {}; + \\ + \\ baz(); + \\} + \\pub fn bar() anyerror!void { return; } + \\pub fn baz() void {} + , + snap(@src(), + \\ + ), + ); +} + +/// Checks that each markdown document has exactly one h1. +/// +/// There are two schools of thought regarding largest (`# War and Peace`) +/// headings in markdown. One school says that they are _section_ titles, so +/// you could have multiple #'s in the document. But another option is to +/// say that a single # signifies document _title_, and there should be only +/// one in a document. +/// +/// We use markdown to create HTML, so # turns into h1. MDN recommends that +/// there's only a single h1 in a page: +/// +/// +/// +/// For this reason, we follow the second convention. +fn tidy_markdown_title(file: SourceFile, errors: *Errors) void { + var fenced_block = false; // Avoid interpreting `# ` shell comments as titles. + var heading_count: u32 = 0; + var line_count: u32 = 0; + var it = std.mem.splitScalar(u8, file.text, '\n'); + while (it.next()) |line| { + line_count += 1; + if (mem.startsWith(u8, line, "```")) fenced_block = !fenced_block; + if (!fenced_block and mem.startsWith(u8, line, "# ")) heading_count += 1; + } + assert(!fenced_block); + switch (heading_count) { + // No need for a title for a short note. + 0 => if (line_count > 2) errors.add_invalid_markdown_title(file), + 1 => {}, + else => errors.add_invalid_markdown_title(file), + } +} + +test tidy_markdown_title { + try check_tidy_file( + \\ok.md + , + \\# TigerStyle + \\ + \\Style applies everywhere! + \\For example, we check that markdowns contains exactly one top-level title: + \\ + \\``` + \\# Good Document + \\``` + , + snap(@src(), + \\ + ), + ); + try check_tidy_file( + \\bad.md + , + \\# Top Level Header + \\ + \\Lorem Ipsum + \\ + \\# And Another Top Level Header + , + snap(@src(), + \\bad.md: error: document should have exactly one top-level '# Title' + \\ + ), + ); +} + +// Zig's lazy compilation model makes it too easy to forget to include a file into the build --- if +// nothing imports a file, compiler just doesn't see it and can't flag it as unused. +// +// DeadFilesDetector implements heuristic detection of unused files, by "grepping" for import +// statements and flagging file which are never imported. This gives false negatives for unreachable +// cycles of files, as well as for identically-named files, but it should be good enough in +// practice. +const DeadFilesDetector = struct { + const FileName = [64]u8; + const FileState = struct { import_count: u32, definition_count: u32 }; + const FileMap = std.AutoArrayHashMap(FileName, FileState); + + files: FileMap, + + fn init(gpa: Allocator) DeadFilesDetector { + return .{ .files = FileMap.init(gpa) }; + } + + fn deinit(detector: *DeadFilesDetector, _: Allocator) void { + detector.files.deinit(); + } + + fn visit(detector: *DeadFilesDetector, file: SourceFile) Allocator.Error!void { + assert(file.has_extension(".zig")); + (try detector.file_state(file.path)).definition_count += 1; + + var rest: []const u8 = file.text; + for (0..1024) |_| { + _, rest = stdx.cut(rest, "@import(\"") orelse break; + const import_path, rest = stdx.cut(rest, "\")").?; + if (std.mem.endsWith(u8, import_path, ".zig")) { + (try detector.file_state(import_path)).import_count += 1; + } + } else { + std.debug.panic("file with more than 1024 imports: {s}", .{file.path}); + } + } + + fn finish(detector: *DeadFilesDetector, errors: *Errors) void { + defer detector.files.clearRetainingCapacity(); + + for (detector.files.keys(), detector.files.values()) |name, state| { + if (state.definition_count == 0) { + errors.add_file_untracked(&name); + } + if (state.import_count == 0 and !is_entry_point(name)) { + errors.add_file_dead(&name); + } + } + } + + fn file_state(detector: *DeadFilesDetector, path: []const u8) !*FileState { + const gop = try detector.files.getOrPut(path_to_name(path)); + if (!gop.found_existing) gop.value_ptr.* = .{ .import_count = 0, .definition_count = 0 }; + return gop.value_ptr; + } + + fn path_to_name(path: []const u8) FileName { + assert(std.mem.endsWith(u8, path, ".zig")); + const basename = std.fs.path.basename(path); + var file_name: FileName = @splat(0); + assert(basename.len <= file_name.len); + stdx.copy_disjoint(.inexact, u8, &file_name, basename); + return file_name; + } + + fn is_entry_point(file: FileName) bool { + const entry_points: []const []const u8 = &.{ + "build_multiversion.zig", + "build.zig", + "dotnet_bindings.zig", + "fetch.zig", + "file_checker.zig", + "fuzz_tests.zig", + "go_bindings.zig", + "integration_tests.zig", + "java_bindings.zig", + "jni_tests.zig", + "libtb_client.zig", + "main.zig", + "node_bindings.zig", + "node.zig", + "npm_install.zig", + "page_writer.zig", + "python_bindings.zig", + "ruby_bindings.zig", + "rust_bindings.zig", + "scripts.zig", + "search_index_writer.zig", + "service_worker_writer.zig", + "single_page_writer.zig", + "tb_client_header.zig", + "unit_tests.zig", + "vopr.zig", + "vortex.zig", + "zig_driver.zig", + }; + for (entry_points) |entry_point| { + if (std.mem.startsWith(u8, &file, entry_point)) return true; + } + return false; + } +}; + +test "tidy changelog" { + const gpa = std.testing.allocator; + + var errors: Errors = .{}; + + const changelog_buffer = try gpa.alloc(u8, 1 * MiB); + defer gpa.free(changelog_buffer); + + const changelog = try SourceFile.read("CHANGELOG.md", changelog_buffer); + + var line_iterator = mem.splitScalar(u8, changelog.text, '\n'); + var line_index: usize = 0; + while (line_iterator.next()) |line| : (line_index += 1) { + if (std.mem.endsWith(u8, line, " ")) { + errors.add_trailing_whitespace(changelog, line_index); + } + const line_length = tidy_line_length(line); + if (line_length > 100 and !tidy_line_link(line)) { + errors.add_long_line(changelog, line_index); + } + + if (std.mem.indexOf(u8, line, "?si=") != null) { + errors.add_tracking(changelog, line_index); + } + } + if (errors.count > 0) return error.Untidy; + assert(errors.count == 0); +} + +test "tidy no large blobs" { + const allocator = std.testing.allocator; + const shell = try Shell.create(allocator); + defer shell.destroy(); + + // Run `git rev-list | git cat-file` to find large blobs. This is better than looking at the + // files in the working tree, because it catches the cases where a large file is "removed" by + // reverting the commit. + // + // Zig's std doesn't provide a cross platform abstraction for piping two commands together, so + // we begrudgingly pass the data through this intermediary process. + const shallow = try shell.exec_stdout("git rev-parse --is-shallow-repository", .{}); + if (!std.mem.eql(u8, shallow, "false")) { + return error.ShallowRepository; + } + + const rev_list = try shell.exec_stdout("git rev-list --objects HEAD", .{}); + const objects = try shell.exec_stdout_options( + .{ .stdin_slice = rev_list }, + "git cat-file --batch-check={format}", + .{ .format = "%(objecttype) %(objectsize) %(rest)" }, + ); + + var has_large_blobs = false; + var lines = std.mem.splitScalar(u8, objects, '\n'); + while (lines.next()) |line| { + // Parsing lines like + // blob 1032 client/package.json + const blob = stdx.cut_prefix(line, "blob ") orelse continue; + + const size_string, const path = stdx.cut(blob, " ").?; + const size = try stdx.parse_int(u64, size_string, .{}); + + if (std.mem.eql(u8, path, "src/vsr/replica.zig")) continue; // :-) + if (std.mem.eql(u8, path, "src/state_machine.zig")) continue; // :-| + if (std.mem.eql(u8, path, "src/docs_website/package-lock.json")) continue; // :-( + if (size > @divExact(MiB, 4)) { + has_large_blobs = true; + std.debug.print("{s}\n", .{line}); + } + } + if (has_large_blobs) return error.HasLargeBlobs; +} + +test "tidy unix permissions" { + const executable_files = [_][]const u8{ + "zig/download.ps1", + "zig/download.sh", + ".github/ci/test_aof.sh", + "src/scripts/cfo_supervisor.sh", + }; + + const allocator = std.testing.allocator; + const shell = try Shell.create(allocator); + defer shell.destroy(); + + const files = try shell.exec_stdout("git ls-files -z --format {format}", .{ + .format = "%(objectmode) %(path)", + }); + assert(files[files.len - 1] == 0); + var lines = std.mem.splitScalar(u8, files[0 .. files.len - 1], 0); + while (lines.next()) |line| { + const mode, const path = stdx.cut(line, " ").?; + errdefer std.debug.print("{s}: error: unexpected mode={s}\n", .{ path, mode }); + + if (std.mem.eql(u8, mode, "100644")) { + // Expected for most files. + } else if (std.mem.eql(u8, mode, "100755")) { + const expected = for (executable_files) |executable_file| { + if (std.mem.eql(u8, path, executable_file)) break true; + } else false; + + if (!expected) return error.UnexpectedExecutable; + } else { + return error.UnexpectedMode; + } + } +} + +// Sanity check for "unexpected" files in the repository. +test "tidy extensions" { + const allowed_extensions = std.StaticStringMap(void).initComptime(.{ + .{".c"}, .{".cs"}, .{".csproj"}, .{".css"}, .{".go"}, + .{".h"}, .{".hcl"}, .{".html"}, .{".java"}, .{".js"}, + .{".json"}, .{".md"}, .{".mod"}, .{".props"}, .{".py"}, + .{".rs"}, .{".service"}, .{".sln"}, .{".sum"}, .{".svg"}, + .{".toml"}, .{".ts"}, .{".txt"}, .{".xml"}, .{".yml"}, + .{".zig"}, .{".zon"}, .{".rb"}, + }); + + const exceptions = std.StaticStringMap(void).initComptime(.{ + .{".editorconfig"}, + .{".gitignore"}, + .{".nojekyll"}, + .{"CNAME"}, + .{"exclude-pmd.properties"}, + .{"favicon.png"}, + .{"notfound-light.webp"}, + .{"notfound-dark.webp"}, + .{"preview.webp"}, + .{"LICENSE"}, + .{"module-info.test"}, + .{"anchor-links.lua"}, + .{"markdown-links.lua"}, + .{"table-wrapper.lua"}, + .{"code-block-buttons.lua"}, + .{"edit-link-footer.lua"}, + .{"src/docs_website/.vale.ini"}, + .{"zig/download.sh"}, + .{"zig/download.ps1"}, + .{"zig/download.win.ps1"}, + .{"src/scripts/cfo_supervisor.sh"}, + .{".github/ci/test_aof.sh"}, + .{"src/clients/python/pyproject.toml"}, + .{"src/clients/python/src/tigerbeetle/py.typed"}, + .{".clang-format"}, + .{"Rakefile"}, + .{"src/clients/ruby/tigerbeetle.gemspec"}, + .{"src/clients/ruby/sig/tigerbeetle.rbs"}, + }); + + const allocator = std.testing.allocator; + const shell = try Shell.create(allocator); + defer shell.destroy(); + + const paths = try list_file_paths(shell); + + for (exceptions.keys()) |exception| { + for (paths) |path| { + const basename = std.fs.path.basename(path); + if (std.mem.eql(u8, exception, basename) or std.mem.eql(u8, exception, path)) { + break; + } + } else { + std.debug.panic("exception (or basename) doesn't exist: {s} ({s})", .{ + exception, + std.fs.path.basename(exception), + }); + } + } + + var bad_extension = false; + for (paths) |path| { + if (path.len == 0) continue; + const extension = std.fs.path.extension(path); + if (!allowed_extensions.has(extension)) { + const basename = std.fs.path.basename(path); + if (!exceptions.has(basename) and !exceptions.has(path)) { + std.debug.print("bad extension: {s}\n", .{path}); + bad_extension = true; + } + } + } + if (bad_extension) return error.BadExtension; +} + +/// Lists all files in the repository. +fn list_file_paths(shell: *Shell) ![]const []const u8 { + var result = std.ArrayList([]const u8).init(shell.arena.allocator()); + + const files = try shell.exec_stdout("git ls-files -z", .{}); + assert(files.len > 0); + assert(files[files.len - 1] == 0); + var lines = std.mem.splitScalar(u8, files[0 .. files.len - 1], 0); + while (lines.next()) |line| { + assert(line.len > 0); + try result.append(line); + } + + return result.items; +} diff --git a/ocam/src/tigerbeetle.zig b/ocam/src/tigerbeetle.zig new file mode 100644 index 00000000..70638e6c --- /dev/null +++ b/ocam/src/tigerbeetle.zig @@ -0,0 +1,1022 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; + +const vsr = @import("vsr.zig"); +const constants = vsr.constants; +const stdx = vsr.stdx; +const maybe = stdx.maybe; + +pub const Account = extern struct { + id: u128, + debits_pending: u128, + debits_posted: u128, + credits_pending: u128, + credits_posted: u128, + /// Opaque third-party identifiers to link this account (many-to-one) to external entities. + user_data_128: u128, + user_data_64: u64, + user_data_32: u32, + /// Reserved for accounting policy primitives. + reserved: u32, + ledger: u32, + /// A chart of accounts code describing the type of account (e.g. clearing, settlement). + code: u16, + flags: AccountFlags, + timestamp: u64, + + comptime { + assert(stdx.no_padding(Account)); + assert(@sizeOf(Account) == 128); + assert(@alignOf(Account) == 16); + } + + pub fn debits_exceed_credits(self: *const Account, amount: u128) bool { + return (self.flags.debits_must_not_exceed_credits and + self.debits_pending + self.debits_posted + amount > self.credits_posted); + } + + pub fn credits_exceed_debits(self: *const Account, amount: u128) bool { + return (self.flags.credits_must_not_exceed_debits and + self.credits_pending + self.credits_posted + amount > self.debits_posted); + } +}; + +pub const AccountFlags = packed struct(u16) { + /// When the .linked flag is specified, it links an event with the next event in the batch, to + /// create a chain of events, of arbitrary length, which all succeed or fail together. The tail + /// of a chain is denoted by the first event without this flag. The last event in a batch may + /// therefore never have the .linked flag set as this would leave a chain open-ended. Multiple + /// chains or individual events may coexist within a batch to succeed or fail independently. + /// Events within a chain are executed within order, or are rolled back on error, so that the + /// effect of each event in the chain is visible to the next, and so that the chain is either + /// visible or invisible as a unit to subsequent events after the chain. The event that was the + /// first to break the chain will have a unique error result. Other events in the chain will + /// have their error result set to .linked_event_failed. + linked: bool = false, + debits_must_not_exceed_credits: bool = false, + credits_must_not_exceed_debits: bool = false, + history: bool = false, + imported: bool = false, + closed: bool = false, + padding: u10 = 0, + + comptime { + assert(@sizeOf(AccountFlags) == @sizeOf(u16)); + assert(@bitSizeOf(AccountFlags) == @sizeOf(AccountFlags) * 8); + } +}; + +pub const AccountBalance = extern struct { + debits_pending: u128, + debits_posted: u128, + credits_pending: u128, + credits_posted: u128, + timestamp: u64, + reserved: [56]u8 = @splat(0), + + comptime { + assert(stdx.no_padding(AccountBalance)); + assert(@sizeOf(AccountBalance) == 128); + assert(@alignOf(AccountBalance) == 16); + } +}; + +pub const Transfer = extern struct { + id: u128, + debit_account_id: u128, + credit_account_id: u128, + amount: u128, + /// If this transfer will post or void a pending transfer, the id of that pending transfer. + pending_id: u128, + /// Opaque third-party identifiers to link this transfer (many-to-one) to an external entities. + user_data_128: u128, + user_data_64: u64, + user_data_32: u32, + /// Timeout in seconds for pending transfers to expire automatically + /// if not manually posted or voided. + timeout: u32, + ledger: u32, + /// A chart of accounts code describing the reason for the transfer (e.g. deposit, settlement). + code: u16, + flags: TransferFlags, + timestamp: u64, + + // Converts the timeout from seconds to ns. + pub fn timeout_ns(self: *const Transfer) u64 { + // Casting to u64 to avoid integer overflow: + return @as(u64, self.timeout) * std.time.ns_per_s; + } + + comptime { + assert(stdx.no_padding(Transfer)); + assert(@sizeOf(Transfer) == 128); + assert(@alignOf(Transfer) == 16); + } +}; + +pub const TransferPendingStatus = enum(u8) { + none = 0, + pending = 1, + posted = 2, + voided = 3, + expired = 4, + + comptime { + for (std.enums.values(TransferPendingStatus), 0..) |result, index| { + assert(@intFromEnum(result) == index); + } + } +}; + +pub const TransferFlags = packed struct(u16) { + linked: bool = false, + pending: bool = false, + post_pending_transfer: bool = false, + void_pending_transfer: bool = false, + balancing_debit: bool = false, + balancing_credit: bool = false, + closing_debit: bool = false, + closing_credit: bool = false, + imported: bool = false, + padding: u7 = 0, + + comptime { + assert(@sizeOf(TransferFlags) == @sizeOf(u16)); + assert(@bitSizeOf(TransferFlags) == @sizeOf(TransferFlags) * 8); + } +}; + +/// Status codes are ordered by descending precedence. +/// When errors do not have an obvious/natural precedence (e.g. "*_must_be_zero"), +/// the ordering matches struct field order. +pub const CreateAccountStatus = enum(u32) { + deprecated_ok = 0, + created = std.math.maxInt(u32), + + linked_event_failed = 1, + linked_event_chain_open = 2, + + imported_event_expected = 22, + imported_event_not_expected = 23, + + timestamp_must_be_zero = 3, + + imported_event_timestamp_out_of_range = 24, + imported_event_timestamp_must_not_advance = 25, + + reserved_field = 4, + reserved_flag = 5, + + id_must_not_be_zero = 6, + id_must_not_be_int_max = 7, + + exists_with_different_flags = 15, + exists_with_different_user_data_128 = 16, + exists_with_different_user_data_64 = 17, + exists_with_different_user_data_32 = 18, + exists_with_different_ledger = 19, + exists_with_different_code = 20, + exists = 21, + + flags_are_mutually_exclusive = 8, + + debits_pending_must_be_zero = 9, + debits_posted_must_be_zero = 10, + credits_pending_must_be_zero = 11, + credits_posted_must_be_zero = 12, + ledger_must_not_be_zero = 13, + code_must_not_be_zero = 14, + + imported_event_timestamp_must_not_regress = 26, + + comptime { + const values = std.enums.values(CreateAccountStatus); + const BitSet = stdx.BitSetType(values.len - 1); + var set: BitSet = .{}; + for (0..values.len - 1) |index| { + const result: CreateAccountStatus = @enumFromInt(index); + stdx.maybe(result == values[index]); + + assert(!set.is_set(index)); + set.set(index); + } + + // It's a non-ordered enum, we need to ensure + // there are no gaps in the numbering of the values. + assert(set.full()); + + // Except by the "created" result, which is represented as `maxInt`. + const max: CreateAccountStatus = @enumFromInt( + std.math.maxInt(std.meta.Tag(CreateAccountStatus)), + ); + assert(max == .created); + } +}; + +/// Status codes are ordered by descending precedence. +/// When errors do not have an obvious/natural precedence (e.g. "*_must_not_be_zero"), +/// the ordering matches struct field order. +pub const CreateTransferStatus = enum(u32) { + deprecated_ok = 0, + created = std.math.maxInt(u32), + + linked_event_failed = 1, + linked_event_chain_open = 2, + + imported_event_expected = 56, + imported_event_not_expected = 57, + + timestamp_must_be_zero = 3, + + imported_event_timestamp_out_of_range = 58, + imported_event_timestamp_must_not_advance = 59, + + reserved_flag = 4, + + id_must_not_be_zero = 5, + id_must_not_be_int_max = 6, + + exists_with_different_flags = 36, + exists_with_different_pending_id = 40, + exists_with_different_timeout = 44, + exists_with_different_debit_account_id = 37, + exists_with_different_credit_account_id = 38, + exists_with_different_amount = 39, + exists_with_different_user_data_128 = 41, + exists_with_different_user_data_64 = 42, + exists_with_different_user_data_32 = 43, + exists_with_different_ledger = 67, + exists_with_different_code = 45, + exists = 46, + + id_already_failed = 68, + + flags_are_mutually_exclusive = 7, + + debit_account_id_must_not_be_zero = 8, + debit_account_id_must_not_be_int_max = 9, + credit_account_id_must_not_be_zero = 10, + credit_account_id_must_not_be_int_max = 11, + accounts_must_be_different = 12, + + pending_id_must_be_zero = 13, + pending_id_must_not_be_zero = 14, + pending_id_must_not_be_int_max = 15, + pending_id_must_be_different = 16, + timeout_reserved_for_pending_transfer = 17, + + closing_transfer_must_be_pending = 64, + + ledger_must_not_be_zero = 19, + code_must_not_be_zero = 20, + + debit_account_not_found = 21, + credit_account_not_found = 22, + + accounts_must_have_the_same_ledger = 23, + transfer_must_have_the_same_ledger_as_accounts = 24, + + pending_transfer_not_found = 25, + pending_transfer_not_pending = 26, + + pending_transfer_has_different_debit_account_id = 27, + pending_transfer_has_different_credit_account_id = 28, + pending_transfer_has_different_ledger = 29, + pending_transfer_has_different_code = 30, + + exceeds_pending_transfer_amount = 31, + pending_transfer_has_different_amount = 32, + + pending_transfer_already_posted = 33, + pending_transfer_already_voided = 34, + + pending_transfer_expired = 35, + + imported_event_timestamp_must_not_regress = 60, + imported_event_timestamp_must_postdate_debit_account = 61, + imported_event_timestamp_must_postdate_credit_account = 62, + imported_event_timeout_must_be_zero = 63, + + debit_account_already_closed = 65, + credit_account_already_closed = 66, + + overflows_debits_pending = 47, + overflows_credits_pending = 48, + overflows_debits_posted = 49, + overflows_credits_posted = 50, + overflows_debits = 51, + overflows_credits = 52, + overflows_timeout = 53, + + exceeds_credits = 54, + exceeds_debits = 55, + + deprecated_18 = 18, // amount_must_not_be_zero. + + // Update this comment when adding a new value: + // Last item: id_already_failed = 68. + + /// Returns `true` if the error code depends on transient system status and retrying + /// the same transfer with identical request data can produce different outcomes. + pub fn transient(result: CreateTransferStatus) bool { + return switch (result) { + .created, .deprecated_ok => unreachable, + + .debit_account_not_found, + .credit_account_not_found, + .pending_transfer_not_found, + .exceeds_credits, + .exceeds_debits, + .debit_account_already_closed, + .credit_account_already_closed, + => true, + + .linked_event_failed, + .linked_event_chain_open, + .imported_event_expected, + .imported_event_not_expected, + .timestamp_must_be_zero, + .imported_event_timestamp_out_of_range, + .imported_event_timestamp_must_not_advance, + .reserved_flag, + .id_must_not_be_zero, + .id_must_not_be_int_max, + .id_already_failed, + .exists_with_different_flags, + .exists_with_different_pending_id, + .exists_with_different_timeout, + .exists_with_different_debit_account_id, + .exists_with_different_credit_account_id, + .exists_with_different_amount, + .exists_with_different_user_data_128, + .exists_with_different_user_data_64, + .exists_with_different_user_data_32, + .exists_with_different_ledger, + .exists_with_different_code, + .exists, + .imported_event_timestamp_must_not_regress, + .imported_event_timestamp_must_postdate_debit_account, + .imported_event_timestamp_must_postdate_credit_account, + .imported_event_timeout_must_be_zero, + .flags_are_mutually_exclusive, + .debit_account_id_must_not_be_zero, + .debit_account_id_must_not_be_int_max, + .credit_account_id_must_not_be_zero, + .credit_account_id_must_not_be_int_max, + .accounts_must_be_different, + .pending_id_must_be_zero, + .pending_id_must_not_be_zero, + .pending_id_must_not_be_int_max, + .pending_id_must_be_different, + .timeout_reserved_for_pending_transfer, + .closing_transfer_must_be_pending, + .ledger_must_not_be_zero, + .code_must_not_be_zero, + .accounts_must_have_the_same_ledger, + .transfer_must_have_the_same_ledger_as_accounts, + .pending_transfer_not_pending, + .pending_transfer_has_different_debit_account_id, + .pending_transfer_has_different_credit_account_id, + .pending_transfer_has_different_ledger, + .pending_transfer_has_different_code, + .exceeds_pending_transfer_amount, + .pending_transfer_has_different_amount, + .pending_transfer_already_posted, + .pending_transfer_already_voided, + .pending_transfer_expired, + .overflows_debits_pending, + .overflows_credits_pending, + .overflows_debits_posted, + .overflows_credits_posted, + .overflows_debits, + .overflows_credits, + .overflows_timeout, + => false, + + .deprecated_18 => unreachable, + }; + } + + comptime { + @setEvalBranchQuota(2_000); + const values = std.enums.values(CreateTransferStatus); + const BitSet = stdx.BitSetType(values.len - 1); + var set: BitSet = .{}; + for (0..values.len - 1) |index| { + const result: CreateTransferStatus = @enumFromInt(index); + stdx.maybe(result == values[index]); + + assert(!set.is_set(index)); + set.set(index); + } + + // It's a non-ordered enum, we need to ensure + // there are no gaps in the numbering of the values. + assert(set.full()); + + // Except by the "created" result, which is represented as `maxInt`. + const max: CreateTransferStatus = @enumFromInt( + std.math.maxInt(std.meta.Tag(CreateTransferStatus)), + ); + assert(max == .created); + } + + /// TODO(zig): CreateTransferStatus is ordered by precedence, but it crashes + /// `EnumSet`, and `@setEvalBranchQuota()` isn't propagating correctly: + /// https://godbolt.org/z/6a45bx6xs + /// error: evaluation exceeded 1000 backwards branches + /// note: use @setEvalBranchQuota() to raise the branch limit from 1000. + /// + /// As a workaround we generate a new Ordered enum to be used in this case. + pub const Ordered = type: { + const values = std.enums.values(CreateTransferStatus); + var fields: [values.len]std.builtin.Type.EnumField = undefined; + for (0..values.len - 1) |index| { + const result: CreateTransferStatus = @enumFromInt(index); + fields[index] = .{ + .name = @tagName(result), + .value = index, + }; + } + fields[values.len - 1] = .{ + .name = @tagName(CreateTransferStatus.created), + .value = @intFromEnum(CreateTransferStatus.created), + }; + + var type_info = @typeInfo(enum {}); + type_info.@"enum".tag_type = std.meta.Tag(CreateTransferStatus); + type_info.@"enum".fields = &fields; + break :type @Type(type_info); + }; + + pub fn to_ordered(value: CreateTransferStatus) Ordered { + return @enumFromInt(@intFromEnum(value)); + } + + comptime { + const values = std.enums.values(Ordered); + assert(values.len == std.enums.values(CreateTransferStatus).len); + for (0..values.len - 1) |index| { + const value: Ordered = @enumFromInt(index); + assert(value == values[index]); + + const value_source: CreateTransferStatus = @enumFromInt(index); + assert(std.mem.eql(u8, @tagName(value_source), @tagName(value))); + } + assert(@intFromEnum(Ordered.created) == @intFromEnum(CreateTransferStatus.created)); + } +}; + +pub const CreateAccountResult = extern struct { + timestamp: u64, + status: CreateAccountStatus, + reserved: u32 = 0, + + comptime { + assert(@sizeOf(CreateAccountResult) == 16); + assert(@alignOf(CreateAccountResult) == 8); + assert(stdx.no_padding(CreateAccountResult)); + } +}; + +pub const CreateTransferResult = extern struct { + timestamp: u64, + status: CreateTransferStatus, + reserved: u32 = 0, + + comptime { + assert(@sizeOf(CreateTransferResult) == 16); + assert(@alignOf(CreateTransferResult) == 8); + assert(stdx.no_padding(CreateTransferResult)); + } +}; + +// Deprecated: sparse results containing only error codes. +pub const CreateAccountErrorResult = extern struct { + index: u32, + result: CreateAccountStatus, + + comptime { + assert(@sizeOf(CreateAccountErrorResult) == 8); + assert(stdx.no_padding(CreateAccountErrorResult)); + } +}; + +// Deprecated: sparse results containing only error codes. +pub const CreateTransferErrorResult = extern struct { + index: u32, + result: CreateTransferStatus, + + comptime { + assert(@sizeOf(CreateTransferErrorResult) == 8); + assert(stdx.no_padding(CreateTransferErrorResult)); + } +}; + +pub const QueryFilter = extern struct { + /// Query by the `user_data_128` index. + /// Use zero for no filter. + user_data_128: u128, + /// Query by the `user_data_64` index. + /// Use zero for no filter. + user_data_64: u64, + /// Query by the `user_data_32` index. + /// Use zero for no filter. + user_data_32: u32, + /// Query by the `ledger` index. + /// Use zero for no filter. + ledger: u32, + /// Query by the `code` index. + /// Use zero for no filter. + code: u16, + reserved: [6]u8 = @splat(0), + /// The initial timestamp (inclusive). + /// Use zero for no filter. + timestamp_min: u64, + /// The final timestamp (inclusive). + /// Use zero for no filter. + timestamp_max: u64, + /// Maximum number of results that can be returned by this query. + /// Must be greater than zero. + limit: u32, + /// Query flags. + flags: QueryFilterFlags, + + comptime { + assert(@sizeOf(QueryFilter) == 64); + assert(stdx.no_padding(QueryFilter)); + } +}; + +pub const QueryFilterFlags = packed struct(u32) { + /// Whether the results are sorted by timestamp in chronological or reverse-chronological order. + reversed: bool, + padding: u31 = 0, + + comptime { + assert(@sizeOf(QueryFilterFlags) == @sizeOf(u32)); + assert(@bitSizeOf(QueryFilterFlags) == @sizeOf(QueryFilterFlags) * 8); + } +}; + +/// Filter used in both `get_account_transfers` and `get_account_balances`. +pub const AccountFilter = extern struct { + /// The account id. + account_id: u128, + /// Filter by the `user_data_128` index. + /// Use zero for no filter. + user_data_128: u128, + /// Filter by the `user_data_64` index. + /// Use zero for no filter. + user_data_64: u64, + /// Filter by the `user_data_32` index. + /// Use zero for no filter. + user_data_32: u32, + /// Query by the `code` index. + /// Use zero for no filter. + code: u16, + + reserved: [58]u8 = @splat(0), + /// The initial timestamp (inclusive). + /// Use zero for no filter. + timestamp_min: u64, + /// The final timestamp (inclusive). + /// Use zero for no filter. + timestamp_max: u64, + /// Maximum number of results that can be returned by this query. + /// Must be greater than zero. + limit: u32, + /// Query flags. + flags: AccountFilterFlags, + + comptime { + assert(@sizeOf(AccountFilter) == 128); + assert(stdx.no_padding(AccountFilter)); + } +}; + +pub const AccountFilterFlags = packed struct(u32) { + /// Whether to include results where `debit_account_id` matches. + debits: bool, + /// Whether to include results where `credit_account_id` matches. + credits: bool, + /// Whether the results are sorted by timestamp in chronological or reverse-chronological order. + reversed: bool, + padding: u29 = 0, + + comptime { + assert(@sizeOf(AccountFilterFlags) == @sizeOf(u32)); + assert(@bitSizeOf(AccountFilterFlags) == @sizeOf(AccountFilterFlags) * 8); + } +}; + +pub const ChangeEventType = enum(u8) { + single_phase = 0, + two_phase_pending = 1, + two_phase_posted = 2, + two_phase_voided = 3, + two_phase_expired = 4, +}; + +pub const ChangeEvent = extern struct { + transfer_id: u128, + transfer_amount: u128, + transfer_pending_id: u128, + transfer_user_data_128: u128, + transfer_user_data_64: u64, + transfer_user_data_32: u32, + transfer_timeout: u32, + transfer_code: u16, + transfer_flags: TransferFlags, + + ledger: u32, + type: ChangeEventType, + reserved: [39]u8 = @splat(0), + + debit_account_id: u128, + debit_account_debits_pending: u128, + debit_account_debits_posted: u128, + debit_account_credits_pending: u128, + debit_account_credits_posted: u128, + debit_account_user_data_128: u128, + debit_account_user_data_64: u64, + debit_account_user_data_32: u32, + debit_account_code: u16, + debit_account_flags: AccountFlags, + + credit_account_id: u128, + credit_account_debits_pending: u128, + credit_account_debits_posted: u128, + credit_account_credits_pending: u128, + credit_account_credits_posted: u128, + credit_account_user_data_128: u128, + credit_account_user_data_64: u64, + credit_account_user_data_32: u32, + credit_account_code: u16, + credit_account_flags: AccountFlags, + + timestamp: u64, + transfer_timestamp: u64, + debit_account_timestamp: u64, + credit_account_timestamp: u64, + + comptime { + assert(stdx.no_padding(ChangeEvent)); + // Each event has the size of one transfer + 2 accounts. + assert(@sizeOf(ChangeEvent) == @sizeOf(Transfer) + (2 * @sizeOf(Account))); + assert(@alignOf(ChangeEvent) == 16); + } +}; + +pub const ChangeEventsFilter = extern struct { + timestamp_min: u64, + timestamp_max: u64, + limit: u32, + reserved: [44]u8 = @splat(0), + + comptime { + assert(stdx.no_padding(ChangeEventsFilter)); + assert(@sizeOf(ChangeEventsFilter) == 64); + } +}; + +/// Operations exported by TigerBeetle. +pub const Operation = enum(u8) { + // Looking to make backwards incompatible changes here? + // Make sure to check release.zig for `release_triple_client_min`. + + pulse = constants.vsr_operations_reserved + 0, + + // Deprecated operations not encoded as multi-batch: + deprecated_create_accounts_unbatched = constants.vsr_operations_reserved + 1, + deprecated_create_transfers_unbatched = constants.vsr_operations_reserved + 2, + deprecated_lookup_accounts_unbatched = constants.vsr_operations_reserved + 3, + deprecated_lookup_transfers_unbatched = constants.vsr_operations_reserved + 4, + deprecated_get_account_transfers_unbatched = constants.vsr_operations_reserved + 5, + deprecated_get_account_balances_unbatched = constants.vsr_operations_reserved + 6, + deprecated_query_accounts_unbatched = constants.vsr_operations_reserved + 7, + deprecated_query_transfers_unbatched = constants.vsr_operations_reserved + 8, + + get_change_events = constants.vsr_operations_reserved + 9, + + // `create_*` operations that return sparse results containing only errors. + deprecated_create_accounts_sparse = constants.vsr_operations_reserved + 10, + deprecated_create_transfers_sparse = constants.vsr_operations_reserved + 11, + + lookup_accounts = constants.vsr_operations_reserved + 12, + lookup_transfers = constants.vsr_operations_reserved + 13, + get_account_transfers = constants.vsr_operations_reserved + 14, + get_account_balances = constants.vsr_operations_reserved + 15, + query_accounts = constants.vsr_operations_reserved + 16, + query_transfers = constants.vsr_operations_reserved + 17, + + create_accounts = constants.vsr_operations_reserved + 18, + create_transfers = constants.vsr_operations_reserved + 19, + + pub fn EventType(comptime operation: Operation) type { + return switch (operation) { + .pulse => void, + .create_accounts => Account, + .create_transfers => Transfer, + .lookup_accounts => u128, + .lookup_transfers => u128, + .get_account_transfers => AccountFilter, + .get_account_balances => AccountFilter, + .query_accounts => QueryFilter, + .query_transfers => QueryFilter, + .get_change_events => ChangeEventsFilter, + + .deprecated_create_accounts_sparse => Account, + .deprecated_create_transfers_sparse => Transfer, + + .deprecated_create_accounts_unbatched => Account, + .deprecated_create_transfers_unbatched => Transfer, + .deprecated_lookup_accounts_unbatched => u128, + .deprecated_lookup_transfers_unbatched => u128, + .deprecated_get_account_transfers_unbatched => AccountFilter, + .deprecated_get_account_balances_unbatched => AccountFilter, + .deprecated_query_accounts_unbatched => QueryFilter, + .deprecated_query_transfers_unbatched => QueryFilter, + }; + } + + pub fn ResultType(comptime operation: Operation) type { + return switch (operation) { + .pulse => void, + .create_accounts => CreateAccountResult, + .create_transfers => CreateTransferResult, + .lookup_accounts => Account, + .lookup_transfers => Transfer, + .get_account_transfers => Transfer, + .get_account_balances => AccountBalance, + .query_accounts => Account, + .query_transfers => Transfer, + .get_change_events => ChangeEvent, + + .deprecated_create_accounts_sparse => CreateAccountErrorResult, + .deprecated_create_transfers_sparse => CreateTransferErrorResult, + + .deprecated_create_accounts_unbatched => CreateAccountErrorResult, + .deprecated_create_transfers_unbatched => CreateTransferErrorResult, + .deprecated_lookup_accounts_unbatched => Account, + .deprecated_lookup_transfers_unbatched => Transfer, + .deprecated_get_account_transfers_unbatched => Transfer, + .deprecated_get_account_balances_unbatched => AccountBalance, + .deprecated_query_accounts_unbatched => Account, + .deprecated_query_transfers_unbatched => Transfer, + }; + } + + /// Inline function so that `operation` can be known at comptime. + pub inline fn event_size(operation: Operation) u32 { + return switch (operation) { + inline else => |operation_comptime| @sizeOf(operation_comptime.EventType()), + }; + } + + /// Inline function so that `operation` can be known at comptime. + pub inline fn result_size(operation: Operation) u32 { + return switch (operation) { + inline else => |operation_comptime| @sizeOf(operation_comptime.ResultType()), + }; + } + + /// Whether the operation supports multiple events per batch. + /// If not, multi-batch requests are still supported, but with a single event per batch. + pub inline fn is_batchable(operation: Operation) bool { + return switch (operation) { + // Pulse does not take any input. + .pulse => false, + // Operations that take multiple events as input: + .create_accounts => true, + .create_transfers => true, + .lookup_accounts => true, + .lookup_transfers => true, + // Operations that take a single event as input: + .get_account_transfers => false, + .get_account_balances => false, + .query_accounts => false, + .query_transfers => false, + .get_change_events => false, + + .deprecated_create_accounts_sparse => true, + .deprecated_create_transfers_sparse => true, + + .deprecated_create_accounts_unbatched => true, + .deprecated_create_transfers_unbatched => true, + .deprecated_lookup_accounts_unbatched => true, + .deprecated_lookup_transfers_unbatched => true, + .deprecated_get_account_transfers_unbatched => false, + .deprecated_get_account_balances_unbatched => false, + .deprecated_query_accounts_unbatched => false, + .deprecated_query_transfers_unbatched => false, + }; + } + + /// Whether the operation is multi-batch encoded. + /// Inline function so that `operation` can be known at comptime. + pub inline fn is_multi_batch(operation: Operation) bool { + return switch (operation) { + .pulse => false, + + .create_accounts, + .create_transfers, + .lookup_accounts, + .lookup_transfers, + .get_account_transfers, + .get_account_balances, + .query_accounts, + .query_transfers, + => true, + + .get_change_events => false, + + .deprecated_create_accounts_sparse, + .deprecated_create_transfers_sparse, + => true, + + .deprecated_create_accounts_unbatched, + .deprecated_create_transfers_unbatched, + .deprecated_lookup_accounts_unbatched, + .deprecated_lookup_transfers_unbatched, + .deprecated_get_account_transfers_unbatched, + .deprecated_get_account_balances_unbatched, + .deprecated_query_accounts_unbatched, + .deprecated_query_transfers_unbatched, + => false, + }; + } + + /// The maximum number of events per batch. + /// Inline function so that `operation` and `batch_size_limit` can be known at comptime. + pub inline fn event_max(operation: Operation, batch_size_limit: u32) u32 { + assert(batch_size_limit > 0); + assert(batch_size_limit <= constants.message_body_size_max); + + const event_size_bytes: u32 = operation.event_size(); + maybe(event_size_bytes == 0); // Zeroed event size is allowed. + const result_size_bytes: u32 = operation.result_size(); + assert(result_size_bytes > 0); + + if (!operation.is_multi_batch()) { + return if (event_size_bytes == 0) + @divFloor(constants.message_body_size_max, result_size_bytes) + else + @min( + @divFloor(batch_size_limit, event_size_bytes), + @divFloor(constants.message_body_size_max, result_size_bytes), + ); + } + assert(operation.is_multi_batch()); + + const reply_trailer_size_min: u32 = vsr.multi_batch.trailer_total_size(.{ + .element_size = result_size_bytes, + .batch_count = 1, + }); + assert(reply_trailer_size_min > 0); + assert(reply_trailer_size_min < batch_size_limit); + + if (event_size_bytes == 0) { + return @divFloor( + constants.message_body_size_max - reply_trailer_size_min, + result_size_bytes, + ); + } else { + const request_trailer_size_min: u32 = vsr.multi_batch.trailer_total_size(.{ + .element_size = event_size_bytes, + .batch_count = 1, + }); + assert(request_trailer_size_min > 0); + assert(request_trailer_size_min < constants.message_body_size_max); + + return @min( + @divFloor(batch_size_limit - request_trailer_size_min, event_size_bytes), + @divFloor( + constants.message_body_size_max - reply_trailer_size_min, + result_size_bytes, + ), + ); + } + } + + /// The maximum number of results per batch. + /// If the number of results is defined by the number of events (`is_batchable()` + /// is true) then `result_max() == event_max()`. + /// Inline function so that `operation` and `batch_size_limit` can be known at comptime. + pub inline fn result_max(operation: Operation, batch_size_limit: u32) u32 { + assert(batch_size_limit > 0); + assert(batch_size_limit <= constants.message_body_size_max); + if (operation.is_batchable()) { + return operation.event_max(batch_size_limit); + } + assert(!operation.is_batchable()); + + const result_size_bytes = operation.result_size(); + assert(result_size_bytes > 0); + + if (!operation.is_multi_batch()) { + return @divFloor(constants.message_body_size_max, result_size_bytes); + } + assert(operation.is_multi_batch()); + + const reply_trailer_size_min: u32 = vsr.multi_batch.trailer_total_size(.{ + .element_size = result_size_bytes, + .batch_count = 1, + }); + return @divFloor( + constants.message_body_size_max - reply_trailer_size_min, + result_size_bytes, + ); + } + + /// Returns the expected number of results for a given batch. + /// For multi-batch requests, this function expects a single, already decoded batch. + /// Inline function so that `operation` can be known at comptime. + pub inline fn result_count_expected( + operation: Operation, + batch: []const u8, + ) u32 { + return switch (operation) { + .pulse => 0, + inline .create_accounts, + .create_transfers, + .lookup_accounts, + .lookup_transfers, + .deprecated_create_accounts_sparse, + .deprecated_create_transfers_sparse, + .deprecated_create_accounts_unbatched, + .deprecated_create_transfers_unbatched, + .deprecated_lookup_accounts_unbatched, + .deprecated_lookup_transfers_unbatched, + => |operation_comptime| count: { + // For these types of operations, each event produces at most one result. + comptime assert(operation_comptime.is_batchable()); + + // Clients do not validate batch size == 0, + // and even the simulator can generate requests with no events. + if (batch.len == 0) return 0; + + const event_size_bytes: u32 = operation_comptime.event_size(); + comptime assert(event_size_bytes > 0); + assert(batch.len % event_size_bytes == 0); // Input has already been validated. + + break :count @intCast(@divExact(batch.len, event_size_bytes)); + }, + inline .get_account_transfers, + .get_account_balances, + .query_accounts, + .query_transfers, + .deprecated_get_account_transfers_unbatched, + .deprecated_get_account_balances_unbatched, + .deprecated_query_accounts_unbatched, + .deprecated_query_transfers_unbatched, + .get_change_events, + => |operation_comptime| count: { + // For queries, each event produces up to `limit` events. + comptime assert(!operation_comptime.is_batchable()); + + const Filter = operation_comptime.EventType(); + comptime assert(@sizeOf(Filter) > 0); + assert(batch.len == @sizeOf(Filter)); + // This function is used by the client, + // so the input may come from unaligned memory. + maybe(!std.mem.isAligned(@intFromPtr(batch.ptr), @alignOf(Filter))); + + const filter: Filter = std.mem.bytesToValue(Filter, batch); + maybe(filter.limit == 0); + + break :count filter.limit; + }, + }; + } + + pub fn from_vsr(operation: vsr.Operation) ?Operation { + if (operation == .pulse) return .pulse; + if (operation.vsr_reserved()) return null; + + return vsr.Operation.to(Operation, operation); + } + + pub fn to_vsr(operation: Operation) vsr.Operation { + return vsr.Operation.from(Operation, operation); + } +}; + +comptime { + const target = builtin.target; + + if (target.os.tag != .linux and !target.os.tag.isDarwin() and target.os.tag != .windows) { + @compileError("linux, windows or macos is required for io"); + } + + // We require little-endian architectures everywhere for efficient network deserialization: + if (target.cpu.arch.endian() != .little) { + @compileError("big-endian systems not supported"); + } + + switch (builtin.mode) { + .Debug, .ReleaseSafe => {}, + .ReleaseFast, .ReleaseSmall => @compileError("safety checks are required for correctness"), + } +} diff --git a/ocam/src/tigerbeetle/benchmark_driver.zig b/ocam/src/tigerbeetle/benchmark_driver.zig new file mode 100644 index 00000000..968207a8 --- /dev/null +++ b/ocam/src/tigerbeetle/benchmark_driver.zig @@ -0,0 +1,233 @@ +//! Driver script behind `tigerbeetle benchmark` command. +//! +//! During benchmarking, there are three entities to keep track of: +//! - the "load" process generating requests, +//! - the cluster of `tigerbeetle`s processing requests, +//! - the orchestrating script coordinating the two. +//! +//! This here is the orchestrator. If no `--addresses` is passed on the command line, it spins up a +//! temporary single-node `tigerbeetle` cluster. Otherwise, an existing cluster is re-used for the +//! benchmarking. +//! +//! The cluster address is then passed onto `benchmark_load.zig`, which deals with both offering +//! the load and measuring response latencies and throughput. The load runs in-process. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ChildProcess = std.process.Child; + +const vsr = @import("vsr"); +const stdx = vsr.stdx; +const cli = @import("./cli.zig"); +const benchmark_load = @import("./benchmark_load.zig"); + +const log = std.log; + +pub fn command_benchmark( + allocator: Allocator, + io: *vsr.io.IO, + time: vsr.time.Time, + args: *const cli.Command.Benchmark, +) !void { + // Note: we intentionally don't use a temporary directory for this data file, and instead just + // put it into CWD, as performance of TigerBeetle very much depends on a specific file system. + const data_file = args.file orelse data_file: { + var random_bytes: [4]u8 = undefined; + std.crypto.random.bytes(&random_bytes); + const random_suffix: [8]u8 = std.fmt.bytesToHex(random_bytes, .lower); + break :data_file "0_0-" ++ random_suffix ++ ".tigerbeetle.benchmark"; + }; + + var data_file_created = false; + defer { + if (data_file_created and args.file == null) { + std.fs.cwd().deleteFile(data_file) catch {}; + } + } + + var tigerbeetle_process: ?TigerBeetleProcess = null; + defer if (tigerbeetle_process) |*p| { + _ = p.deinit(); + }; + + var maybe_stat_empty: ?std.fs.File.Stat = null; + if (args.addresses == null) { + const me = try std.fs.selfExePathAlloc(allocator); + defer allocator.free(me); + + try format(allocator, .{ .tigerbeetle = me, .data_file = data_file }); + data_file_created = true; + maybe_stat_empty = try std.fs.cwd().statFile(data_file); + + tigerbeetle_process = try start(allocator, .{ + .tigerbeetle = me, + .data_file = data_file, + .args = args, + }); + } else { + // Arguments forwarded to the replica cannot be used with a cluster started by the user. + inline for (.{ + "cache_accounts", + "cache_transfers", + "cache_transfers_pending", + "cache_grid", + "memory", + "statsd", + "trace", + "file", + }) |arg_name| { + if (@field(args, arg_name) != null) { + vsr.fatal(.cli, "--" ++ arg_name ++ ": incompatible with --addresses", .{}); + } + } + + if (args.log_debug_replica) { + vsr.fatal(.cli, "--log-debug-replica: incompatible with --addresses", .{}); + } + } + + const addresses = if (args.addresses) |*addresses| + addresses.slice() + else + &.{tigerbeetle_process.?.address}; + try benchmark_load.main(allocator, io, time, addresses, args); + + if (tigerbeetle_process) |*p| { + const rusage = p.deinit(); + tigerbeetle_process = null; + + if (rusage.getMaxRss()) |max_rss_bytes| { + std.io.getStdOut().writer().print("\nrss = {} bytes\n", .{max_rss_bytes}) catch {}; + } + } + + if (data_file_created) { + const stat = try std.fs.cwd().statFile(data_file); + if (maybe_stat_empty) |stat_empty| { + try std.io.getStdOut().writer().print("\ndatafile empty = {} bytes\n", .{ + stat_empty.size, + }); + } + try std.io.getStdOut().writer().print("datafile = {} bytes\n", .{stat.size}); + } +} + +fn format(allocator: std.mem.Allocator, options: struct { + tigerbeetle: []const u8, + data_file: []const u8, +}) !void { + const format_result = try ChildProcess.run(.{ + .allocator = allocator, + .argv = &.{ + options.tigerbeetle, + "format", + "--cluster=0", + "--replica=0", + "--replica-count=1", + options.data_file, + }, + }); + defer { + allocator.free(format_result.stdout); + allocator.free(format_result.stderr); + } + errdefer log.err("stderr: {s}", .{format_result.stderr}); + + switch (format_result.term) { + .Exited => |code| if (code != 0) return error.BadFormat, + else => return error.BadFormat, + } +} + +const TigerBeetleProcess = struct { + child: std.process.Child, + address: stdx.SocketAddress, + + fn deinit(self: *TigerBeetleProcess) std.process.Child.ResourceUsageStatistics { + // Although we could just kill the child here, let's exercise the "normal" termination logic + // through stdin closure, such that, from the perspective of the child, there's no + // difference between the parent process exiting normally or just crashing. + self.child.stdin.?.close(); + self.child.stdin = null; + _ = self.child.wait() catch {}; + + defer self.* = undefined; + + return self.child.resource_usage_statistics; + } +}; + +fn start(allocator: std.mem.Allocator, options: struct { + tigerbeetle: []const u8, + data_file: []const u8, + args: *const cli.Command.Benchmark, +}) !TigerBeetleProcess { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + var start_args = std.ArrayListUnmanaged([]const u8){}; + try start_args.append(arena.allocator(), options.tigerbeetle); + try start_args.append(arena.allocator(), "start"); + try start_args.append(arena.allocator(), "--addresses=0"); + + // Forward the cache options to the tigerbeetle process: + const forward_args = &.{ + .{ options.args.cache_accounts, "cache-accounts" }, + .{ options.args.cache_transfers, "cache-transfers" }, + .{ options.args.cache_transfers_pending, "cache-transfers-pending" }, + .{ options.args.cache_grid, "cache-grid" }, + .{ options.args.memory, "memory" }, + .{ options.args.statsd, "statsd" }, + .{ options.args.trace, "trace" }, + }; + + inline for (forward_args) |forward_arg| { + if (forward_arg[0]) |arg_value| { + try start_args.append( + arena.allocator(), + try std.fmt.allocPrint(arena.allocator(), "--{s}={s}", .{ + forward_arg[1], + arg_value, + }), + ); + } + } + + if (options.args.log_debug_replica) { + try start_args.append(arena.allocator(), "--log-debug"); + } + + // Some of the forwarded arguments require the "--experimental" flag. + const experimental: bool = inline for (forward_args) |forward_arg| { + if (forward_arg[0] != null) break true; + } else false; + if (experimental or options.args.log_debug_replica) { + try start_args.append(arena.allocator(), "--experimental"); + } + + try start_args.append(arena.allocator(), options.data_file); + var child = std.process.Child.init(start_args.items, allocator); + + child.request_resource_usage_statistics = true; + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Inherit; + try child.spawn(); + errdefer { + _ = child.kill() catch {}; + } + + const port = port: { + errdefer log.err("failed to read port number from tigerbeetle process", .{}); + var port_buf: [std.fmt.count("{}\n", .{std.math.maxInt(u16)})]u8 = undefined; + const port_buf_len = try child.stdout.?.readAll(&port_buf); + break :port try stdx.parse_int(u16, port_buf[0 .. port_buf_len - 1], .{}); + }; + + const address: stdx.SocketAddress = .{ + .ip = .@"127.0.0.1", + .port = port, + }; + + return .{ .child = child, .address = address }; +} diff --git a/ocam/src/tigerbeetle/benchmark_load.zig b/ocam/src/tigerbeetle/benchmark_load.zig new file mode 100644 index 00000000..efd089e7 --- /dev/null +++ b/ocam/src/tigerbeetle/benchmark_load.zig @@ -0,0 +1,1096 @@ +//! Start TigerBeetle clients to run a workload against a cluster, measuring latency and throughput. +//! +//! Workload Design: +//! +//! Without arguments, `tigerbeetle benchmark` runs the "canonical workload", representative of the +//! expected real-world workload. It outputs two numbers, throughput and latency. Performance is +//! multidimensional and parametric, so treat this as a lossy compression problem: +//! +//! What is the most important to communicate given zero inputs and two outputs? +//! +//! This default behavior matters for quickly assessing particular hardware or code changes, and it +//! optimizes for human comprehension. The canonical workload isn't fixed, and default +//! benchmark numbers are not necessarily comparable across different TigerBeetle versions. +//! +//! For debugging performance, it is useful to be able to simulate a wide variety of workloads, so +//! `tigerbeetle benchmark` accepts a wide variety of arguments to override defaults. +//! +//! While TigerBeetle is optimized primarily for OLTP with extreme contention and almost exclusively +//! read-modify-writes, it supports other workload profiles as well. Introduce symbolic names for +//! workload kinds (e.g. `--read-heavy`), which desugar into low-level values of parameters, to +//! capture our evolving understanding of real-world load patterns. + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const panic = std.debug.panic; +const log = std.log.scoped(.benchmark); + +const vsr = @import("vsr"); +const tb = vsr.tigerbeetle; +const constants = vsr.constants; +const stdx = vsr.stdx; +const Ratio = stdx.PRNG.Ratio; +const ratio = stdx.PRNG.ratio; +const flags = vsr.flags; +const random_int_exponential = vsr.testing.random_int_exponential; +const IO = vsr.io.IO; +const Time = vsr.time.Time; +const Duration = stdx.Duration; +const MessagePool = vsr.message_pool.MessagePool; +const MessageBus = vsr.message_bus.MessageBusType(IO); +const Client = vsr.ClientType(tb.Operation, MessageBus); +const IdPermutation = vsr.testing.IdPermutation; +const ZipfianGenerator = stdx.ZipfianGenerator; +const ZipfianShuffled = stdx.ZipfianShuffled; + +const cli = @import("./cli.zig"); + +pub fn main( + allocator: std.mem.Allocator, + io: *IO, + time: Time, + addresses: []const stdx.SocketAddress, + cli_args: *const cli.Command.Benchmark, +) !void { + if (builtin.mode != .ReleaseSafe and builtin.mode != .ReleaseFast) { + log.warn("Benchmark must be built with '-Drelease' for reasonable results.", .{}); + } + if (!vsr.constants.config.process.direct_io) { + log.warn("Direct IO is disabled.", .{}); + } + if (vsr.constants.config.process.verify) { + log.warn("Extra assertions are enabled.", .{}); + } + + if (cli_args.account_count < 2) vsr.fatal( + .cli, + "--account-count: need at least two accounts, got {}", + .{cli_args.account_count}, + ); + + // The first account_count_hot accounts are "hot" -- they will be the debit side of + // transfer_hot_percent of the transfers. + if (cli_args.account_count_hot > cli_args.account_count) vsr.fatal( + .cli, + "--account-count-hot: must be less-than-or-equal-to --account-count, got {}", + .{cli_args.account_count_hot}, + ); + + if (cli_args.transfer_hot_percent > 100) vsr.fatal( + .cli, + "--transfer-hot-percent: must be less-than-or-equal-to 100, got {}", + .{cli_args.transfer_hot_percent}, + ); + + if (cli_args.clients == 0 or cli_args.clients > constants.clients_max) vsr.fatal( + .cli, + "--clients: must be between 1 and {}, got {}", + .{ constants.clients_max, cli_args.clients }, + ); + + if (cli_args.validate and cli_args.id_order == .tbid) vsr.fatal( + .cli, + "--validate is incompatible with --id-order=tbid", + .{}, + ); + + const cluster_id: u128 = 0; + + var message_pools = stdx.BoundedArrayType(MessagePool, constants.clients_max){}; + defer for (message_pools.slice()) |*message_pool| message_pool.deinit(allocator); + + for (0..cli_args.clients) |_| { + message_pools.push(try MessagePool.init(allocator, .client)); + } + + std.log.info("Benchmark running against {any}", .{addresses}); + + var clients = stdx.BoundedArrayType(Client, constants.clients_max){}; + defer for (clients.slice()) |*client| client.deinit(allocator); + + for (0..cli_args.clients) |i| { + clients.push(try Client.init( + allocator, + time, + &message_pools.slice()[i], + .{ + .id = stdx.unique_u128(), + .cluster = cluster_id, + .replica_count = @intCast(addresses.len), + .aof_recovery = false, + .message_bus_options = .{ + .configuration = addresses, + .io = io, + .trace = null, + .time = time, + }, + }, + )); + } + + // Each array position corresponds to a histogram bucket of 1ms. The last bucket is 10_000ms+. + const request_latency_histogram = try allocator.alloc(u64, 10_001); + @memset(request_latency_histogram, 0); + defer allocator.free(request_latency_histogram); + + const client_timeouts = try allocator.alloc(Benchmark.Timeout, clients.count()); + defer allocator.free(client_timeouts); + + const client_requests = try allocator.alignedAlloc( + [constants.message_body_size_max]u8, + constants.cache_line_size, + clients.count(), + ); + defer allocator.free(client_requests); + + const client_replies = try allocator.alignedAlloc( + [constants.message_body_size_max]u8, + constants.cache_line_size, + clients.count(), + ); + defer allocator.free(client_replies); + + // If no seed was given, use a default seed for reproducibility. + const seed = seed_from_arg: { + const seed_argument = cli_args.seed orelse break :seed_from_arg 42; + break :seed_from_arg vsr.testing.parse_seed(seed_argument); + }; + + log.info("Benchmark seed = {}", .{seed}); + + var prng = stdx.PRNG.from_seed(seed); + const account_id_permutation: IdPermutation = switch (cli_args.id_order) { + .sequential, .tbid => .{ .identity = {} }, + .random => .{ .random = prng.int(u64) }, + .reversed => .{ .inversion = {} }, + }; + + assert(cli_args.account_count >= cli_args.account_count_hot); + const account_generator = Generator.from_distribution( + cli_args.account_distribution, + cli_args.account_count - cli_args.account_count_hot, + &prng, + ); + const account_generator_hot = Generator.from_distribution( + cli_args.account_distribution, + cli_args.account_count_hot, + &prng, + ); + + log.info("Account distribution: {s}", .{ + @tagName(cli_args.account_distribution), + }); + + const use_tbid = cli_args.id_order == .tbid; + const account_id_start: ?u128 = if (use_tbid) + stdx.unique_u128() + else + null; + + var benchmark = Benchmark{ + .io = io, + .prng = &prng, + .timer = try std.time.Timer.start(), + .output = std.io.getStdOut().writer().any(), + .clients = clients.slice(), + .client_timeouts = client_timeouts, + .client_requests = client_requests, + .client_replies = client_replies, + .request_latency_histogram = request_latency_histogram, + .account_id_permutation = account_id_permutation, + .account_id_start = account_id_start, + .account_batch_count = cli_args.account_batch_count, + .account_count = cli_args.account_count, + .account_count_hot = cli_args.account_count_hot, + .account_generator = account_generator, + .account_generator_hot = account_generator_hot, + .transfer_id_permutation = account_id_permutation, + .tbid_generator = if (use_tbid) TbidGenerator.init(&prng) else null, + .transfer_batch_count = cli_args.transfer_batch_count, + .transfer_batch_delay = cli_args.transfer_batch_delay, + .transfer_count = cli_args.transfer_count, + .transfer_hot_ratio = ratio(cli_args.transfer_hot_percent, 100), + .transfer_pending = cli_args.transfer_pending, + .query_count = cli_args.query_count, + .no_history = cli_args.no_history, + .imported = cli_args.imported, + .validate = cli_args.validate, + .print_batch_timings = cli_args.print_batch_timings, + }; + + try benchmark.run(.register); + + var prng_init = prng; + { + try benchmark.run(.create_accounts); + try benchmark.run(.create_transfers); + if (benchmark.query_count > 0) { + try benchmark.run(.get_account_transfers); + } + } + + if (benchmark.validate) { + // Reset our state so we can check our work. + benchmark.prng = &prng_init; + try benchmark.run(.validate_accounts); + try benchmark.run(.validate_transfers); + } + + if (cli_args.checksum_performance) { + const buffer = try allocator.alloc(u8, constants.message_size_max); + defer allocator.free(buffer); + + benchmark.prng.fill(buffer); + + benchmark.timer.reset(); + _ = vsr.checksum(buffer); + const checksum_duration_ns = benchmark.timer.read(); + + benchmark.output.print( + \\message size max = {} bytes + \\checksum message size max = {} us + \\ + , .{ + constants.message_size_max, + @divTrunc(checksum_duration_ns, std.time.ns_per_us), + }) catch unreachable; + } +} + +const Generator = union(enum) { + zipfian: ZipfianShuffled, + latest: ZipfianGenerator, + uniform: u64, + + fn from_distribution( + distribution: cli.Command.Benchmark.Distribution, + count: u64, + prng: *stdx.PRNG, + ) Generator { + return switch (distribution) { + .zipfian => .{ .zipfian = ZipfianShuffled.init(count, prng) }, + .latest => .{ .latest = ZipfianGenerator.init(count) }, + .uniform => .{ .uniform = count }, + }; + } +}; + +/// Generates TigerBeetle time-based identifiers (TBIDs) using real wall-clock timestamps. +/// Modeled after the Rust client implementation in src/clients/rust/src/time_based_id.rs. +/// +/// Layout: 48-bit millisecond timestamp | 80-bit random +/// +/// Monotonicity is maintained by: +/// - Advancing time: new random value +/// - Same/backward time: incrementing random +/// - Random overflow: carry to timestamp, new random +const TbidGenerator = struct { + prng: *stdx.PRNG, + epoch_ms: u128, + random: u80, + + fn init(prng: *stdx.PRNG) TbidGenerator { + const epoch_ms: u128 = @intCast(std.time.milliTimestamp()); + return .{ + .prng = prng, + .epoch_ms = epoch_ms, + .random = prng.int(u80), + }; + } + + fn next(generator: *TbidGenerator) u128 { + const now: u128 = @intCast(std.time.milliTimestamp()); + + if (now > generator.epoch_ms) { + // Time advanced: use new time and new random. + generator.epoch_ms = now; + generator.random = generator.prng.int(u80); + } else { + // Time same or behind: keep old time, increment random. + generator.random = std.math.add(u80, generator.random, 1) catch blk: { + // Carry the overflow to the time part and reseed random (as the rust client). + generator.epoch_ms = std.math.add(u128, generator.epoch_ms, 1) catch + @panic("tbid timestamp overflow"); + break :blk generator.prng.int(u80); + }; + } + + return (@as(u128, generator.epoch_ms) << 80) | @as(u128, generator.random); + } +}; + +const Benchmark = struct { + io: *IO, + prng: *stdx.PRNG, + timer: std.time.Timer, + output: std.io.AnyWriter, + clients: []Client, + + // Configuration: + account_id_permutation: IdPermutation, + account_id_start: ?u128, + account_batch_count: u32, + account_count: u64, + account_count_hot: u32, + account_generator: Generator, + account_generator_hot: Generator, + transfer_id_permutation: IdPermutation, + tbid_generator: ?TbidGenerator, + transfer_batch_count: u32, + transfer_batch_delay: Duration, + transfer_count: u64, + transfer_hot_ratio: Ratio, + transfer_pending: bool, + query_count: u32, + no_history: bool, + imported: bool, + validate: bool, + print_batch_timings: bool, + + // State: + clients_busy: stdx.BitSetType(constants.clients_max) = .{}, + clients_request_ns: [constants.clients_max]u64 = @splat(undefined), + client_requests: []align(constants.cache_line_size) [constants.message_body_size_max]u8, + client_replies: []align(constants.cache_line_size) [constants.message_body_size_max]u8, + client_timeouts: []Timeout, + request_latency_histogram: []u64, + request_index: u64 = 0, + account_index: u64 = 0, + transfer_index: u64 = 0, + transfers_created: u64 = 0, + query_index: u64 = 0, + stage: Stage = .idle, + + const Timeout = struct { + benchmark: *Benchmark, + client_index: u32, + completion: IO.Completion = undefined, + }; + + const Stage = enum { + idle, + register, + create_accounts, + create_transfers, + get_account_transfers, + validate_accounts, + validate_transfers, + }; + + pub fn run(b: *Benchmark, stage: Stage) !void { + assert(b.stage == .idle); + assert(b.clients.len > 0); + assert(b.clients_busy.empty()); + assert(stdx.zeroed(std.mem.sliceAsBytes(b.request_latency_histogram))); + assert(b.request_index == 0); + assert(b.account_index == 0); + assert(b.transfer_index == 0); + assert(b.query_index == 0); + assert(stage != .idle); + + b.stage = stage; + b.timer.reset(); + + for (0..b.clients.len) |client_usize| { + const client: u32 = @intCast(client_usize); + switch (b.stage) { + .register => b.register(client), + .create_accounts => b.create_accounts(client), + .create_transfers => b.create_transfers(client), + .get_account_transfers => b.get_account_transfers(client), + .validate_accounts => b.validate_accounts(client), + .validate_transfers => b.validate_transfers(client), + .idle => break, // i-1 decided not to start any work. + } + } + + while (b.stage != .idle) { + for (b.clients) |*client| client.tick(); + try b.io.run_for_ns(constants.tick_ms * std.time.ns_per_ms); + } + } + + fn run_finish(b: *Benchmark) void { + assert(b.stage != .idle); + assert(b.clients_busy.empty()); + + b.stage = .idle; + b.request_index = 0; + b.account_index = 0; + b.transfer_index = 0; + b.query_index = 0; + @memset(b.request_latency_histogram, 0); + } + + fn register(b: *Benchmark, client_index: u32) void { + assert(b.stage == .register); + assert(!b.clients_busy.is_set(client_index)); + + b.clients_busy.set(client_index); + b.clients[client_index].register(register_callback, @bitCast(RequestContext{ + .benchmark = b, + .client_index = @intCast(client_index), + .request_index = undefined, + })); + b.request_index += 1; + } + + fn register_callback(user_data: u128, _: *const vsr.RegisterResult) void { + const context: RequestContext = @bitCast(user_data); + const b: *Benchmark = context.benchmark; + assert(b.stage == .register); + assert(b.clients_busy.is_set(context.client_index)); + + b.clients_busy.unset(context.client_index); + if (b.clients_busy.empty()) b.run_finish(); + } + + fn create_accounts(b: *Benchmark, client_index: u32) void { + assert(b.stage == .create_accounts); + assert(!b.clients_busy.is_set(client_index)); + assert(b.account_batch_count > 0); + assert(b.account_index <= b.account_count); + if (b.account_index == b.account_count) { + if (b.clients_busy.empty()) b.run_finish(); + return; + } + + const account_count: u32 = @intCast(@min( + b.account_count - b.account_index, + b.account_batch_count, + )); + const accounts = stdx.bytes_as_slice( + .exact, + tb.Account, + &b.client_requests[client_index], + )[0..account_count]; + b.build_accounts(accounts); + b.request(client_index, .create_accounts, .{ + .batch_count = account_count, + .event_size = @sizeOf(tb.Account), + }); + } + + fn create_accounts_callback(b: *Benchmark, client_index: u32, results: []const u8) void { + assert(b.stage == .create_accounts); + + const account_results = stdx.bytes_as_slice( + .exact, + tb.CreateAccountResult, + results, + ); + for (account_results) |result| { + assert(result.timestamp > 0); + if (result.status != .created) { + panic("CreateAccountStatus: {any}", .{result.status}); + } + } + if (account_results.len != 0) {} + b.create_accounts(client_index); + } + + fn create_transfers(b: *Benchmark, client_index: u32) void { + assert(b.stage == .create_transfers); + assert(!b.clients_busy.is_set(client_index)); + assert(b.transfer_batch_count > 0); + assert(b.transfer_index <= b.transfer_count); + if (b.transfer_index == b.transfer_count) { + if (b.clients_busy.empty()) b.create_transfers_finish(); + return; + } + + const transfer_count: u32 = @intCast(@min( + b.transfer_count - b.transfer_index, + b.transfer_batch_count, + )); + const transfers = stdx.bytes_as_slice( + .exact, + tb.Transfer, + &b.client_requests[client_index], + )[0..transfer_count]; + b.build_transfers(transfers); + b.request(client_index, .create_transfers, .{ + .batch_count = transfer_count, + .event_size = @sizeOf(tb.Transfer), + }); + } + + fn create_transfers_callback(b: *Benchmark, client_index: u32, results: []const u8) void { + assert(!b.clients_busy.is_set(client_index)); + const transfer_results = stdx.bytes_as_slice( + .exact, + tb.CreateTransferResult, + results, + ); + for (transfer_results) |result| { + assert(result.timestamp > 0); + if (result.status != .created) { + panic("CreateTransferStatus: {any}", .{result.status}); + } + } + + const requests_complete = b.request_index - b.clients_busy.count(); + const request_duration_ns = b.timer.read() - b.clients_request_ns[client_index]; + const request_duration_ms = @divTrunc(request_duration_ns, std.time.ns_per_ms); + const transfers_created = @min(b.transfer_count, b.transfer_batch_count); + b.transfers_created += transfers_created; + + if (b.print_batch_timings) { + log.info("batch {}: {} tx in {} ms", .{ + requests_complete, + b.transfer_batch_count, + request_duration_ms, + }); + } + + if (b.transfer_batch_delay.ns == 0) { + b.create_transfers(client_index); + } else { + b.client_timeouts[client_index] = .{ .benchmark = b, .client_index = client_index }; + b.clients_busy.set(client_index); + b.io.timeout( + *Timeout, + &b.client_timeouts[client_index], + create_transfers_next, + &b.client_timeouts[client_index].completion, + @intCast(b.transfer_batch_delay.ns), + ); + } + } + + fn create_transfers_next( + timeout: *Timeout, + completion: *IO.Completion, + result: IO.TimeoutError!void, + ) void { + assert(completion == &timeout.completion); + _ = result catch |e| switch (e) { + error.Canceled => unreachable, + error.Unexpected => unreachable, + }; + + const b = timeout.benchmark; + assert(b.clients_busy.is_set(timeout.client_index)); + + b.clients_busy.unset(timeout.client_index); + b.create_transfers(timeout.client_index); + } + + fn create_transfers_finish(b: *Benchmark) void { + assert(b.stage == .create_transfers); + + b.output.print( + \\{[batch_count]} batches in {[batch_duration_s]d:.2} s + \\transfer batch size = {[batch_size]} txs + \\transfer batch delay = {[batch_delay]} + \\load accepted = {[transfer_rate]} tx/s + \\ + , .{ + .batch_count = b.request_index, + .batch_duration_s = @as(f64, @floatFromInt(b.timer.read())) / std.time.ns_per_s, + .batch_size = b.transfer_batch_count, + .batch_delay = b.transfer_batch_delay, + .transfer_rate = @divTrunc( + @as(u64, b.transfer_count) * std.time.ns_per_s, + b.timer.read(), + ), + }) catch unreachable; + print_percentiles_histogram(b.output, "batch", b.request_latency_histogram); + + b.run_finish(); + } + + fn get_account_transfers(b: *Benchmark, client_index: u32) void { + assert(b.stage == .get_account_transfers); + assert(!b.clients_busy.is_set(client_index)); + + if (b.query_index >= b.query_count) { + if (b.clients_busy.empty()) b.get_account_transfers_finish(); + return; + } + b.query_index += 1; + + const request_body = b.client_requests[client_index][0..@sizeOf(tb.AccountFilter)]; + // Use hot accounts for queries to equalize the number of results + // returned on each execution. + const account_index = b.choose_account_index(.hot); + const filter: *tb.AccountFilter = @alignCast(std.mem.bytesAsValue( + tb.AccountFilter, + request_body, + )); + filter.* = .{ + .account_id = b.account_id_from_index(account_index), + .user_data_128 = 0, + .user_data_64 = 0, + .user_data_32 = 0, + .code = 0, + .timestamp_min = 0, + .timestamp_max = 0, + .limit = @divExact( + constants.message_size_max - @sizeOf(vsr.Header), + @sizeOf(tb.Transfer), + ), + .flags = .{ + .credits = true, + .debits = true, + .reversed = false, + }, + }; + b.request(client_index, .get_account_transfers, .{ + .batch_count = 1, + .event_size = @sizeOf(tb.AccountFilter), + }); + } + + fn get_account_transfers_callback(b: *Benchmark, client_index: u32, result: []const u8) void { + assert(b.stage == .get_account_transfers); + + const filter: tb.AccountFilter = std.mem.bytesToValue( + tb.AccountFilter, + b.client_requests[client_index][0..@sizeOf(tb.AccountFilter)], + ); + const results = stdx.bytes_as_slice(.exact, tb.Transfer, result); + for (results) |*transfer| { + assert((transfer.debit_account_id == filter.account_id) != + (transfer.credit_account_id == filter.account_id)); + } + b.get_account_transfers(client_index); + } + + fn get_account_transfers_finish(b: *Benchmark) void { + assert(b.stage == .get_account_transfers); + + b.output.print("\n{[query_count]} queries in {[query_duration_s]d:.1} s\n", .{ + .query_count = b.request_index, + .query_duration_s = @as(f64, @floatFromInt(b.timer.read())) / std.time.ns_per_s, + }) catch unreachable; + print_percentiles_histogram(b.output, "query", b.request_latency_histogram); + + b.run_finish(); + } + + fn validate_accounts(b: *Benchmark, client_index: u32) void { + assert(b.stage == .validate_accounts); + assert(!b.clients_busy.is_set(client_index)); + assert(b.account_index <= b.account_count); + if (b.account_index == b.account_count) { + if (b.clients_busy.empty()) b.validate_accounts_finish(); + return; + } + + const account_count: u32 = @intCast(@min( + b.account_count - b.account_index, + b.account_batch_count, + )); + const account_ids = stdx.bytes_as_slice( + .exact, + u128, + &b.client_requests[client_index], + )[0..account_count]; + const accounts = stdx.bytes_as_slice( + .exact, + tb.Account, + &b.client_replies[client_index], + )[0..account_count]; + b.build_accounts(accounts); + for (account_ids, accounts) |*account_id, account| account_id.* = account.id; + b.request(client_index, .lookup_accounts, .{ + .batch_count = account_count, + .event_size = @sizeOf(u128), + }); + } + + fn validate_accounts_callback( + b: *Benchmark, + client_index: u32, + result: []const u8, + ) void { + assert(b.stage == .validate_accounts); + + const accounts_count = accounts_count: { + if (b.account_index == b.account_count) { + // The last batch might not be full. + const remaining = @rem(b.account_count, b.account_batch_count); + if (remaining > 0) break :accounts_count remaining; + } + + break :accounts_count b.account_batch_count; + }; + const accounts_expected_body = &b.client_replies[client_index]; + const accounts_expected = stdx.bytes_as_slice( + .exact, + tb.Account, + accounts_expected_body, + )[0..accounts_count]; + const accounts_actual = stdx.bytes_as_slice( + .exact, + tb.Account, + result, + ); + assert(accounts_actual.len == accounts_count); + for (accounts_expected, accounts_actual) |expected, actual| { + assert(expected.id == actual.id); + assert(expected.user_data_128 == actual.user_data_128); + assert(expected.user_data_64 == actual.user_data_64); + assert(expected.user_data_32 == actual.user_data_32); + assert(expected.code == actual.code); + assert(@as(u16, @bitCast(expected.flags)) == @as(u16, @bitCast(actual.flags))); + } + b.validate_accounts(client_index); + } + + fn validate_accounts_finish(b: *Benchmark) void { + assert(b.stage == .validate_accounts); + + b.output.print( + "validated {d} accounts\n", + .{b.account_count}, + ) catch unreachable; + b.run_finish(); + } + + fn validate_transfers(b: *Benchmark, client_index: u32) void { + assert(b.stage == .validate_transfers); + assert(!b.clients_busy.is_set(client_index)); + assert(b.transfer_index <= b.transfer_count); + if (b.transfer_index == b.transfer_count) { + if (b.clients_busy.empty()) b.validate_transfers_finish(); + return; + } + + const transfer_count: u32 = @intCast(@min( + b.transfer_count - b.transfer_index, + b.transfer_batch_count, + )); + const transfer_ids = stdx.bytes_as_slice( + .exact, + u128, + &b.client_requests[client_index], + )[0..transfer_count]; + const transfers = stdx.bytes_as_slice( + .exact, + tb.Transfer, + &b.client_replies[client_index], + )[0..transfer_count]; + b.build_transfers(transfers); + for (transfer_ids, transfers) |*transfer_id, transfer| transfer_id.* = transfer.id; + b.request(client_index, .lookup_transfers, .{ + .batch_count = transfer_count, + .event_size = @sizeOf(u128), + }); + } + + fn validate_transfers_callback( + b: *Benchmark, + client_index: u32, + result: []const u8, + ) void { + assert(b.stage == .validate_transfers); + + const transfers_count = transfers_count: { + if (b.transfer_index == b.transfer_count) { + // The last batch might not be full. + const remaining = @rem(b.transfer_count, b.transfer_batch_count); + if (remaining > 0) break :transfers_count remaining; + } + + break :transfers_count b.transfer_batch_count; + }; + const transfers_expected = stdx.bytes_as_slice( + .exact, + tb.Transfer, + &b.client_replies[client_index], + )[0..transfers_count]; + const transfers_actual = stdx.bytes_as_slice( + .exact, + tb.Transfer, + result, + ); + assert(transfers_actual.len == transfers_count); + for (transfers_expected, transfers_actual) |expected, actual| { + assert(expected.id == actual.id); + assert(expected.debit_account_id == actual.debit_account_id); + assert(expected.credit_account_id == actual.credit_account_id); + assert(expected.amount == actual.amount); + assert(expected.pending_id == actual.pending_id); + assert(expected.user_data_128 == actual.user_data_128); + assert(expected.user_data_64 == actual.user_data_64); + assert(expected.user_data_32 == actual.user_data_32); + assert(expected.timeout == actual.timeout); + assert(expected.ledger == actual.ledger); + assert(expected.code == actual.code); + assert(@as(u16, @bitCast(expected.flags)) == @as(u16, @bitCast(actual.flags))); + } + b.validate_transfers(client_index); + } + + fn validate_transfers_finish(b: *Benchmark) void { + assert(b.stage == .validate_transfers); + + b.output.print( + "validated {d} transfers\n", + .{b.transfer_count}, + ) catch unreachable; + + b.run_finish(); + } + + const RequestContext = extern struct { + benchmark: *Benchmark, + client_index: u32, + request_index: u32, + + comptime { + assert(@sizeOf(RequestContext) == @sizeOf(u128)); + } + }; + + fn request( + b: *Benchmark, + client_index: u32, + operation: tb.Operation, + options: struct { + batch_count: u32, + event_size: u32, + }, + ) void { + assert(b.stage != .idle); + assert(b.clients_busy.count() < b.clients.len); + assert(!b.clients_busy.is_set(client_index)); + + b.clients_busy.set(client_index); + b.clients_request_ns[client_index] = b.timer.read(); + b.request_index += 1; + + var encoder = vsr.multi_batch.MultiBatchEncoder.init( + &b.client_requests[client_index], + .{ .element_size = options.event_size }, + ); + encoder.add(options.batch_count * options.event_size); + const bytes_written = encoder.finish(); + + b.clients[client_index].request( + request_complete, + @bitCast(RequestContext{ + .benchmark = b, + .client_index = @intCast(client_index), + .request_index = @intCast(b.request_index - 1), + }), + operation, + b.client_requests[client_index][0..bytes_written], + ); + } + + fn request_complete( + user_data: u128, + operation_vsr: vsr.Operation, + timestamp: u64, + result: []align(constants.cache_line_size) const u8, + ) void { + const operation = operation_vsr.cast(tb.Operation); + const context: RequestContext = @bitCast(user_data); + const client = context.client_index; + const b: *Benchmark = context.benchmark; + assert(b.clients_busy.is_set(client)); + assert(b.stage != .idle); + assert(timestamp > 0); + + b.clients_busy.unset(client); + + const duration_ns = b.timer.read() - b.clients_request_ns[client]; + const duration_ms = @divTrunc(duration_ns, std.time.ns_per_ms); + b.request_latency_histogram[@min(duration_ms, b.request_latency_histogram.len - 1)] += 1; + + const input: []const u8 = input: { + assert(operation.is_multi_batch()); + var reply_decoder = vsr.multi_batch.MultiBatchDecoder.init( + result, + .{ .element_size = operation.result_size() }, + ) catch unreachable; + assert(reply_decoder.batch_count() == 1); + break :input reply_decoder.peek(); + }; + + switch (operation) { + .create_accounts => b.create_accounts_callback(client, input), + .create_transfers => b.create_transfers_callback(client, input), + .lookup_accounts => b.validate_accounts_callback(client, input), + .lookup_transfers => b.validate_transfers_callback(client, input), + .get_account_transfers => b.get_account_transfers_callback(client, input), + else => unreachable, + } + } + + fn account_id_from_index(b: *const Benchmark, index: u64) u128 { + if (b.account_id_start) |start| { + return start + index; + } else { + return b.account_id_permutation.encode(index + 1); + } + } + + fn next_transfer_id(b: *Benchmark) u128 { + if (b.tbid_generator) |*gen| { + return gen.next(); + } else { + return b.transfer_id_permutation.encode(b.transfer_index + 1); + } + } + + fn build_accounts(b: *Benchmark, accounts: []tb.Account) void { + for (accounts) |*account| { + account.* = .{ + .id = b.account_id_from_index(b.account_index), + .user_data_128 = b.prng.int(u128), + .user_data_64 = b.prng.int(u64), + .user_data_32 = b.prng.int(u32), + .reserved = 0, + .ledger = 2, + .code = 1, + .flags = .{ + .history = !b.no_history, + .imported = b.imported, + }, + .debits_pending = 0, + .debits_posted = 0, + .credits_pending = 0, + .credits_posted = 0, + .timestamp = if (b.imported) b.account_index + 1 else 0, + }; + b.account_index += 1; + } + } + + fn build_transfers(b: *Benchmark, transfers: []tb.Transfer) void { + for (transfers) |*transfer| { + // The set of accounts is divided into two different "worlds" by + // `account_count_hot`. Sometimes the debit account will be selected + // from the first `account_count_hot` accounts; otherwise both + // debit and credit will be selected from an account >= `account_count_hot`. + + const debit_account_index = b.choose_account_index( + if (b.prng.chance(b.transfer_hot_ratio)) .hot else .cold, + ); + + const credit_account_index = index: { + var index = b.choose_account_index(.cold); + if (index == debit_account_index) { + index = (index + 1) % b.account_count; + } + break :index index; + }; + assert(debit_account_index < b.account_count); + assert(credit_account_index < b.account_count); + assert(debit_account_index != credit_account_index); + + const debit_account_id = b.account_id_from_index(debit_account_index); + const credit_account_id = b.account_id_from_index(credit_account_index); + assert(debit_account_id != credit_account_id); + + // 30% of pending transfers. + const pending = b.transfer_pending and b.prng.chance(ratio(3, 10)); + + transfer.* = .{ + .id = b.next_transfer_id(), + .debit_account_id = debit_account_id, + .credit_account_id = credit_account_id, + .user_data_128 = b.prng.int(u128), + .user_data_64 = b.prng.int(u64), + .user_data_32 = b.prng.int(u32), + // TODO Benchmark posting/voiding pending transfers. + .pending_id = 0, + .ledger = 2, + .code = b.prng.int(u16) +| 1, + .flags = .{ + .pending = pending, + .imported = b.imported, + }, + .timeout = if (pending) + // Timeouts must be short enough to ensure they are likely to expire + // during the benchmark, allowing the performance impact to be measured. + b.prng.range_inclusive(u32, 1, 5) + else + 0, + .amount = random_int_exponential(b.prng, u64, 10_000) +| 1, + .timestamp = if (b.imported) b.account_index + b.transfer_index + 1 else 0, + }; + b.transfer_index += 1; + } + } + + fn choose_account_index(b: *Benchmark, hint: enum { hot, cold }) u64 { + assert(b.account_count > 0); + stdx.maybe(b.account_count_hot == 0); + assert(b.account_count >= b.account_count_hot); + + // The hint may be ignored if: + // Always use hot accounts if `account_count == account_count_hot`. + // Always use cold accounts if `account_count_hot == 0`. + const source: @TypeOf(hint) = switch (hint) { + .hot => if (b.account_count_hot > 0) .hot else .cold, + .cold => if (b.account_count > b.account_count_hot) .cold else .hot, + }; + + // Select the generator and the count from each source. + const generator: *Generator, const account_count: u64 = switch (source) { + .hot => .{ &b.account_generator_hot, b.account_count_hot }, + .cold => .{ &b.account_generator, b.account_count - b.account_count_hot }, + }; + assert(account_count > 0); + + const index = switch (generator.*) { + .zipfian => |gen| index: { + // zipfian set size must be same as account set size + assert(account_count == gen.gen.n); + const index = gen.next(b.prng); + assert(index < account_count); + break :index index; + }, + .latest => |gen| index: { + assert(account_count == gen.n); + const index_rev = gen.next(b.prng); + assert(index_rev < account_count); + break :index account_count - index_rev - 1; + }, + .uniform => |count| index: { + const index = b.prng.int_inclusive(u64, count - 1); + assert(index < account_count); + break :index index; + }, + }; + + return switch (source) { + .hot => index, + .cold => index + b.account_count_hot, + }; + } +}; + +fn print_percentiles_histogram( + stdout: std.io.AnyWriter, + label: []const u8, + histogram_buckets: []const u64, +) void { + var histogram_total: u64 = 0; + for (histogram_buckets) |bucket| histogram_total += bucket; + + const percentiles = [_]u64{ 1, 50, 99, 100 }; + for (percentiles) |percentile| { + const histogram_percentile: u64 = @divTrunc(histogram_total * percentile, 100); + + // Since each bucket in our histogram represents 1ms, the bucket we're in is the ms value. + var sum: u64 = 0; + const latency = for (histogram_buckets, 0..) |bucket, bucket_index| { + sum += bucket; + if (sum >= histogram_percentile) break bucket_index; + } else histogram_buckets.len; + + stdout.print("{s} latency p{: <3} = {} ms{s}\n", .{ + label, + percentile, + latency, + if (latency == histogram_buckets.len) "+ (exceeds histogram resolution)" else "", + }) catch unreachable; + } +} diff --git a/ocam/src/tigerbeetle/cli.zig b/ocam/src/tigerbeetle/cli.zig new file mode 100644 index 00000000..74da3cac --- /dev/null +++ b/ocam/src/tigerbeetle/cli.zig @@ -0,0 +1,1499 @@ +//! Parse and validate command-line arguments for the tigerbeetle binary. +//! +//! Everything that can be validated without reading the data file must be validated here. +//! Caller must additionally assert validity of arguments as a defense in depth. +//! +//! Some flags are experimental: intentionally undocumented and are not a part of the official +//! surface area. Even experimental features must adhere to the same strict standard of safety, +//! but they come without any performance or usability guarantees. +//! +//! Experimental features are not gated by comptime option for safety: it is much easier to review +//! code for correctness when it is initially added to the main branch, rather when a comptime flag +//! is lifted. + +const std = @import("std"); +const assert = std.debug.assert; +const fmt = std.fmt; + +const vsr = @import("vsr"); +const stdx = vsr.stdx; +const constants = vsr.constants; +const tigerbeetle = vsr.tigerbeetle; +const data_file_size_min = vsr.superblock.data_file_size_min; +const StateMachine = @import("./main.zig").StateMachine; +const Grid = @import("./main.zig").Grid; +const Ratio = stdx.PRNG.Ratio; +const ByteSize = stdx.ByteSize; +const Operation = tigerbeetle.Operation; +const Duration = stdx.Duration; + +comptime { + // Make sure we are running the Accounting StateMachine. + assert(StateMachine.Operation == tigerbeetle.Operation); +} + +const KiB = stdx.KiB; +const GiB = stdx.GiB; + +const CLIArgs = union(enum) { + const Format = struct { + cluster: ?u128 = null, + replica: ?u8 = null, + // Experimental: standbys don't have a concrete practical use-case yet. + standby: ?u8 = null, + replica_count: u8, + development: bool = false, + log_debug: bool = false, + + @"--": void, + path: []const u8, + }; + + const Recover = struct { + cluster: u128, + addresses: vsr.ClusterAddress, + replica: u8, + replica_count: u8, + development: bool = false, + log_debug: bool = false, + + @"--": void, + path: []const u8, + }; + + const Start = struct { + // Stable CLI arguments. + addresses: vsr.ClusterAddress, + cache_grid: ?ByteSize = null, + development: bool = false, + + // Everything from here until positional arguments is considered experimental, and requires + // `--experimental` to be set. Experimental flags must default to null, except for bools + // which must be false. + experimental: bool = false, + + limit_storage: ?ByteSize = null, + limit_pipeline_requests: ?u32 = null, + limit_request: ?ByteSize = null, + memory: ?ByteSize = null, + cache_accounts: ?ByteSize = null, + cache_transfers: ?ByteSize = null, + cache_transfers_pending: ?ByteSize = null, + memory_lsm_manifest: ?ByteSize = null, + memory_lsm_compaction: ?ByteSize = null, + trace: ?[]const u8 = null, + log_debug: bool = false, + log_trace: bool = false, + timeout_prepare_ms: ?u64 = null, + timeout_grid_repair_message_ms: ?u64 = null, + + commit_stall_probability: ?Ratio = null, + commit_stall_lag_min: ?u32 = null, + commit_stall_lag_max: ?u32 = null, + commit_stall_multiple_max: ?u16 = null, + + /// Legacy option. Star replication is the default behavior now. + replicate_star: bool = false, + + statsd: ?[]const u8 = null, + + /// AOF (Append Only File) logs all transactions synchronously to disk before replying + /// to the client. The logic behind this code has been kept as simple as possible - + /// io_uring or kqueue aren't used, there aren't any fancy data structures. Just a simple + /// log consisting of logged requests. Much like a redis AOF with fsync=on. + /// Enabling this will have performance implications. + aof_file: ?[]const u8 = null, + + /// Legacy AOF option. Mutually exclusive with aof_file, and will have the same effect as + /// setting aof_file to '.aof'. + aof: bool = false, + + /// AOF recovery mode: accept timestamps passed by the client. + /// Only enable this when recovering cluster from AOF. + aof_recovery: bool = false, + + @"--": void, + path: []const u8, + }; + + const Version = struct { + verbose: bool = false, + }; + + const Repl = struct { + addresses: vsr.ClusterAddress, + cluster: u128, + verbose: bool = false, + command: []const u8 = "", + log_debug: bool = false, + }; + + // Experimental: the interface is subject to change. + const Benchmark = struct { + cache_accounts: ?[]const u8 = null, + cache_transfers: ?[]const u8 = null, + cache_transfers_pending: ?[]const u8 = null, + cache_grid: ?[]const u8 = null, + memory: ?[]const u8 = null, + account_count: u64 = 10_000, + account_count_hot: u32 = 0, + log_debug: bool = false, + log_debug_replica: bool = false, + /// The probability distribution used to select accounts when making transfers or queries. + account_distribution: Command.Benchmark.Distribution = .uniform, + no_history: bool = false, + imported: bool = false, + account_batch_count: u32 = Operation.create_accounts.event_max( + constants.message_body_size_max, + ), + transfer_count: u64 = 10_000_000, + transfer_hot_percent: u32 = 100, + transfer_pending: bool = false, + transfer_batch_count: u32 = Operation.create_transfers.event_max( + constants.message_body_size_max, + ), + transfer_batch_delay: Duration = .ms(0), + validate: bool = false, + checksum_performance: bool = false, + query_count: u32 = 100, + print_batch_timings: bool = false, + id_order: Command.Benchmark.IdOrder = .tbid, + clients: u32 = 1, + statsd: ?[]const u8 = null, + trace: ?[]const u8 = null, + /// When set, don't delete the data file when the benchmark completes. + file: ?[]const u8 = null, + addresses: ?vsr.ClusterAddress = null, + seed: ?[]const u8 = null, + }; + + // Experimental: the interface is subject to change. + const Inspect = union(enum) { + constants, + metrics, + op: struct { + @"--": void, + op: u64, + }, + superblock: struct { + @"--": void, + path: []const u8, + }, + wal: struct { + slot: ?usize = null, + + @"--": void, + path: []const u8, + }, + replies: struct { + slot: ?usize = null, + superblock_copy: ?u8 = null, + + @"--": void, + path: []const u8, + }, + grid: struct { + block: ?u64 = null, + superblock_copy: ?u8 = null, + + @"--": void, + path: []const u8, + }, + manifest: struct { + superblock_copy: ?u8 = null, + + @"--": void, + path: []const u8, + }, + tables: struct { + superblock_copy: ?u8 = null, + tree: []const u8, + level: ?u6 = null, + + @"--": void, + path: []const u8, + }, + integrity: struct { + log_debug: bool = false, + seed: ?[]const u8 = null, + memory_lsm_manifest: ?ByteSize = null, + skip_wal: bool = false, + skip_client_replies: bool = false, + skip_grid: bool = false, + + @"--": void, + path: [:0]const u8, + }, + + pub const help = + \\Usage: + \\ + \\ tigerbeetle inspect [-h | --help] + \\ + \\ tigerbeetle inspect constants + \\ + \\ tigerbeetle inspect metrics + \\ + \\ tigerbeetle inspect op + \\ + \\ tigerbeetle inspect superblock + \\ + \\ tigerbeetle inspect wal [--slot=] + \\ + \\ tigerbeetle inspect replies [--slot=] + \\ + \\ tigerbeetle inspect grid [--block=
] + \\ + \\ tigerbeetle inspect manifest + \\ + \\ tigerbeetle inspect tables --tree= [--level=] + \\ + \\ tigerbeetle inspect integrity [--log-debug] [--seed=] + \\ [--memory-lsm-manifest=] + \\ [--skip-wal] [--skip-client-replies] [--skip-grid] + \\ + \\ + \\Options: + \\ + \\ When `--superblock-copy` is set, use the trailer referenced by that superblock copy. + \\ Otherwise, the current quorum will be used by default. + \\ + \\ -h, --help + \\ Print this help message and exit. + \\ + \\ constants + \\ Print most important compile-time parameters. + \\ + \\ metrics + \\ List metrics and their cardinalities. + \\ + \\ op + \\ Print op numbers for adjacent checkpoints and triggers. + \\ + \\ superblock + \\ Inspect the superblock header copies. + \\ + \\ wal + \\ Inspect the WAL headers and prepares. + \\ + \\ wal --slot= + \\ Inspect the WAL header/prepare in the given slot. + \\ + \\ replies [--superblock-copy=] + \\ Inspect the client reply headers and session numbers. + \\ + \\ replies --slot= [--superblock-copy=] + \\ Inspect a particular client reply. + \\ + \\ grid [--superblock-copy=] + \\ Inspect the free set. + \\ + \\ grid --block=
+ \\ Inspect the block at the given address. + \\ + \\ manifest [--superblock-copy=] + \\ Inspect the LSM manifest. + \\ + \\ tables --tree= [--level=] [--superblock-copy=] + \\ List the tables matching the given tree/level. + \\ Example tree names: "transfers" (object table), "transfers.amount" (index table). + \\ + \\ integrity + \\ Scans the data file and checks all internal checksums to verify internal + \\ integrity. + \\ + ; + }; + + // Internal: used to validate multiversion binaries. + const Multiversion = struct { + log_debug: bool = false, + + @"--": void, + path: []const u8, + }; + + // CDC connector for AMQP targets. + const AMQP = struct { + addresses: vsr.ClusterAddress, + cluster: u128, + host: []const u8, + user: []const u8, + password: []const u8, + vhost: []const u8, + publish_exchange: ?[]const u8 = null, + publish_routing_key: ?[]const u8 = null, + event_count_max: ?u32 = null, + idle_interval_ms: ?u32 = null, + requests_per_second_limit: ?u32 = null, + amqp_timeout_seconds: ?u32 = null, + tigerbeetle_timeout_seconds: ?u32 = null, + timestamp_last: ?u64 = null, + verbose: bool = false, + }; + + format: Format, + recover: Recover, + start: Start, + version: Version, + repl: Repl, + benchmark: Benchmark, + inspect: Inspect, + multiversion: Multiversion, + amqp: AMQP, + + // TODO Document --cache-accounts, --cache-transfers, --cache-transfers-posted, --limit-storage, + // --limit-pipeline-requests + pub const help = fmt.comptimePrint( + \\Usage: + \\ + \\ tigerbeetle [-h | --help] + \\ + \\ tigerbeetle format [--cluster=] --replica= --replica-count= + \\ + \\ tigerbeetle start --addresses= [--cache-grid=] + \\ + \\ tigerbeetle recover --cluster= --addresses= + \\ --replica= --replica-count= + \\ + \\ tigerbeetle version [--verbose] + \\ + \\ tigerbeetle repl --cluster= --addresses= + \\ + \\Commands: + \\ + \\ format Create a TigerBeetle replica data file at . + \\ The --replica and --replica-count arguments are required. + \\ Each TigerBeetle replica must have its own data file. + \\ + \\ start Run a TigerBeetle replica from the data file at . + \\ + \\ recover Create a TigerBeetle replica data file at for recovery. + \\ Used when a replica's data file is completely lost. + \\ Replicas with recovered data files must sync with the cluster before + \\ they can participate in consensus. + \\ + \\ version Print the TigerBeetle build version and the compile-time config values. + \\ + \\ repl Enter the TigerBeetle client REPL. + \\ + \\ amqp CDC connector for AMQP targets. + \\ + \\Options: + \\ + \\ -h, --help + \\ Print this help message and exit. + \\ + \\ --cluster= + \\ Set the cluster ID to the provided 128-bit unsigned decimal integer. + \\ Defaults to generating a random cluster ID. + \\ + \\ --replica= + \\ Set the zero-based index that will be used for the replica process. + \\ An index greater than or equal to "replica-count" makes the replica a standby. + \\ The value of this argument will be interpreted as an index into the --addresses array. + \\ + \\ --replica-count= + \\ Set the number of replicas participating in replication. + \\ + \\ --addresses= + \\ The addresses of all replicas in the cluster. + \\ Accepts a comma-separated list of IPv4/IPv6 addresses with port numbers. + \\ The order is significant and must match across all replicas and clients. + \\ Either the address or port number (but not both) may be omitted, + \\ in which case a default of {[default_address]s} or {[default_port]d} will be used. + \\ "addresses[i]" corresponds to replica "i". + \\ + \\ --cache-grid= + \\ Set the grid cache size. The grid cache acts like a page cache for TigerBeetle, + \\ and should be set as large as possible. + \\ On a machine running only TigerBeetle, this is somewhere around + \\ (Total RAM) - 3GiB (TigerBeetle) - 1GiB (System), eg 12GiB for a 16GiB machine. + \\ Defaults to {[default_cache_grid_gb]d}GiB. + \\ + \\ --verbose + \\ Print compile-time configuration along with the build version. + \\ + \\ --development + \\ Allow the replica to format/start/recover even when Direct IO is unavailable. + \\ Additionally, use smaller cache sizes and batch size by default. + \\ + \\ Since this shrinks the batch size, note that: + \\ * All replicas should use the same batch size. That is, if any replica in the cluster has + \\ "--development", then all replicas should have "--development". + \\ * It is always possible to increase the batch size by restarting without "--development". + \\ * Shrinking the batch size of an existing cluster is possible, but not recommended. + \\ + \\ For safety, production replicas should always enforce Direct IO -- this flag should only be + \\ used for testing and development. It should not be used for production or benchmarks. + \\ + \\Examples: + \\ + \\ tigerbeetle format --cluster=0 --replica=0 --replica-count=3 0_0.tigerbeetle + \\ tigerbeetle format --cluster=0 --replica=1 --replica-count=3 0_1.tigerbeetle + \\ tigerbeetle format --cluster=0 --replica=2 --replica-count=3 0_2.tigerbeetle + \\ + \\ tigerbeetle start --addresses=127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002 0_0.tigerbeetle + \\ tigerbeetle start --addresses=3000,3001,3002 0_1.tigerbeetle + \\ tigerbeetle start --addresses=3000,3001,3002 0_2.tigerbeetle + \\ + \\ tigerbeetle start --addresses=192.168.0.1,192.168.0.2,192.168.0.3 0_0.tigerbeetle + \\ + \\ tigerbeetle start --addresses='[::1]:3000,[::1]:3001,[::1]:3002' 0_0.tigerbeetle + \\ + \\ tigerbeetle recover --cluster=0 --addresses=3003,3001,3002 \ + \\ --replica=1 --replica-count=3 0_1.tigerbeetle + \\ + \\ tigerbeetle version --verbose + \\ + \\ tigerbeetle repl --addresses=3000,3001,3002 --cluster=0 + \\ + \\ tigerbeetle amqp --addresses=3000,3001,3002 --cluster=0 \ + \\ --host=127.0.0.1 --vhost=/ --user=guest --password=guest \ + \\ --publish-exchange=my_exchange_name + \\ + , .{ + .default_address = constants.address, + .default_port = constants.port, + .default_cache_grid_gb = @divExact( + constants.grid_cache_size_default, + GiB, + ), + }); +}; + +const StartDefaults = struct { + limit_pipeline_requests: u32, + limit_request: ByteSize, + cache_accounts: ByteSize, + cache_transfers: ByteSize, + cache_transfers_pending: ByteSize, + cache_grid: ByteSize, + memory_lsm_compaction: ByteSize, +}; + +const start_defaults_production = StartDefaults{ + .limit_pipeline_requests = vsr.stdx.div_ceil(constants.clients_max, 2) - + constants.pipeline_prepare_queue_max, + .limit_request = .{ .value = constants.message_size_max }, + .cache_accounts = .{ .value = constants.cache_accounts_size_default }, + .cache_transfers = .{ .value = constants.cache_transfers_size_default }, + .cache_transfers_pending = .{ .value = constants.cache_transfers_pending_size_default }, + .cache_grid = .{ .value = constants.grid_cache_size_default }, + // Add `lsm_compaction_iops_write_max` to allow pipelining reads of the next + // compaction in the beat with the writes of the current compaction. + .memory_lsm_compaction = .{ + .value = (lsm_compaction_block_count_min + + constants.lsm_compaction_iops_write_max) * constants.block_size, + }, +}; + +const start_defaults_development = StartDefaults{ + .limit_pipeline_requests = 0, + .limit_request = .{ .value = 32 * KiB }, + .cache_accounts = .{ .value = 0 }, + .cache_transfers = .{ .value = 0 }, + .cache_transfers_pending = .{ .value = 0 }, + .cache_grid = .{ .value = constants.block_size * Grid.Cache.value_count_max_multiple }, + .memory_lsm_compaction = .{ .value = lsm_compaction_block_memory_min }, +}; + +const lsm_compaction_block_count_min = StateMachine.Forest.Options.compaction_block_count_min; +const lsm_compaction_block_memory_min = lsm_compaction_block_count_min * constants.block_size; + +/// Invariant: Fields sum to 100. +const MemorySplit = struct { + cache_grid: u8, + cache_accounts: u8, + cache_transfers: u8, + cache_transfers_pending: u8, + const default: MemorySplit = .{ + .cache_grid = 64, + .cache_accounts = 32, + .cache_transfers = 0, + .cache_transfers_pending = 4, + }; + + comptime { + assert(default.cache_accounts + default.cache_grid + default.cache_transfers + + default.cache_transfers_pending == 100); + } +}; + +const CacheSizes = struct { + cache_grid: ByteSize, + cache_accounts: ByteSize, + cache_transfers: ByteSize, + cache_transfers_pending: ByteSize, +}; + +/// While CLIArgs store raw arguments as passed on the command line, Command ensures that arguments +/// are properly validated and desugared (e.g, sizes converted to counts where appropriate). +pub const Command = union(enum) { + const Path = stdx.BoundedArrayType(u8, std.fs.max_path_bytes); + + pub const Format = struct { + cluster: u128, + replica: u8, + replica_count: u8, + development: bool, + path: []const u8, + log_debug: bool, + }; + + pub const Recover = struct { + cluster: u128, + addresses: vsr.ClusterAddress, + replica: u8, + replica_count: u8, + development: bool, + path: []const u8, + log_debug: bool, + }; + + pub const Start = struct { + addresses: vsr.ClusterAddress, + cache_accounts: u32, + cache_transfers: u32, + cache_transfers_pending: u32, + storage_size_limit: u64, + pipeline_requests_limit: u32, + request_size_limit: u32, + cache_grid_blocks: u32, + lsm_forest_compaction_block_count: u32, + lsm_forest_node_count: u32, + timeout_prepare_ticks: ?u64, + timeout_grid_repair_message_ticks: ?u64, + commit_stall_probability: ?Ratio, + commit_stall_lag_min: ?u32, + commit_stall_lag_max: ?u32, + commit_stall_multiple_max: ?u16, + trace: ?[]const u8, + development: bool, + experimental: bool, + replicate_star: bool, + aof_file: ?Path, + aof_recovery: bool, + path: []const u8, + log_debug: bool, + log_trace: bool, + statsd: ?stdx.SocketAddress, + }; + + pub const Version = struct { + verbose: bool, + }; + + pub const Repl = struct { + addresses: vsr.ClusterAddress, + cluster: u128, + verbose: bool, + statements: []const u8, + log_debug: bool, + }; + + pub const Benchmark = struct { + /// The ID order can affect the results of a benchmark significantly. Specifically, + /// sequential is expected to be the best (since it can take advantage of various + /// optimizations such as avoiding negative prefetch) while random/reversed can't. + pub const IdOrder = enum { + // Use TBIDs (time-based IDs) for transfers and a random start for account IDs. + // Avoids ID collisions between benchmark runs against the same cluster. + // Incompatible with --validate (IDs are not deterministically replayable). + tbid, + sequential, + random, + reversed, + }; + + pub const Distribution = enum { + /// Shuffled zipfian numbers where relatively few indexes are selected frequently. + zipfian, + /// Also zipfian, but the most recent indexes are selected frequently. + latest, + /// Uniform distribution; unrealistic workloads. + uniform, + }; + + cache_accounts: ?[]const u8, + cache_transfers: ?[]const u8, + cache_transfers_pending: ?[]const u8, + cache_grid: ?[]const u8, + memory: ?[]const u8, + log_debug: bool, + log_debug_replica: bool, + account_count: u64, + account_count_hot: u32, + account_distribution: Distribution, + no_history: bool, + imported: bool, + account_batch_count: u32, + transfer_count: u64, + transfer_hot_percent: u32, + transfer_pending: bool, + transfer_batch_count: u32, + transfer_batch_delay: Duration, + validate: bool, + checksum_performance: bool, + query_count: u32, + print_batch_timings: bool, + id_order: IdOrder, + clients: u32, + statsd: ?[]const u8, + trace: ?[]const u8, + file: ?[]const u8, + addresses: ?vsr.ClusterAddress, + seed: ?[]const u8, + }; + + pub const Inspect = union(enum) { + constants, + metrics, + op: u64, + data_file: DataFile, + integrity: Integrity, + + pub const DataFile = struct { + path: []const u8, + query: union(enum) { + superblock, + wal: struct { + slot: ?usize, + }, + replies: struct { + slot: ?usize, + superblock_copy: ?u8, + }, + grid: struct { + block: ?u64, + superblock_copy: ?u8, + }, + manifest: struct { + superblock_copy: ?u8, + }, + tables: struct { + superblock_copy: ?u8, + tree: []const u8, + level: ?u6, + }, + }, + }; + + pub const Integrity = struct { + log_debug: bool, + seed: ?[]const u8, + lsm_forest_node_count: u32, + skip_wal: bool, + skip_client_replies: bool, + skip_grid: bool, + path: [:0]const u8, + }; + }; + + pub const Multiversion = struct { + path: []const u8, + log_debug: bool, + }; + + pub const AMQP = struct { + addresses: vsr.ClusterAddress, + cluster: u128, + host: stdx.SocketAddress, + user: []const u8, + password: []const u8, + vhost: []const u8, + publish_exchange: ?[]const u8, + publish_routing_key: ?[]const u8, + event_count_max: ?u32, + idle_interval_ms: ?u32, + requests_per_second_limit: ?u32, + amqp_timeout_seconds: ?u32, + tigerbeetle_timeout_seconds: ?u32, + timestamp_last: ?u64, + log_debug: bool, + }; + + format: Format, + recover: Recover, + start: Start, + version: Version, + repl: Repl, + benchmark: Benchmark, + inspect: Inspect, + multiversion: Multiversion, + amqp: AMQP, +}; + +/// Parse the command line arguments passed to the `tigerbeetle` binary. +/// Exits the program with a non-zero exit code if an error is found. +pub fn parse_args(flags: *stdx.Flags) Command { + const cli_args = flags.parse(CLIArgs); + + return switch (cli_args) { + .format => |format| .{ .format = parse_args_format(format) }, + .recover => |recover| .{ .recover = parse_args_recover(recover) }, + .start => |start| .{ .start = parse_args_start(start) }, + .version => |version| .{ .version = parse_args_version(version) }, + .repl => |repl| .{ .repl = parse_args_repl(repl) }, + .benchmark => |benchmark| .{ .benchmark = parse_args_benchmark(benchmark) }, + .inspect => |inspect| .{ .inspect = parse_args_inspect(inspect) }, + .multiversion => |multiversion| .{ .multiversion = parse_args_multiversion(multiversion) }, + .amqp => |amqp| .{ .amqp = parse_args_amqp(amqp) }, + }; +} + +fn parse_args_format(format: CLIArgs.Format) Command.Format { + if (format.replica_count == 0) { + vsr.fatal(.cli, "--replica-count: value needs to be greater than zero", .{}); + } + if (format.replica_count > constants.replicas_max) { + vsr.fatal(.cli, "--replica-count: value is too large ({}), at most {} is allowed", .{ + format.replica_count, + constants.replicas_max, + }); + } + + if (format.replica == null and format.standby == null) { + vsr.fatal(.cli, "--replica: argument is required", .{}); + } + + if (format.replica != null and format.standby != null) { + vsr.fatal(.cli, "--standby: conflicts with '--replica'", .{}); + } + + if (format.replica) |replica| { + if (replica >= format.replica_count) { + vsr.fatal(.cli, "--replica: value is too large ({}), at most {} is allowed", .{ + replica, + format.replica_count - 1, + }); + } + } + + if (format.standby) |standby| { + if (standby < format.replica_count) { + vsr.fatal(.cli, "--standby: value is too small ({}), at least {} is required", .{ + standby, + format.replica_count, + }); + } + if (standby >= format.replica_count + constants.standbys_max) { + vsr.fatal(.cli, "--standby: value is too large ({}), at most {} is allowed", .{ + standby, + format.replica_count + constants.standbys_max - 1, + }); + } + } + + const replica = (format.replica orelse format.standby).?; + assert(replica < constants.members_max); + assert(replica < format.replica_count + constants.standbys_max); + + const cluster_random = std.crypto.random.int(u128); + assert(cluster_random != 0); + const cluster = format.cluster orelse cluster_random; + if (format.cluster == null) { + std.log.info("generated random cluster id: {}\n", .{cluster}); + } else if (format.cluster.? == 0) { + std.log.warn("a cluster id of 0 is reserved for testing and benchmarking, " ++ + "do not use in production", .{}); + std.log.warn("omit --cluster=0 to randomly generate a suitable id\n", .{}); + } + + return .{ + .cluster = cluster, // just an ID, any value is allowed + .replica = replica, + .replica_count = format.replica_count, + .development = format.development, + .path = format.path, + .log_debug = format.log_debug, + }; +} + +fn parse_args_recover(recover: CLIArgs.Recover) Command.Recover { + if (recover.replica_count == 0) { + vsr.fatal(.cli, "--replica-count: value needs to be greater than zero", .{}); + } + if (recover.replica_count > constants.replicas_max) { + vsr.fatal(.cli, "--replica-count: value is too large ({}), at most {} is allowed", .{ + recover.replica_count, + constants.replicas_max, + }); + } + + if (recover.replica >= recover.replica_count) { + vsr.fatal(.cli, "--replica: value is too large ({}), at most {} is allowed", .{ + recover.replica, + recover.replica_count - 1, + }); + } + if (recover.replica_count <= 2) { + vsr.fatal(.cli, "--replica-count: 1- or 2- replica clusters don't support 'recover'", .{}); + } + + const replica = recover.replica; + assert(replica < constants.members_max); + assert(replica < recover.replica_count); + + return .{ + .cluster = recover.cluster, + .addresses = recover.addresses, + .replica = replica, + .replica_count = recover.replica_count, + .development = recover.development, + .path = recover.path, + .log_debug = recover.log_debug, + }; +} + +fn parse_args_start(start: CLIArgs.Start) Command.Start { + // Allowlist of stable flags. --development will disable automatic multiversion + // upgrades too, but the flag itself is stable. + const stable_args = .{ + "addresses", "cache_grid", + "development", "experimental", + }; + inline for (std.meta.fields(@TypeOf(start))) |field| { + @setEvalBranchQuota(4_000); + // Positional arguments can't be experimental. + comptime if (std.mem.eql(u8, field.name, "--")) break; + + const stable_field = comptime for (stable_args) |stable_arg| { + assert(std.meta.fieldIndex(@TypeOf(start), stable_arg) != null); + if (std.mem.eql(u8, field.name, stable_arg)) { + break true; + } + } else false; + if (stable_field) continue; + + const flag_name = comptime blk: { + var result: [2 + field.name.len]u8 = ("--" ++ field.name).*; + std.mem.replaceScalar(u8, &result, '_', '-'); + break :blk result; + }; + + // If you've added a flag and get a comptime error here, it's likely because + // we require experimental flags to default to null. + const required_default = if (field.type == bool) false else null; + assert(field.defaultValue().? == required_default); + + if (@field(start, field.name) != required_default and !start.experimental) { + vsr.fatal( + .cli, + "{s} is marked experimental, add `--experimental` to continue.", + .{flag_name}, + ); + } + } else unreachable; + + const groove_config = StateMachine.Forest.groove_config; + const AccountsValuesCache = groove_config.accounts.ObjectsCache.Cache; + const TransfersValuesCache = groove_config.transfers.ObjectsCache.Cache; + const TransfersPendingValuesCache = groove_config.transfers_pending.ObjectsCache.Cache; + + const defaults = + if (start.development) start_defaults_development else start_defaults_production; + + if (start.memory != null) { + inline for (.{ + .{ start.cache_grid, "--cache-grid" }, + .{ start.cache_accounts, "--cache-accounts" }, + .{ start.cache_transfers, "--cache-transfers" }, + .{ start.cache_transfers_pending, "--cache-transfers-pending" }, + }) |cache_arg| { + if (cache_arg[0] != null) { + vsr.fatal(.cli, "--memory is mutually exclusive with {s}", .{cache_arg[1]}); + } + } + } + + const cache_sizes = if (start.memory) |memory| + memory_split_cache_sizes(memory, MemorySplit.default) + else + CacheSizes{ + .cache_grid = start.cache_grid orelse defaults.cache_grid, + .cache_accounts = start.cache_accounts orelse defaults.cache_accounts, + .cache_transfers = start.cache_transfers orelse defaults.cache_transfers, + .cache_transfers_pending = start.cache_transfers_pending orelse + defaults.cache_transfers_pending, + }; + + const start_limit_storage: ByteSize = start.limit_storage orelse + .{ .value = constants.storage_size_limit_default }; + const start_memory_lsm_manifest: ByteSize = start.memory_lsm_manifest orelse + .{ .value = constants.lsm_manifest_memory_size_default }; + + const storage_size_limit = start_limit_storage.bytes(); + const storage_size_limit_min = data_file_size_min; + const storage_size_limit_max = constants.storage_size_limit_max; + if (storage_size_limit > storage_size_limit_max) { + vsr.fatal(.cli, "--limit-storage: size {}{s} exceeds maximum: {}", .{ + start_limit_storage.value, + start_limit_storage.suffix(), + vsr.stdx.fmt_int_size_bin_exact(storage_size_limit_max), + }); + } + if (storage_size_limit < storage_size_limit_min) { + vsr.fatal(.cli, "--limit-storage: size {}{s} is below minimum: {}", .{ + start_limit_storage.value, + start_limit_storage.suffix(), + vsr.stdx.fmt_int_size_bin_exact(storage_size_limit_min), + }); + } + if (storage_size_limit % constants.sector_size != 0) { + vsr.fatal( + .cli, + "--limit-storage: size {}{s} must be a multiple of sector size ({})", + .{ + start_limit_storage.value, + start_limit_storage.suffix(), + vsr.stdx.fmt_int_size_bin_exact(constants.sector_size), + }, + ); + } + + const pipeline_limit = + start.limit_pipeline_requests orelse defaults.limit_pipeline_requests; + const pipeline_limit_min = 0; + const pipeline_limit_max = constants.pipeline_request_queue_max; + if (pipeline_limit > pipeline_limit_max) { + vsr.fatal(.cli, "--limit-pipeline-requests: count {} exceeds maximum: {}", .{ + pipeline_limit, + pipeline_limit_max, + }); + } + if (pipeline_limit < pipeline_limit_min) { + vsr.fatal(.cli, "--limit-pipeline-requests: count {} is below minimum: {}", .{ + pipeline_limit, + pipeline_limit_min, + }); + } + + // The minimum is chosen rather arbitrarily as 4096 since it is the sector size. + const request_size_limit = start.limit_request orelse defaults.limit_request; + const request_size_limit_min = 4096; + const request_size_limit_max = constants.message_size_max; + if (request_size_limit.bytes() > request_size_limit_max) { + vsr.fatal(.cli, "--limit-request: size {}{s} exceeds maximum: {}", .{ + request_size_limit.value, + request_size_limit.suffix(), + vsr.stdx.fmt_int_size_bin_exact(request_size_limit_max), + }); + } + if (request_size_limit.bytes() < request_size_limit_min) { + vsr.fatal(.cli, "--limit-request: size {}{s} is below minimum: {}", .{ + request_size_limit.value, + request_size_limit.suffix(), + vsr.stdx.fmt_int_size_bin_exact(request_size_limit_min), + }); + } + + const lsm_manifest_memory = start_memory_lsm_manifest.bytes(); + const lsm_manifest_memory_max = constants.lsm_manifest_memory_size_max; + const lsm_manifest_memory_min = constants.lsm_manifest_memory_size_min; + const lsm_manifest_memory_multiplier = constants.lsm_manifest_memory_size_multiplier; + if (lsm_manifest_memory > lsm_manifest_memory_max) { + vsr.fatal(.cli, "--memory-lsm-manifest: size {}{s} exceeds maximum: {}", .{ + start_memory_lsm_manifest.value, + start_memory_lsm_manifest.suffix(), + vsr.stdx.fmt_int_size_bin_exact(lsm_manifest_memory_max), + }); + } + if (lsm_manifest_memory < lsm_manifest_memory_min) { + vsr.fatal(.cli, "--memory-lsm-manifest: size {}{s} is below minimum: {}", .{ + start_memory_lsm_manifest.value, + start_memory_lsm_manifest.suffix(), + vsr.stdx.fmt_int_size_bin_exact(lsm_manifest_memory_min), + }); + } + if (lsm_manifest_memory % lsm_manifest_memory_multiplier != 0) { + vsr.fatal( + .cli, + "--memory-lsm-manifest: size {}{s} must be a multiple of {}", + .{ + start_memory_lsm_manifest.value, + start_memory_lsm_manifest.suffix(), + vsr.stdx.fmt_int_size_bin_exact(lsm_manifest_memory_multiplier), + }, + ); + } + + const lsm_compaction_block_memory = + start.memory_lsm_compaction orelse defaults.memory_lsm_compaction; + const lsm_compaction_block_memory_max = constants.compaction_block_memory_size_max; + if (lsm_compaction_block_memory.bytes() > lsm_compaction_block_memory_max) { + vsr.fatal(.cli, "--memory-lsm-compaction: size {}{s} exceeds maximum: {}", .{ + lsm_compaction_block_memory.value, + lsm_compaction_block_memory.suffix(), + vsr.stdx.fmt_int_size_bin_exact(lsm_compaction_block_memory_max), + }); + } + if (lsm_compaction_block_memory.bytes() < lsm_compaction_block_memory_min) { + vsr.fatal(.cli, "--memory-lsm-compaction: size {}{s} is below minimum: {}", .{ + lsm_compaction_block_memory.value, + lsm_compaction_block_memory.suffix(), + vsr.stdx.fmt_int_size_bin_exact(lsm_compaction_block_memory_min), + }); + } + if (lsm_compaction_block_memory.bytes() % constants.block_size != 0) { + vsr.fatal( + .cli, + "--memory-lsm-compaction: size {}{s} must be a multiple of {}", + .{ + lsm_compaction_block_memory.value, + lsm_compaction_block_memory.suffix(), + vsr.stdx.fmt_int_size_bin_exact(constants.block_size), + }, + ); + } + + const lsm_forest_compaction_block_count: u32 = + @intCast(@divExact(lsm_compaction_block_memory.bytes(), constants.block_size)); + const lsm_forest_node_count: u32 = + @intCast(@divExact(lsm_manifest_memory, constants.lsm_manifest_node_size)); + + const aof_file: ?Command.Path = if (start.aof) blk: { + if (start.aof_file != null) { + vsr.fatal(.cli, "--aof is mutually exclusive with --aof-file", .{}); + } + + var aof_file: Command.Path = .{}; + if (aof_file.capacity() < start.path.len + 4) { + vsr.fatal(.cli, "data file path is too long for --aof. use --aof-file", .{}); + } + aof_file.push_slice(start.path); + aof_file.push_slice(".aof"); + + std.log.warn( + "--aof is deprecated. consider switching to '--aof-file={s}'", + .{aof_file.const_slice()}, + ); + + break :blk aof_file; + } else if (start.aof_file) |start_aof_file| blk: { + if (!std.mem.endsWith(u8, start_aof_file, ".aof")) { + vsr.fatal(.cli, "--aof-file must end with .aof: '{s}'", .{start_aof_file}); + } + + var aof_file: Command.Path = .{}; + if (aof_file.capacity() < start.path.len) { + vsr.fatal(.cli, "--aof-file path is too long", .{}); + } + aof_file.push_slice(start_aof_file); + + break :blk aof_file; + } else null; + + if (start.log_trace and !start.log_debug) { + vsr.fatal(.cli, "--log-debug must be provided when using --log-trace", .{}); + } + + if (start.replicate_star) { + std.log.warn( + "--replicate-star is deprecated; star replication is now the default.", + .{}, + ); + } + + return .{ + .addresses = start.addresses, + .storage_size_limit = storage_size_limit, + .pipeline_requests_limit = pipeline_limit, + .request_size_limit = @intCast(request_size_limit.bytes()), + .cache_accounts = parse_cache_size_to_count( + tigerbeetle.Account, + AccountsValuesCache, + cache_sizes.cache_accounts, + "--cache-accounts", + ), + .cache_transfers = parse_cache_size_to_count( + tigerbeetle.Transfer, + TransfersValuesCache, + cache_sizes.cache_transfers, + "--cache-transfers", + ), + .cache_transfers_pending = parse_cache_size_to_count( + vsr.state_machine.TransferPending, + TransfersPendingValuesCache, + cache_sizes.cache_transfers_pending, + "--cache-transfers-pending", + ), + .cache_grid_blocks = parse_cache_size_to_count( + [constants.block_size]u8, + Grid.Cache, + cache_sizes.cache_grid, + "--cache-grid", + ), + .lsm_forest_compaction_block_count = lsm_forest_compaction_block_count, + .lsm_forest_node_count = lsm_forest_node_count, + .timeout_prepare_ticks = parse_timeout_to_ticks( + start.timeout_prepare_ms, + "--timeout-prepare-ms", + ), + .timeout_grid_repair_message_ticks = parse_timeout_to_ticks( + start.timeout_grid_repair_message_ms, + "--timeout-grid-repair-message-ms", + ), + .commit_stall_probability = start.commit_stall_probability, + .commit_stall_lag_min = start.commit_stall_lag_min, + .commit_stall_lag_max = start.commit_stall_lag_max, + .commit_stall_multiple_max = start.commit_stall_multiple_max, + .development = start.development, + .experimental = start.experimental, + .trace = start.trace, + .replicate_star = start.replicate_star, + .aof_file = aof_file, + .aof_recovery = start.aof_recovery, + .path = start.path, + .log_debug = start.log_debug, + .log_trace = start.log_trace, + .statsd = if (start.statsd) |statsd_address| + parse_address_and_port(statsd_address, "--statsd", 8125) + else + null, + }; +} + +fn parse_args_version(version: CLIArgs.Version) Command.Version { + return .{ + .verbose = version.verbose, + }; +} + +fn parse_args_repl(repl: CLIArgs.Repl) Command.Repl { + return .{ + .addresses = repl.addresses, + .cluster = repl.cluster, + .verbose = repl.verbose, + .statements = repl.command, + .log_debug = repl.log_debug, + }; +} + +const account_batch_count_max = @divExact( + constants.message_size_max - @sizeOf(vsr.Header), + @sizeOf(tigerbeetle.Account), +); + +const transfer_batch_count_max = @divExact( + constants.message_size_max - @sizeOf(vsr.Header), + @sizeOf(tigerbeetle.Transfer), +); + +fn parse_args_benchmark(benchmark: CLIArgs.Benchmark) Command.Benchmark { + if (benchmark.addresses != null and benchmark.file != null) { + vsr.fatal(.cli, "--file: --addresses and --file are mutually exclusive", .{}); + } + + if (benchmark.account_batch_count == 0) { + vsr.fatal(.cli, "--account-batch-count must be greater than 0", .{}); + } + + if (benchmark.account_batch_count > account_batch_count_max) { + vsr.fatal( + .cli, + "--account-batch-count must be less than or equal to {}", + .{account_batch_count_max}, + ); + } + + if (benchmark.transfer_batch_count == 0) { + vsr.fatal(.cli, "--transfer-batch-count must be greater than 0", .{}); + } + + if (benchmark.transfer_batch_count > transfer_batch_count_max) { + vsr.fatal( + .cli, + "--transfer-batch-count must be less than or equal to {}", + .{transfer_batch_count_max}, + ); + } + + return .{ + .cache_accounts = benchmark.cache_accounts, + .cache_transfers = benchmark.cache_transfers, + .cache_transfers_pending = benchmark.cache_transfers_pending, + .cache_grid = benchmark.cache_grid, + .memory = benchmark.memory, + .log_debug = benchmark.log_debug, + .log_debug_replica = benchmark.log_debug_replica, + .account_count = benchmark.account_count, + .account_count_hot = benchmark.account_count_hot, + .account_distribution = benchmark.account_distribution, + .no_history = benchmark.no_history, + .imported = benchmark.imported, + .account_batch_count = benchmark.account_batch_count, + .transfer_count = benchmark.transfer_count, + .transfer_hot_percent = benchmark.transfer_hot_percent, + .transfer_pending = benchmark.transfer_pending, + .transfer_batch_count = benchmark.transfer_batch_count, + .transfer_batch_delay = benchmark.transfer_batch_delay, + .validate = benchmark.validate, + .checksum_performance = benchmark.checksum_performance, + .query_count = benchmark.query_count, + .print_batch_timings = benchmark.print_batch_timings, + .clients = benchmark.clients, + .id_order = benchmark.id_order, + .statsd = benchmark.statsd, + .trace = benchmark.trace, + .file = benchmark.file, + .addresses = benchmark.addresses, + .seed = benchmark.seed, + }; +} + +fn parse_args_inspect_integrity(args: CLIArgs.Inspect) Command.Inspect.Integrity { + const integrity = args.integrity; + + const scrub_memory_lsm_manifest: ByteSize = integrity.memory_lsm_manifest orelse + .{ .value = constants.lsm_manifest_memory_size_default }; + + const lsm_manifest_memory = scrub_memory_lsm_manifest.bytes(); + const lsm_manifest_memory_max = constants.lsm_manifest_memory_size_max; + const lsm_manifest_memory_min = constants.lsm_manifest_memory_size_min; + const lsm_manifest_memory_multiplier = constants.lsm_manifest_memory_size_multiplier; + if (lsm_manifest_memory > lsm_manifest_memory_max) { + vsr.fatal(.cli, "--memory-lsm-manifest: size {}{s} exceeds maximum: {}", .{ + scrub_memory_lsm_manifest.value, + scrub_memory_lsm_manifest.suffix(), + vsr.stdx.fmt_int_size_bin_exact(lsm_manifest_memory_max), + }); + } + if (lsm_manifest_memory < lsm_manifest_memory_min) { + vsr.fatal(.cli, "--memory-lsm-manifest: size {}{s} is below minimum: {}", .{ + scrub_memory_lsm_manifest.value, + scrub_memory_lsm_manifest.suffix(), + vsr.stdx.fmt_int_size_bin_exact(lsm_manifest_memory_min), + }); + } + if (lsm_manifest_memory % lsm_manifest_memory_multiplier != 0) { + vsr.fatal( + .cli, + "--memory-lsm-manifest: size {}{s} must be a multiple of {}", + .{ + scrub_memory_lsm_manifest.value, + scrub_memory_lsm_manifest.suffix(), + vsr.stdx.fmt_int_size_bin_exact(lsm_manifest_memory_multiplier), + }, + ); + } + + const lsm_forest_node_count: u32 = + @intCast(@divExact(lsm_manifest_memory, constants.lsm_manifest_node_size)); + + return .{ + .path = integrity.path, + .log_debug = integrity.log_debug, + .seed = integrity.seed, + .skip_wal = integrity.skip_wal, + .skip_client_replies = integrity.skip_client_replies, + .skip_grid = integrity.skip_grid, + .lsm_forest_node_count = lsm_forest_node_count, + }; +} + +fn parse_args_inspect(inspect: CLIArgs.Inspect) Command.Inspect { + const path = switch (inspect) { + .constants => return .constants, + .metrics => return .metrics, + .op => |args| return .{ .op = args.op }, + .integrity => return .{ .integrity = parse_args_inspect_integrity(inspect) }, + inline else => |args| args.path, + }; + + return .{ .data_file = .{ + .path = path, + .query = switch (inspect) { + .constants, + .metrics, + .op, + .integrity, + => unreachable, + .superblock => .superblock, + .wal => |args| .{ .wal = .{ .slot = args.slot } }, + .replies => |args| .{ .replies = .{ + .slot = args.slot, + .superblock_copy = args.superblock_copy, + } }, + .grid => |args| .{ .grid = .{ + .block = args.block, + .superblock_copy = args.superblock_copy, + } }, + .manifest => |args| .{ .manifest = .{ + .superblock_copy = args.superblock_copy, + } }, + .tables => |args| .{ .tables = .{ + .superblock_copy = args.superblock_copy, + .tree = args.tree, + .level = args.level, + } }, + }, + } }; +} + +fn parse_args_multiversion(multiversion: CLIArgs.Multiversion) Command.Multiversion { + return .{ + .path = multiversion.path, + .log_debug = multiversion.log_debug, + }; +} + +fn parse_args_amqp(amqp: CLIArgs.AMQP) Command.AMQP { + const host = parse_address_and_port( + amqp.host, + "--host", + vsr.cdc.amqp.tcp_port_default, + ); + + if (amqp.publish_exchange == null and amqp.publish_routing_key == null) { + vsr.fatal( + .cli, + "--publish-exchange and --publish-routing-key cannot both be empty.", + .{}, + ); + } + + if (amqp.requests_per_second_limit) |requests_per_second_limit| { + if (requests_per_second_limit == 0) { + vsr.fatal( + .cli, + "--requests-per-second-limit must not be zero.", + .{}, + ); + } + } + + if (amqp.idle_interval_ms) |idle_interval_ms| { + if (idle_interval_ms == 0) { + vsr.fatal( + .cli, + "--idle-interval-ms must not be zero.", + .{}, + ); + } + } + + if (amqp.amqp_timeout_seconds) |amqp_timeout_seconds| { + if (amqp_timeout_seconds == 0) { + vsr.fatal( + .cli, + "--amqp-timeout-seconds must not be zero.", + .{}, + ); + } + } + + if (amqp.tigerbeetle_timeout_seconds) |tigerbeetle_timeout_seconds| { + if (tigerbeetle_timeout_seconds == 0) { + vsr.fatal( + .cli, + "--tigerbeetle-timeout-seconds must not be zero.", + .{}, + ); + } + } + + return .{ + .addresses = amqp.addresses, + .cluster = amqp.cluster, + .host = host, + .user = amqp.user, + .password = amqp.password, + .vhost = amqp.vhost, + .publish_exchange = amqp.publish_exchange, + .publish_routing_key = amqp.publish_routing_key, + .event_count_max = amqp.event_count_max, + .idle_interval_ms = amqp.idle_interval_ms, + .requests_per_second_limit = amqp.requests_per_second_limit, + .timestamp_last = amqp.timestamp_last, + .amqp_timeout_seconds = amqp.amqp_timeout_seconds, + .tigerbeetle_timeout_seconds = amqp.tigerbeetle_timeout_seconds, + .log_debug = amqp.verbose, + }; +} + +fn parse_address_and_port( + raw_address: []const u8, + comptime flag: []const u8, + port_default: u16, +) stdx.SocketAddress { + comptime assert(std.mem.startsWith(u8, flag, "--")); + + return vsr.parse_address_and_port(.{ + .string = raw_address, + .port_default = port_default, + }) catch |err| switch (err) { + error.AddressHasMoreThanOneColon => { + vsr.fatal(.cli, flag ++ ": invalid address with more than one colon", .{}); + }, + error.PortInvalid => vsr.fatal(.cli, flag ++ ": invalid port", .{}), + error.AddressInvalid => vsr.fatal(.cli, flag ++ ": invalid IPv4 or IPv6 address", .{}), + }; +} + +/// Given a limit like `10GiB`, a SetAssociativeCache and T return the largest `value_count_max` +/// that can fit in the limit. +fn parse_cache_size_to_count( + comptime T: type, + comptime SetAssociativeCache: type, + size: ByteSize, + cli_flag: []const u8, +) u32 { + const value_count_max_multiple = SetAssociativeCache.value_count_max_multiple; + + const count_limit = @divFloor(size.bytes(), @sizeOf(T)); + const count_rounded = @divFloor( + count_limit, + value_count_max_multiple, + ) * value_count_max_multiple; + + if (count_rounded > std.math.maxInt(u32)) { + vsr.fatal(.cli, "{s}: exceeds the limit", .{cli_flag}); + } + + const result: u32 = @intCast(count_rounded); + assert(@as(u64, result) * @sizeOf(T) <= size.bytes()); + + return result; +} + +fn memory_split_cache_sizes(memory: ByteSize, split: MemorySplit) CacheSizes { + assert(split.cache_accounts + split.cache_grid + split.cache_transfers + + split.cache_transfers_pending == 100); + + const memory_bytes = memory.bytes(); + + return .{ + .cache_grid = .{ .value = memory_split_bytes(memory_bytes, split.cache_grid) }, + .cache_accounts = .{ .value = memory_split_bytes(memory_bytes, split.cache_accounts) }, + .cache_transfers = .{ .value = memory_split_bytes(memory_bytes, split.cache_transfers) }, + .cache_transfers_pending = .{ + .value = memory_split_bytes(memory_bytes, split.cache_transfers_pending), + }, + }; +} + +fn memory_split_bytes(memory_bytes: u64, percent: u8) u64 { + assert(percent <= 100); + return @intCast(@divFloor(@as(u128, memory_bytes) * percent, 100)); +} + +fn parse_timeout_to_ticks(timeout_ms: ?u64, cli_flag: []const u8) ?u64 { + if (timeout_ms) |ms| { + if (ms == 0) { + vsr.fatal(.cli, "{s}: timeout {}ms be nonzero", .{ cli_flag, ms }); + } + + if (ms % constants.tick_ms != 0) { + vsr.fatal( + .cli, + "{s}: timeout {}ms must be a multiple of {}ms", + .{ cli_flag, ms, constants.tick_ms }, + ); + } + + return @divExact(ms, constants.tick_ms); + } else { + return null; + } +} diff --git a/ocam/src/tigerbeetle/inspect.zig b/ocam/src/tigerbeetle/inspect.zig new file mode 100644 index 00000000..95adb935 --- /dev/null +++ b/ocam/src/tigerbeetle/inspect.zig @@ -0,0 +1,1782 @@ +//! Decode a TigerBeetle data file without running a replica or modifying the data file. +//! +//! This tool is intended for TigerBeetle developers, for debugging and understanding data files. +//! +//! Principles: +//! - Never modify the data file. +//! - Adhere to the "be liberal in what you accept" side of Postel's Law. +//! When the data file is corrupt, decode as much as possible. (This is somewhat aspirational). +//! - Outside of the "summary" commands, don't discard potentially useful information. + +const std = @import("std"); +const assert = std.debug.assert; +const log = std.log.scoped(.inspect); + +const cli = @import("cli.zig"); +const vsr = @import("vsr"); +const stdx = vsr.stdx; +const schema = vsr.lsm.schema; +const constants = vsr.constants; +const IO = vsr.io.IO; +const Tracer = vsr.trace.Tracer; +const Storage = @import("main.zig").Storage; +const SuperBlockHeader = vsr.superblock.SuperBlockHeader; +const SuperBlockVersion = vsr.superblock.SuperBlockVersion; +const SuperBlockQuorums = vsr.superblock.Quorums; +const StateMachine = @import("main.zig").StateMachine; +const BlockPtr = vsr.grid.BlockPtr; +const BlockPtrConst = vsr.grid.BlockPtrConst; +const is_composite_key = vsr.lsm.composite_key.is_composite_key; + +const EventMetric = vsr.trace.EventMetric; +const EventMetricAggregate = vsr.trace.EventMetricAggregate; +const EventTiming = vsr.trace.EventTiming; +const EventTimingAggregate = vsr.trace.EventTimingAggregate; +const command_inspect_integrity = @import("inspect_integrity.zig").command_inspect_integrity; + +pub fn command_inspect( + allocator: std.mem.Allocator, + io: *IO, + tracer: *Tracer, + cli_args: *const cli.Command.Inspect, +) !void { + var stdout_buffer = std.io.bufferedWriter(std.io.getStdOut().writer()); + var stdout_writer = stdout_buffer.writer(); + + const inspect_result = run_inspect(allocator, io, tracer, cli_args, stdout_writer.any()); + const flush_result = stdout_buffer.flush(); + + inline for (.{ inspect_result, flush_result }) |result| { + result catch |err| switch (err) { + // Ignore BrokenPipe so that e.g. "tigerbeetle inspect ... | head -n12" succeeds. + error.BrokenPipe => {}, + else => return err, + }; + } +} + +fn run_inspect( + allocator: std.mem.Allocator, + io: *IO, + tracer: *Tracer, + cli_args: *const cli.Command.Inspect, + stdout: std.io.AnyWriter, +) !void { + const data_file = switch (cli_args.*) { + .constants => return try inspect_constants(stdout), + .metrics => return try inspect_metrics(stdout), + .op => |op| return try inspect_op(stdout, op), + .integrity => |*args| return try command_inspect_integrity(allocator, io, tracer, args), + .data_file => |data_file| data_file, + }; + + const inspector = try Inspector.create(allocator, io, tracer, data_file.path); + defer inspector.destroy(); + + switch (data_file.query) { + .superblock => try inspector.inspect_superblock(stdout), + .wal => |args| { + if (args.slot) |slot| { + if (slot >= constants.journal_slot_count) { + return vsr.fatal( + .cli, + "--slot: slot exceeds {}", + .{constants.journal_slot_count - 1}, + ); + } + try inspector.inspect_wal_slot(stdout, slot); + } else { + try inspector.inspect_wal(stdout); + } + }, + .replies => |args| { + if (args.slot) |slot| { + if (slot >= constants.clients_max) { + return vsr.fatal(.cli, "--slot: slot exceeds {}", .{constants.clients_max - 1}); + } + try inspector.inspect_replies_slot(stdout, args.superblock_copy, slot); + } else { + try inspector.inspect_replies(stdout, args.superblock_copy); + } + }, + .grid => |args| { + if (args.superblock_copy != null and + args.superblock_copy.? >= constants.superblock_copies) + { + return vsr.fatal( + .cli, + "--superblock-copy: copy exceeds {}", + .{constants.superblock_copies - 1}, + ); + } + + if (args.block) |address| { + try inspector.inspect_grid_block(stdout, address); + } else { + try inspector.inspect_grid(stdout, args.superblock_copy); + } + }, + .manifest => |args| { + if (args.superblock_copy != null and + args.superblock_copy.? >= constants.superblock_copies) + { + return vsr.fatal( + .cli, + "--superblock-copy: copy exceeds {}", + .{constants.superblock_copies - 1}, + ); + } + + try inspector.inspect_manifest(stdout, args.superblock_copy); + }, + .tables => |args| { + if (args.superblock_copy != null and + args.superblock_copy.? >= constants.superblock_copies) + { + return vsr.fatal( + .cli, + "--superblock-copy: copy exceeds {}", + .{constants.superblock_copies - 1}, + ); + } + + const tree_id = parse_tree_id(args.tree) orelse { + return vsr.fatal(.cli, "--tree: invalid tree name/id: {s}", .{args.tree}); + }; + try inspector.inspect_tables(stdout, args.superblock_copy, .{ + .tree_id = tree_id, + .level = args.level, + }); + }, + } +} + +fn inspect_constants(output: std.io.AnyWriter) !void { + try output.print("VSR:\n", .{}); + try print_header(output, 0, "prepare_queue"); + try output.print("{}\n", .{constants.pipeline_prepare_queue_max}); + try print_header(output, 0, "request_queue"); + try output.print("{}\n", .{constants.pipeline_request_queue_max}); + try print_header(output, 0, "prepare_cache"); + try output.print("{}\n", .{ + constants.pipeline_prepare_queue_max + constants.pipeline_request_queue_max, + }); + try output.print("\n", .{}); + + const checkpoint = vsr.Checkpoint.checkpoint_after(0); + const trigger = vsr.Checkpoint.trigger_for_checkpoint(checkpoint).?; + + try output.print("LSM:\n", .{}); + + try print_header(output, 0, "compaction_ops"); + try output.print("{}\n", .{constants.lsm_compaction_ops}); + + try print_header(output, 0, "tree_table_count_max"); + try output.print("{}\n", .{vsr.lsm.tree.table_count_max}); + + try print_header(output, 0, "forest_table_count_max"); + try output.print("{}\n", .{vsr.lsm.forest.table_count_max}); + + try output.print("\n", .{}); + + try output.print("Checkpoint Schedule:\n", .{}); + const prepare_ok_max = trigger + constants.pipeline_prepare_queue_max; + const prepare_max = vsr.Checkpoint.prepare_max_for_checkpoint(checkpoint).?; + const checkpoint_next = vsr.Checkpoint.checkpoint_after(checkpoint); + const checkpoint_points = .{ + .checkpoint = checkpoint, + .trigger = trigger, + .prepare_ok_max = prepare_ok_max, + .prepare_max = prepare_max, + .checkpoint_next = checkpoint_next, + }; + try output.print( + "{s: <20}{s: <20}{s: <20}{s: <20}{s: <20}{s: <20}{s}\n", + .{ + "checkpoint_ops", + "journal_slot_count", + "checkpoint", + "trigger", + "prepare_ok_max", + "prepare_max", + "checkpoint_next", + }, + ); + try output.print("{d: <20}{d: <20}", .{ + constants.vsr_checkpoint_ops, + constants.journal_slot_count, + }); + try output.print( + "{d: <15}+{d: <4}{d: <15}+{d: <4}{d: <15}+{d: <4}{d: <15}+{d: <4}{}\n", + .{ + checkpoint_points.checkpoint, + checkpoint_points.trigger - checkpoint_points.checkpoint, + checkpoint_points.trigger, + checkpoint_points.prepare_ok_max - checkpoint_points.trigger, + checkpoint_points.prepare_ok_max, + checkpoint_points.prepare_max - checkpoint_points.prepare_ok_max, + checkpoint_points.prepare_max, + checkpoint_points.checkpoint_next - checkpoint_points.prepare_max, + checkpoint_points.checkpoint_next, + }, + ); + try output.print("\n", .{}); + + try output.print("Data File Layout:\n", .{}); + inline for (comptime std.enums.values(vsr.Zone)) |zone| { + try print_header(output, 0, @tagName(zone)); + switch (zone) { + inline else => |zone_sized| { + try print_size_count( + output, + zone_sized.size().?, + 1, + ); + }, + .grid => { + try output.print("elastic\n", .{}); + }, + } + switch (zone) { + .superblock => { + try print_header(output, 1, "copy"); + try print_size_count( + output, + vsr.superblock.superblock_copy_size, + constants.superblock_copies, + ); + }, + .wal_headers => { + try print_header(output, 1, "sector"); + try print_size_count( + output, + constants.sector_size, + @divExact(vsr.Zone.wal_headers.size().?, constants.sector_size), + ); + + try print_header(output, 2, "header"); + try print_size_count( + output, + @sizeOf(vsr.Header), + @divExact(constants.sector_size, @sizeOf(vsr.Header)), + ); + }, + .wal_prepares => { + try print_header(output, 1, "prepare"); + try print_size_count( + output, + constants.message_size_max, + constants.journal_slot_count, + ); + }, + .client_replies => { + try print_header(output, 1, "reply"); + try print_size_count( + output, + constants.message_size_max, + constants.clients_max, + ); + }, + .grid => { + try print_header(output, 1, "block"); + try print_size_count(output, constants.block_size, 1); + }, + else => {}, + } + try output.print("\n", .{}); + } + + // Print the size required to store each object + indexes. + try output.print("StateMachine:\n", .{}); + try print_objects(output); + + // Memory usage is intentionally estimated from constants, rather than measured, to sanity + // check that our observed memory usage is reasonable. + try output.print("Memory (approximate):\n", .{}); + const datafile_size = constants.storage_size_limit_max; + try print_header(output, 0, "datafile (on disk)"); + try output.print("{}\n", .{ + stdx.fmt_int_size_bin_exact(datafile_size), + }); + + { + const grid_size_limit = datafile_size - vsr.superblock.data_file_size_min; + const blocks_count = vsr.FreeSet.block_count_max(grid_size_limit); + const ewah = vsr.ewah(vsr.FreeSet.Word); + + // 2x since both `blocks_acquired` and `blocks_released` are encoded. + const free_set_encoded_blocks_max = 2 * + vsr.checkpoint_trailer.block_count_for_trailer_size(ewah.encode_size_max(blocks_count)); + + const client_sessions_encoded_blocks_max = + vsr.checkpoint_trailer.block_count_for_trailer_size(vsr.ClientSessions.encode_size); + + try print_header(output, 0, "free_set"); + const hashmap_entries = stdx.div_ceil( + 100 * (StateMachine.Forest.compaction_blocks_released_per_pipeline_max() + + free_set_encoded_blocks_max + client_sessions_encoded_blocks_max), + std.hash_map.default_max_load_percentage, + ); + + try output.print("{:.2}\n", .{std.fmt.fmtIntSizeBin( + // HashMap of block addresses plus two bitsets with bit per block. + hashmap_entries * @sizeOf(u64) + 2 * stdx.div_ceil(blocks_count, 8), + )}); + } +} + +fn inspect_metrics(output: std.io.AnyWriter) !void { + const EventMetricTag = std.meta.Tag(EventMetric); + const EventTimingTag = std.meta.Tag(EventTiming); + + const stats_per_gauge = std.meta.fields(EventMetricAggregate).len - 1; // -1 to ignore `event`. + const stats_per_timing = std.meta.fields(std.meta.FieldType(EventTimingAggregate, .values)).len; + var stats_total: usize = 0; + + log.info("Format: [metric type]: [metric name]([metric tags])=[metric cardinality]", .{}); + + inline for (std.meta.fields(EventMetric)) |field| { + const metric_tag = std.meta.stringToEnum(EventMetricTag, field.name).?; + try output.print("gauge: {s}(", .{field.name}); + if (field.type != void) { + inline for (std.meta.fields(field.type), 0..) |data_field, i| { + if (i != 0) try output.print(", ", .{}); + try output.print("{s}", .{data_field.name}); + } + } + const metric_stats = EventMetric.slot_limits.get(metric_tag) * stats_per_gauge; + try output.print(")={}\n", .{metric_stats}); + stats_total += metric_stats; + } + inline for (std.meta.fields(EventTiming)) |field| { + const timing_tag = std.meta.stringToEnum(EventTimingTag, field.name).?; + try output.print("timing: {s}(", .{field.name}); + if (field.type != void) { + inline for (std.meta.fields(field.type), 0..) |data_field, i| { + if (i != 0) try output.print(", ", .{}); + try output.print("{s}", .{data_field.name}); + } + } + const timing_stats = EventTiming.slot_limits.get(timing_tag) * stats_per_timing; + try output.print(")={}\n", .{timing_stats}); + stats_total += timing_stats; + } + log.info("Total stats per replica: {}", .{stats_total}); + log.info( + "(All stats are tagged with the replica, so the cluster has 6x as many stats.)", + .{}, + ); +} + +// Example output: +// checkpoint op trigger prepare_max checkpoint_next +// 624894719 +20 624894739 +12 624894751 +16 624894767 +912 624895679 +fn inspect_op(output: std.io.AnyWriter, op: u64) !void { + const checkpoint = if (op < constants.vsr_checkpoint_ops - 1) 0 else checkpoint: { + // op = q * checkpoints_ops - 1 + r + const r = (op + 1) % constants.vsr_checkpoint_ops; + const q = @divExact(op + 1 - r, constants.vsr_checkpoint_ops); + break :checkpoint q * constants.vsr_checkpoint_ops - 1; + }; + const checkpoint_next = vsr.Checkpoint.checkpoint_after(checkpoint); + + const points = .{ + .checkpoint = checkpoint, + .trigger = vsr.Checkpoint.trigger_for_checkpoint(checkpoint) orelse 0, + .prepare_max = vsr.Checkpoint.prepare_max_for_checkpoint(checkpoint) orelse 0, + .checkpoint_next = checkpoint_next, + .op = op, + }; + const Points = @TypeOf(points); + + const Entry = struct { + label: []const u8, + op: u64, + fn less_than(_: void, a: @This(), b: @This()) bool { + return a.op < b.op; + } + }; + + var entries: [std.meta.fields(Points).len]Entry = undefined; + inline for (std.meta.fields(Points), 0..) |field, index| { + entries[index] = .{ + .label = field.name, + .op = @field(points, field.name), + }; + } + std.sort.insertion(Entry, &entries, {}, Entry.less_than); + for (entries) |entry| { + try output.print("{s: <20}", .{entry.label}); + } + try output.print("\n", .{}); + for (entries[0 .. entries.len - 1], entries[1..]) |entry, entry_next| { + try output.print("{d: <15}", .{entry.op}); + try output.print("+{d: <4}", .{entry_next.op - entry.op}); + } + try output.print("{d: <20}", .{entries[entries.len - 1].op}); + try output.print("\n", .{}); +} + +fn print_header(output: std.io.AnyWriter, comptime level: u8, comptime header: []const u8) !void { + const width_total = 32; + const pad_left = " " ** level; + const pad_right = " " ** (width_total -| level * 2 -| header.len); + try output.print(pad_left ++ header ++ pad_right, .{}); +} + +fn print_size_count(output: std.io.AnyWriter, comptime size: u64, comptime count: u64) !void { + if (count == 1) { + try output.print("{}\n", .{stdx.fmt_int_size_bin_exact(size)}); + } else { + const size_formatted = comptime if (size < 1024) + std.fmt.comptimePrint("{}B", .{size}) + else + std.fmt.comptimePrint("{}", .{stdx.fmt_int_size_bin_exact(size)}); + try output.print("{s<8} x{}\n", .{ size_formatted, count }); + } +} + +fn print_size_counts( + output: std.io.AnyWriter, + comptime size: u64, + comptime labels: []const []const u8, + comptime counts: []const u64, +) !void { + const size_formatted = comptime if (size < 1024) + std.fmt.comptimePrint("{}B", .{size}) + else + std.fmt.comptimePrint("{}", .{stdx.fmt_int_size_bin_exact(size)}); + try output.print("{s<8}", .{size_formatted}); + for (labels, counts) |label, count| { + try output.print(" {s}={}", .{ label, count }); + } + try output.print("\n", .{}); +} + +fn print_objects(output: std.io.AnyWriter) !void { + const Grooves = StateMachine.Forest.Grooves; + inline for (std.meta.fields(Grooves)) |groove_field| { + const Groove = groove_field.type; + const ObjectTree = Groove.ObjectTree; + + const object_size = @sizeOf(ObjectTree.Table.Value); + comptime var size_total: usize = 0; + size_total += object_size; + comptime { + for (std.meta.fields(Groove.IndexTrees)) |index_field| { + const IndexTree = index_field.type; + const index_size = @sizeOf(IndexTree.Table.Value); + size_total += index_size; + } + } + + try print_header(output, 0, ObjectTree.tree_name()); + try print_size_counts( + output, + size_total, + &.{}, + &.{}, + ); + + try print_header(output, 1, "object"); + try print_tree_schema( + output, + @field(Groove.config.ids, "timestamp"), + ObjectTree, + ); + + inline for (std.meta.fields(Groove.IndexTrees)) |index_field| { + const IndexTree = index_field.type; + + try print_header(output, 1, index_field.name); + try print_tree_schema( + output, + @field(Groove.config.ids, index_field.name), + IndexTree, + ); + } + + try output.print("\n", .{}); + } +} + +fn print_tree_schema( + output: std.io.AnyWriter, + comptime tree_id: u16, + comptime Tree: type, +) !void { + try output.print( + "id={d: <2} K={s: <3} V={s: <4} T={d: <6} B={d: <5} BC={d: <3} ", + .{ + tree_id, + stdx.fmt_int_size_bin_exact(@sizeOf(Tree.Table.Key)), + stdx.fmt_int_size_bin_exact(@sizeOf(Tree.Table.Value)), + Tree.Table.value_count_max, + Tree.Table.layout.block_value_count_max, + Tree.Table.layout.value_block_count_max, + }, + ); + + const block_index = comptime schema.TableIndex.init(.{ + .key_size = @sizeOf(Tree.Table.Key), + .value_block_count_max = Tree.Table.layout.value_block_count_max, + }); + try output.print("IL={d}+{d},{d}/{d}+{d},{d}+{d},{d}+{d} ", .{ + block_index.value_checksums_offset, + block_index.value_checksums_size, + block_index.keys_min_offset, + block_index.keys_max_offset, + block_index.keys_size, + block_index.value_addresses_offset, + block_index.value_addresses_size, + block_index.padding_offset, + block_index.padding_size, + }); + + const block_value = comptime schema.TableValue.init(.{ + .value_size = @sizeOf(Tree.Table.Value), + .value_count_max = Tree.Table.layout.block_value_count_max, + }); + + try output.print("VL={d}+{d},{d}+{d}\n", .{ + block_value.values_offset, + block_value.values_size, + block_value.padding_offset, + block_value.padding_size, + }); +} + +const Inspector = struct { + allocator: std.mem.Allocator, + io: *IO, + storage: Storage, + + superblock_buffer: []align(constants.sector_size) u8, + superblock_headers: [constants.superblock_copies]*const SuperBlockHeader, + + busy: bool = false, + read: Storage.Read = undefined, + + fn create( + allocator: std.mem.Allocator, + io: *IO, + tracer: *Tracer, + path: []const u8, + ) !*Inspector { + var inspector = try allocator.create(Inspector); + errdefer allocator.destroy(inspector); + + inspector.* = .{ + .allocator = allocator, + .io = io, + .storage = undefined, + .superblock_buffer = undefined, + .superblock_headers = undefined, + }; + + inspector.storage = try Storage.init(io, tracer, .{ + .path = path, + .size_min = vsr.superblock.data_file_size_min, + .purpose = .inspect, + .direct_io = .direct_io_optional, + }); + errdefer inspector.storage.deinit(); + + inspector.superblock_buffer = try allocator.alignedAlloc( + u8, + constants.sector_size, + vsr.superblock.superblock_zone_size, + ); + errdefer allocator.free(inspector.superblock_buffer); + + try inspector.read_buffer(inspector.superblock_buffer, .superblock, 0); + + for (&inspector.superblock_headers, 0..) |*superblock_header, copy| { + const offset = @as(u64, copy) * vsr.superblock.superblock_copy_size; + superblock_header.* = @alignCast(std.mem.bytesAsValue( + SuperBlockHeader, + inspector.superblock_buffer[offset..][0..@sizeOf(SuperBlockHeader)], + )); + } + + const superblock = try inspector.read_superblock(null); + if (superblock.version != SuperBlockVersion) { + return vsr.fatal( + .cli, + "invalid superblock version; inspector supports version={}, version in {s}={}", + .{ + SuperBlockVersion, + path, + superblock.version, + }, + ); + } + return inspector; + } + + fn destroy(inspector: *Inspector) void { + inspector.allocator.free(inspector.superblock_buffer); + inspector.storage.deinit(); + inspector.allocator.destroy(inspector); + } + + fn work(inspector: *Inspector) !void { + assert(!inspector.busy); + inspector.busy = true; + + while (inspector.busy) { + try inspector.io.run_for_ns(constants.tick_ms * std.time.ns_per_ms); + } + } + + fn inspector_read_callback(read: *Storage.Read) void { + const inspector: *Inspector = @alignCast(@fieldParentPtr("read", read)); + assert(inspector.busy); + + inspector.busy = false; + } + + fn inspect_superblock(inspector: *Inspector, output: std.io.AnyWriter) !void { + log.info("In the left column of the output, \"|\" denotes which copies have a " ++ + "particular value.", .{}); + log.info("\"||||\" means that all four superblock copies are in agreement.", .{}); + log.info("\"|_|_\" means that the value matches in copies 0/2, but differs from copies " ++ + "1/3.", .{}); + + var header_valid: [constants.superblock_copies]bool = undefined; + for (&inspector.superblock_headers, 0..) |header, i| { + header_valid[i] = header.valid_checksum(); + } + + inline for (std.meta.fields(SuperBlockHeader)) |field| { + var group_by = GroupByType(constants.superblock_copies){}; + for (inspector.superblock_headers) |header| { + group_by.compare(std.mem.asBytes(&@field(header, field.name))); + } + + var label_buffer: [128]u8 = undefined; + for (group_by.groups()) |group| { + const header_index = group.first_set().?; + const header = &inspector.superblock_headers[header_index]; + const header_mark: u8 = if (header_valid[header_index]) '|' else 'X'; + + var label_stream = std.io.fixedBufferStream(&label_buffer); + for (0..constants.superblock_copies) |j| { + try label_stream.writer().writeByte(if (group.is_set(j)) header_mark else '_'); + } + try label_stream.writer().writeByte(' '); + try label_stream.writer().writeAll(field.name); + + try print_struct(output, label_stream.getWritten(), &@field(header.*, field.name)); + } + } + } + + fn inspect_wal(inspector: *Inspector, output: std.io.AnyWriter) !void { + log.info("In the left column of the output, \"|\" denotes which set of headers has " ++ + "each value.", .{}); + log.info("\"||\" denotes that the prepare and the redundant header match.", .{}); + log.info("\"|_\" is the redundant header.", .{}); + log.info("\"_|\" is the prepare's header.", .{}); + + const headers_buffer = try inspector.allocator.alignedAlloc( + u8, + constants.sector_size, + constants.journal_size_headers, + ); + defer inspector.allocator.free(headers_buffer); + + const prepare_buffer = try inspector.allocator.alignedAlloc( + u8, + constants.sector_size, + constants.message_size_max, + ); + defer inspector.allocator.free(prepare_buffer); + + try inspector.read_buffer(headers_buffer, .wal_headers, 0); + + for (std.mem.bytesAsSlice(vsr.Header.Prepare, headers_buffer), 0..) |*wal_header, slot| { + const offset = slot * constants.message_size_max; + try inspector.read_buffer(prepare_buffer, .wal_prepares, offset); + + const wal_prepare = std.mem.bytesAsValue( + vsr.Header.Prepare, + prepare_buffer[0..@sizeOf(vsr.Header)], + ); + + const wal_prepare_body_valid = + wal_prepare.valid_checksum() and + wal_prepare.valid_checksum_body( + prepare_buffer[@sizeOf(vsr.Header)..wal_prepare.size], + ); + + const header_pair = [_]*const vsr.Header.Prepare{ wal_header, wal_prepare }; + + var group_by = GroupByType(2){}; + group_by.compare(std.mem.asBytes(wal_header)); + group_by.compare(std.mem.asBytes(wal_prepare)); + + var label_buffer: [64]u8 = undefined; + for (group_by.groups()) |group| { + const header = header_pair[group.first_set().?]; + const header_valid = header.valid_checksum() and + (!group.is_set(1) or wal_prepare_body_valid); + + const mark: u8 = if (header_valid) '|' else 'X'; + var label_stream = std.io.fixedBufferStream(&label_buffer); + try label_stream.writer().writeByte(if (group.is_set(0)) mark else '_'); + try label_stream.writer().writeByte(if (group.is_set(1)) mark else '_'); + try label_stream.writer().print("{:_>4}: ", .{slot}); + + try print_struct(output, label_stream.getWritten(), &.{ + "checksum=", header.checksum, + "release=", header.release, + "view=", header.view, + "op=", header.op, + "size=", header.size, + "operation=", header.operation, + }); + } + } + } + + fn inspect_wal_slot(inspector: *Inspector, output: std.io.AnyWriter, slot: usize) !void { + assert(slot <= constants.journal_slot_count); + + const headers_buffer = try inspector.allocator.alignedAlloc( + u8, + constants.sector_size, + constants.journal_size_headers, + ); + defer inspector.allocator.free(headers_buffer); + + const prepare_buffer = try inspector.allocator.alignedAlloc( + u8, + constants.sector_size, + constants.message_size_max, + ); + defer inspector.allocator.free(prepare_buffer); + + try inspector.read_buffer(headers_buffer, .wal_headers, 0); + try inspector.read_buffer(prepare_buffer, .wal_prepares, slot * constants.message_size_max); + + const headers = std.mem.bytesAsSlice(vsr.Header.Prepare, headers_buffer); + const prepare_header = + std.mem.bytesAsValue(vsr.Header.Prepare, prepare_buffer[0..@sizeOf(vsr.Header)]); + + const prepare_body_valid = + prepare_header.valid_checksum() and + prepare_header.valid_checksum_body( + prepare_buffer[@sizeOf(vsr.Header)..prepare_header.size], + ); + + const copies: [2]*const vsr.Header.Prepare = .{ &headers[slot], prepare_header }; + + var group_by = GroupByType(2){}; + for (copies) |h| group_by.compare(std.mem.asBytes(h)); + + var label_buffer: [2]u8 = undefined; + for (group_by.groups()) |group| { + const header = copies[group.first_set().?]; + const header_mark: u8 = if (header.valid_checksum()) '|' else 'X'; + label_buffer[0] = if (group.is_set(0)) header_mark else '_'; + label_buffer[1] = if (group.is_set(1)) header_mark else '_'; + + try print_struct(output, &label_buffer, header); + } + try print_prepare_body(output, prepare_buffer); + + if (!prepare_body_valid) { + try output.writeAll("error: invalid prepare body!\n"); + } + } + + fn inspect_replies( + inspector: *Inspector, + output: std.io.AnyWriter, + superblock_copy: ?u8, + ) !void { + const entries_block = try allocate_block(inspector.allocator); + defer inspector.allocator.free(entries_block); + + const reply_sector = + try inspector.allocator.alignedAlloc(u8, constants.sector_size, constants.sector_size); + defer inspector.allocator.free(reply_sector); + + const entries = + try inspector.read_client_sessions(entries_block, superblock_copy) orelse return; + + var label_buffer: [64]u8 = undefined; + for (&entries.headers, &entries.sessions, 0..) |*session_header, session, slot| { + try inspector.read_buffer( + reply_sector, + .client_replies, + constants.message_size_max * slot, + ); + + const reply_header = + std.mem.bytesAsValue(vsr.Header.Reply, reply_sector[0..@sizeOf(vsr.Header)]); + const copies: [2]*const vsr.Header.Reply = .{ session_header, reply_header }; + var group_by = GroupByType(2){}; + for (copies) |h| group_by.compare(std.mem.asBytes(h)); + + // The session doesn't include the group diff labels since it is only stored in the + // client sessions, not the replies. + try output.print("{:_>2} session={}\n", .{ slot, session }); + + for (group_by.groups()) |group| { + const header_index = group.first_set().?; + const header = copies[header_index]; + const header_mark: u8 = if (header.valid_checksum()) '|' else 'X'; + + var label_stream = std.io.fixedBufferStream(&label_buffer); + try label_stream.writer().print("{:_>2}: ", .{slot}); + try label_stream.writer().writeByte(if (group.is_set(0)) header_mark else '_'); + try label_stream.writer().writeByte(if (group.is_set(1)) header_mark else '_'); + try label_stream.writer().writeAll(" header"); + try print_struct(output, label_stream.getWritten(), header); + } + } + } + + fn inspect_replies_slot( + inspector: *Inspector, + output: std.io.AnyWriter, + superblock_copy: ?u8, + slot: usize, + ) !void { + assert(slot < constants.clients_max); + + const block = try allocate_block(inspector.allocator); + defer inspector.allocator.free(block); + + const reply = try inspector.allocator.alignedAlloc( + u8, + constants.sector_size, + constants.message_size_max, + ); + defer inspector.allocator.free(reply); + + log.info("\"||\" denotes that the client session header and reply header match.", .{}); + log.info("\"|_\" is the client session header.", .{}); + log.info("\"_|\" is the client reply's header.", .{}); + + const entries = try inspector.read_client_sessions(block, superblock_copy) orelse { + try output.writeAll("error: no client sessions\n"); + return; + }; + + try inspector.read_buffer( + reply, + .client_replies, + constants.message_size_max * slot, + ); + + const reply_header = std.mem.bytesAsValue(vsr.Header.Reply, reply[0..@sizeOf(vsr.Header)]); + const copies: [2]*const vsr.Header.Reply = .{ &entries.headers[slot], reply_header }; + var group_by = GroupByType(2){}; + for (copies) |h| group_by.compare(std.mem.asBytes(h)); + + var label_buffer: [2]u8 = undefined; + for (group_by.groups()) |group| { + const header = copies[group.first_set().?]; + const header_mark: u8 = if (header.valid_checksum()) '|' else 'X'; + label_buffer[0] = if (group.is_set(0)) header_mark else '_'; + label_buffer[1] = if (group.is_set(1)) header_mark else '_'; + + try print_struct(output, &label_buffer, header); + } + try print_reply_body(output, reply); + } + + fn inspect_grid(inspector: *Inspector, output: std.io.AnyWriter, superblock_copy: ?u8) !void { + const superblock = try inspector.read_superblock(superblock_copy); + + const free_set_blocks_acquired_size = + superblock.vsr_state.checkpoint.free_set_blocks_acquired_size; + const free_set_blocks_released_size = + superblock.vsr_state.checkpoint.free_set_blocks_released_size; + + const free_set_blocks_acquired_buffer = + try inspector.allocator.alignedAlloc( + u8, + @alignOf(vsr.FreeSet.Word), + free_set_blocks_acquired_size, + ); + defer inspector.allocator.free(free_set_blocks_acquired_buffer); + + var free_set_blocks_acquired_addresses = + try std.ArrayList(u64).initCapacity( + inspector.allocator, + stdx.div_ceil( + free_set_blocks_acquired_size, + constants.block_size - @sizeOf(vsr.Header), + ), + ); + defer free_set_blocks_acquired_addresses.deinit(); + + const free_set_blocks_released_buffer = + try inspector.allocator.alignedAlloc( + u8, + @alignOf(vsr.FreeSet.Word), + free_set_blocks_released_size, + ); + defer inspector.allocator.free(free_set_blocks_released_buffer); + + var free_set_blocks_released_addresses = + try std.ArrayList(u64).initCapacity( + inspector.allocator, + stdx.div_ceil( + free_set_blocks_released_size, + constants.block_size - @sizeOf(vsr.Header), + ), + ); + defer free_set_blocks_released_addresses.deinit(); + + try inspector.read_free_set_bitset( + output, + superblock, + .blocks_acquired, + free_set_blocks_acquired_buffer, + &free_set_blocks_acquired_addresses, + ); + try inspector.read_free_set_bitset( + output, + superblock, + .blocks_released, + free_set_blocks_released_buffer, + &free_set_blocks_released_addresses, + ); + + // This is not exact, but is an overestimate: + const grid_blocks_max = + @divFloor(constants.storage_size_limit_max, constants.block_size); + + var free_set = try vsr.FreeSet.init( + inspector.allocator, + .{ + .grid_size_limit = grid_blocks_max * constants.block_size, + .blocks_released_prior_checkpoint_durability_max = 0, + }, + ); + defer free_set.deinit(inspector.allocator); + + const SliceOfAlignedWordSlice = []const []align(@alignOf(vsr.FreeSet.Word)) const u8; + const encoded_free_set_blocks_acquired: SliceOfAlignedWordSlice = + if (free_set_blocks_acquired_buffer.len != 0) + &.{free_set_blocks_acquired_buffer} + else + &.{}; + const encoded_free_set_blocks_released: SliceOfAlignedWordSlice = + if (free_set_blocks_released_buffer.len != 0) + &.{free_set_blocks_released_buffer} + else + &.{}; + + free_set.open(.{ + .encoded = .{ + .blocks_acquired = encoded_free_set_blocks_acquired, + .blocks_released = encoded_free_set_blocks_released, + }, + .free_set_block_addresses = .{ + .blocks_acquired = free_set_blocks_acquired_addresses.items, + .blocks_released = free_set_blocks_released_addresses.items, + }, + }); + + const free_set_acquired_address_max = free_set.highest_address_acquired() orelse 0; + const free_set_blocks_acquired_compression_ratio = + @as(f64, @floatFromInt(stdx.div_ceil(free_set_acquired_address_max, 8))) / + @as(f64, @floatFromInt(superblock.vsr_state.checkpoint.free_set_blocks_acquired_size)); + + const free_set_released_address_max = free_set.highest_address_released() orelse 0; + const free_set_blocks_released_compression_ratio = + @as(f64, @floatFromInt(stdx.div_ceil(free_set_released_address_max, 8))) / + @as(f64, @floatFromInt(superblock.vsr_state.checkpoint.free_set_blocks_released_size)); + + try output.print( + \\free_set.blocks_free={} + \\free_set.blocks_acquired={} + \\free_set.blocks_released={} + \\free_set.highest_address_acquired={?} + \\free_set.acquired_size={} + \\free_set.acquired_compression_ratio={d:0.4} + \\free_set.highest_address_released={?} + \\free_set.released_size={} + \\free_set.released_compression_ratio={d:0.4} + \\ + , + .{ + free_set.count_free(), + free_set.count_acquired(), + free_set.count_released(), + free_set.highest_address_acquired(), + std.fmt.fmtIntSizeBin(superblock.vsr_state.checkpoint + .free_set_blocks_acquired_size), + free_set_blocks_acquired_compression_ratio, + free_set.highest_address_released(), + std.fmt.fmtIntSizeBin(superblock.vsr_state.checkpoint + .free_set_blocks_released_size), + free_set_blocks_released_compression_ratio, + }, + ); + } + + fn inspect_grid_block(inspector: *Inspector, output: std.io.AnyWriter, address: u64) !void { + const block = try allocate_block(inspector.allocator); + defer inspector.allocator.free(block); + + try inspector.read_block(block, address, null); + + // If this is an unexpected (but valid) block, log an error but keep going. + const header = schema.header_from_block(block); + if (header.address != address) log.err("misdirected block", .{}); + + try print_block(output, block); + } + + fn inspect_manifest( + inspector: *Inspector, + output: std.io.AnyWriter, + superblock_copy: ?u8, + ) !void { + const superblock = try inspector.read_superblock(superblock_copy); + + const block = try allocate_block(inspector.allocator); + defer inspector.allocator.free(block); + + var manifest_block_address = superblock.vsr_state.checkpoint.manifest_newest_address; + var manifest_block_checksum = superblock.vsr_state.checkpoint.manifest_newest_checksum; + for (0..superblock.vsr_state.checkpoint.manifest_block_count) |i| { + try output.print( + "manifest_log.blocks[{}]: address={} checksum={x:0>32} ", + .{ i, manifest_block_address, manifest_block_checksum }, + ); + + inspector.read_block( + block, + manifest_block_address, + manifest_block_checksum, + ) catch { + try output.writeAll("error: manifest block not found\n"); + break; + }; + + var entry_counts = std.enums.EnumArray( + schema.ManifestNode.Event, + [constants.lsm_levels]usize, + ).initDefault([_]usize{0} ** constants.lsm_levels, .{}); + + const manifest_node = schema.ManifestNode.from(block); + for (manifest_node.tables_const(block)) |*table_info| { + entry_counts.getPtr(table_info.label.event)[table_info.label.level] += 1; + } + + try output.print( + "entries={}/{}", + .{ manifest_node.entry_count, schema.ManifestNode.entry_count_max }, + ); + + for (std.enums.values(schema.ManifestNode.Event)) |event| { + if (event == .reserved) continue; + try output.print(" {s}=", .{@tagName(event)}); + for (0..constants.lsm_levels) |level| { + if (level != 0) try output.writeAll(","); + try output.print("{}", .{entry_counts.get(event)[level]}); + } + } + try output.writeAll("\n"); + + const manifest_metadata = schema.ManifestNode.metadata(block); + manifest_block_address = manifest_metadata.previous_manifest_block_address; + manifest_block_checksum = manifest_metadata.previous_manifest_block_checksum; + } + } + + fn inspect_tables( + inspector: *Inspector, + output: std.io.AnyWriter, + superblock_copy: ?u8, + filter: struct { tree_id: u16, level: ?u6 }, + ) !void { + var tables_latest = + std.AutoHashMap(u128, ?schema.ManifestNode.TableInfo).init(inspector.allocator); + defer tables_latest.deinit(); + + const block = try allocate_block(inspector.allocator); + defer inspector.allocator.free(block); + + // Construct a set of all active tables. + const superblock = try inspector.read_superblock(superblock_copy); + var manifest_block_address = superblock.vsr_state.checkpoint.manifest_newest_address; + var manifest_block_checksum = superblock.vsr_state.checkpoint.manifest_newest_checksum; + for (0..superblock.vsr_state.checkpoint.manifest_block_count) |_| { + try inspector.read_block(block, manifest_block_address, manifest_block_checksum); + + const manifest_node = schema.ManifestNode.from(block); + const tables = manifest_node.tables_const(block); + for (0..tables.len) |i| { + const table_info = &tables[tables.len - i - 1]; + const table_latest = try tables_latest.getOrPut(table_info.checksum); + if (!table_latest.found_existing) { + if (table_info.label.event == .remove) { + table_latest.value_ptr.* = null; + } else { + table_latest.value_ptr.* = table_info.*; + } + } + } + + const manifest_metadata = schema.ManifestNode.metadata(block); + manifest_block_address = manifest_metadata.previous_manifest_block_address; + manifest_block_checksum = manifest_metadata.previous_manifest_block_checksum; + } + + var tables_filtered = + std.ArrayList(schema.ManifestNode.TableInfo).init(inspector.allocator); + defer tables_filtered.deinit(); + + // Construct a list of only the tables matching the `filter`. + var tables_latest_iterator = tables_latest.iterator(); + while (tables_latest_iterator.next()) |table_or_null| { + const table = table_or_null.value_ptr.* orelse continue; + if (table.tree_id != filter.tree_id) continue; + if (filter.level) |level| { + if (table.label.level != level) continue; + } + try tables_filtered.append(table); + } + + // Order the tables in a predictable way, since the manifest log can shuffle them around. + std.mem.sortUnstable(schema.ManifestNode.TableInfo, tables_filtered.items, {}, struct { + fn less_than( + _: void, + table_a: schema.ManifestNode.TableInfo, + table_b: schema.ManifestNode.TableInfo, + ) bool { + for ([_]std.math.Order{ + std.math.order(table_a.tree_id, table_b.tree_id), + std.math.order(table_a.label.level, table_b.label.level), + std.math.order( + std.mem.bytesAsValue(u256, &table_a.key_min).*, + std.mem.bytesAsValue(u256, &table_b.key_min).*, + ), + std.math.order( + std.mem.bytesAsValue(u256, &table_a.key_max).*, + std.mem.bytesAsValue(u256, &table_b.key_max).*, + ), + std.math.order(table_a.snapshot_min, table_b.snapshot_min), + std.math.order(table_a.snapshot_max, table_b.snapshot_max), + std.math.order(table_a.checksum, table_b.checksum), + }) |order| { + if (order != .eq) return order == .lt; + } + // This *should* be unreachable, especially given the checksum comparison. + return false; + } + }.less_than); + + inline for (StateMachine.Forest.tree_infos) |tree_info| { + if (tree_info.tree_id == filter.tree_id) { + for (tables_filtered.items) |*table| { + try print_table_info(output, tree_info, table); + } + break; + } + } else { + try output.print("error: unknown tree_id={}\n", .{filter.tree_id}); + } + } + + fn read_buffer( + inspector: *Inspector, + buffer: []align(constants.sector_size) u8, + zone: vsr.Zone, + offset_in_zone: u64, + ) !void { + inspector.storage.read_sectors( + inspector_read_callback, + &inspector.read, + buffer, + zone, + offset_in_zone, + ); + try inspector.work(); + } + + fn read_superblock(inspector: *const Inspector, superblock_copy: ?u8) !*const SuperBlockHeader { + if (superblock_copy) |copy| { + return inspector.superblock_headers[copy]; + } else { + var copies: [constants.superblock_copies]SuperBlockHeader = undefined; + for (&copies, inspector.superblock_headers) |*copy, header| copy.* = header.*; + + var quorums = SuperBlockQuorums{}; + const quorum = try quorums.working(&copies, .open); + if (!quorum.valid) return error.SuperBlockQuorumInvalid; + return inspector.superblock_headers[quorum.copies.first_set().?]; + } + } + + fn read_block( + inspector: *Inspector, + buffer: BlockPtr, + address: u64, + checksum: ?u128, + ) !void { + try inspector.read_buffer(buffer, .grid, (address - 1) * constants.block_size); + + const header = std.mem.bytesAsValue(vsr.Header.Block, buffer[0..@sizeOf(vsr.Header)]); + if (!header.valid_checksum()) { + log.err( + "read_block: invalid block address={} checksum_expect={?x:0>32} " ++ + "checksum_actual={x:0>32} (bad checksum)", + .{ address, checksum, header.checksum }, + ); + return error.InvalidChecksum; + } + + if (!header.valid_checksum_body(buffer[@sizeOf(vsr.Header)..header.size])) { + log.err( + "read_block: invalid block address={} checksum_expect={?x:0>32} " ++ + "checksum_actual={x:0>32} (bad checksum_body)", + .{ address, checksum, header.checksum }, + ); + return error.InvalidChecksumBody; + } + + if (checksum) |checksum_| { + if (header.checksum != checksum_) { + log.err( + "read_block: invalid block address={} checksum_expect={?x:0>32} " ++ + "checksum_actual={x:0>32} (wrong block)", + .{ address, checksum, header.checksum }, + ); + return error.WrongBlock; + } + } + } + + fn read_free_set_bitset( + inspector: *Inspector, + output: std.io.AnyWriter, + superblock: *const SuperBlockHeader, + bitset: vsr.FreeSet.BitsetKind, + free_set_buffer: []align(@alignOf(vsr.FreeSet.Word)) u8, + free_set_addresses: *std.ArrayList(u64), + ) !void { + const block = try allocate_block(inspector.allocator); + defer inspector.allocator.free(block); + + const free_set_reference = superblock.free_set_reference(bitset); + const free_set_size = free_set_reference.trailer_size; + const free_set_checksum = free_set_reference.checksum; + + const free_set_block_count = stdx.div_ceil( + free_set_size, + constants.block_size - @sizeOf(vsr.Header), + ); + + var free_set_block_references = try std.ArrayList(vsr.BlockReference).initCapacity( + inspector.allocator, + free_set_block_count, + ); + defer free_set_block_references.deinit(); + + if (free_set_size > 0) { + // Read free set from the grid by manually following the linked list of blocks. + // Note that free set is written in direct order, and must be read backwards. + var free_set_block_reference: ?vsr.BlockReference = .{ + .address = free_set_reference.last_block_address, + .checksum = free_set_reference.last_block_checksum, + }; + + var free_set_cursor: usize = free_set_size; + while (free_set_block_reference) |block_reference| { + try inspector.read_block( + block, + block_reference.address, + block_reference.checksum, + ); + + assert(schema.header_from_block(block).checksum == block_reference.checksum); + + const encoded_words = schema.TrailerNode.body(block); + free_set_cursor -= encoded_words.len; + stdx.copy_disjoint( + .inexact, + u8, + free_set_buffer[free_set_cursor..], + encoded_words, + ); + free_set_block_references.appendAssumeCapacity(block_reference); + free_set_addresses.appendAssumeCapacity(block_reference.address); + free_set_block_reference = schema.TrailerNode.previous(block); + } + assert(free_set_block_reference == null); + assert(free_set_cursor == 0); + } else { + assert(free_set_reference.last_block_address == 0); + assert(free_set_reference.last_block_checksum == 0); + } + + assert(free_set_block_references.items.len == free_set_block_count); + assert(free_set_addresses.items.len == free_set_block_count); + assert(vsr.checksum(free_set_buffer[0..free_set_size]) == free_set_checksum); + + for (free_set_block_references.items, 0..) |reference, i| { + try output.print( + "free_set_trailer.blocks[{}]: address={} checksum={x:0>32}\n", + .{ i, reference.address, reference.checksum }, + ); + } + } + + const ClientSessions = extern struct { + headers: [constants.clients_max]vsr.Header.Reply, + sessions: [constants.clients_max]u64, + }; + + fn read_client_sessions( + inspector: *Inspector, + block: BlockPtr, + superblock_copy: ?u8, + ) !?*ClientSessions { + const superblock = try inspector.read_superblock(superblock_copy); + + if (superblock.vsr_state.checkpoint.client_sessions_size == 0) { + assert(superblock.vsr_state.checkpoint.client_sessions_last_block_address == 0); + assert(superblock.vsr_state.checkpoint.client_sessions_last_block_checksum == 0); + return null; + } + assert(superblock.vsr_state.checkpoint.client_sessions_size == @sizeOf(ClientSessions)); + + try inspector.read_block( + block, + superblock.vsr_state.checkpoint.client_sessions_last_block_address, + superblock.vsr_state.checkpoint.client_sessions_last_block_checksum, + ); + + const block_header = schema.header_from_block(block); + assert(block_header.size == @sizeOf(vsr.Header) + @sizeOf(ClientSessions)); + assert(vsr.checksum(block[@sizeOf(vsr.Header)..block_header.size]) == + superblock.vsr_state.checkpoint.client_sessions_checksum); + + return std.mem.bytesAsValue( + ClientSessions, + block[@sizeOf(vsr.Header)..][0..@sizeOf(ClientSessions)], + ); + } +}; + +fn print_struct( + output: std.io.AnyWriter, + label: []const u8, + value: anytype, +) !void { + comptime assert(@typeInfo(@TypeOf(value)) == .pointer); + comptime assert(@typeInfo(@TypeOf(value)).pointer.size == .one); + + const Type = @typeInfo(@TypeOf(value)).pointer.child; + // Print structs *without* a custom format() function. + if (comptime @typeInfo(Type) == .@"struct" and !std.meta.hasFn(Type, "format")) { + if (@typeInfo(Type).@"struct".is_tuple) { + try output.writeAll(label); + // Print tuples as a single line. + inline for (std.meta.fields(Type), 0..) |field, i| { + if (@typeInfo(field.type) == .pointer and + @typeInfo(@typeInfo(field.type).pointer.child) == .array) + { + // Allow inline labels. + try output.writeAll(@field(value, field.name)); + } else { + try print_value(output, @field(value, field.name)); + if (i != std.meta.fields(Type).len) try output.writeAll(" "); + } + } + try output.writeAll("\n"); + return; + } else { + var label_buffer: [1024]u8 = undefined; + inline for (std.meta.fields(Type)) |field| { + var label_stream = std.io.fixedBufferStream(&label_buffer); + try label_stream.writer().print("{s}.{s}", .{ label, field.name }); + try print_struct(output, label_stream.getWritten(), &@field(value, field.name)); + } + return; + } + } + + if (Element: { + const type_info = @typeInfo(Type); + if (type_info == .array) { + break :Element @as(?type, type_info.array.child); + } + break :Element null; + }) |Element| { + if (Element == u8) { + if (stdx.zeroed(value)) { + return output.print("{s}=[{}]u8{{0}}\n", .{ label, value.len }); + } else { + return output.print("{s}=[{}]u8{{nonzero}}\n", .{ label, value.len }); + } + } else { + var label_buffer: [1024]u8 = undefined; + for (value[0..], 0..) |*item, index| { + var label_stream = std.io.fixedBufferStream(&label_buffer); + try label_stream.writer().print("{s}[{}]", .{ label, index }); + try print_struct(output, label_stream.getWritten(), item); + } + return; + } + } + + try output.print("{s}=", .{label}); + try print_value(output, value.*); + try output.writeAll("\n"); +} + +fn print_value(output: std.io.AnyWriter, value: anytype) !void { + const Type = @TypeOf(value); + if (@typeInfo(Type) == .@"struct") assert(std.meta.hasFn(Type, "format")); + assert(@typeInfo(Type) != .array); + + if (Type == u128) return output.print("0x{x:0>32}", .{value}); + + if (Type == vsr.Operation) { + if (value.valid(StateMachine.Operation)) { + return output.writeAll(value.tag_name(StateMachine.Operation)); + } else { + return output.print("{}!", .{@intFromEnum(value)}); + } + } + + if (@typeInfo(Type) == .@"enum") { + if (std.enums.tagName(Type, value)) |value_string| { + return output.print("{s}", .{value_string}); + } else { + return output.print("{}!", .{@intFromEnum(value)}); + } + } + try output.print("{}", .{value}); +} + +fn print_block(writer: std.io.AnyWriter, block: BlockPtrConst) !void { + const header = schema.header_from_block(block); + try print_struct(writer, "header", header); + + inline for (.{ + .{ .block_type = .free_set, .Schema = schema.TrailerNode }, + .{ .block_type = .client_sessions, .Schema = schema.TrailerNode }, + .{ .block_type = .manifest, .Schema = schema.ManifestNode }, + .{ .block_type = .index, .Schema = schema.TableIndex }, + .{ .block_type = .value, .Schema = schema.TableValue }, + }) |pair| { + if (header.block_type == pair.block_type) { + try print_struct(writer, "header.metadata", pair.Schema.metadata(block)); + break; + } + } else { + try writer.print("header.metadata: unknown block type\n", .{}); + } + + switch (header.block_type) { + .manifest => { + const manifest_node = schema.ManifestNode.from(block); + for (manifest_node.tables_const(block), 0..) |*table_info, entry_index| { + try writer.print( + "entry[{:_>4}]: {s} level={} address={} checksum={x:0>32} " ++ + "tree_id={s} key={:0>64}..{:0>64} snapshot={}..{} values={}\n", + .{ + entry_index, + @tagName(table_info.label.event), + table_info.label.level, + table_info.address, + table_info.checksum, + format_tree_id(table_info.tree_id), + std.fmt.fmtSliceHexLower(&table_info.key_min), + std.fmt.fmtSliceHexLower(&table_info.key_max), + table_info.snapshot_min, + table_info.snapshot_max, + table_info.value_count, + }, + ); + } + }, + .index => { + const index = schema.TableIndex.from_block_without_schema(block); + for ( + index.value_addresses_used(block), + index.value_checksums_used(block), + 0.., + ) |value_address, value_checksum, i| { + try writer.print( + "value_blocks[{:_>3}]: address={} checksum={x:0>32}\n", + .{ i, value_address, value_checksum.value }, + ); + } + }, + .value => { + const value_block = schema.TableValue.from(block); + const metadata = value_block.block_metadata(block); + const value_bytes = value_block.block_values_used_bytes(block); + + var label_buffer: [256]u8 = undefined; + inline for (StateMachine.Forest.tree_infos) |tree_info| { + if (metadata.tree_id == tree_info.tree_id) { + for ( + std.mem.bytesAsSlice(tree_info.Tree.Table.Value, value_bytes), + 0.., + ) |*value, i| { + var label_stream = std.io.fixedBufferStream(&label_buffer); + try label_stream.writer().print("{s}[{}]", .{ tree_info.tree_name, i }); + if (comptime is_composite_key(tree_info.Tree.Table.Value)) { + try label_stream.writer().writeAll(": "); + try print_struct( + writer, + label_stream.getWritten(), + &.{ value.field, value.timestamp }, + ); + } else { + try print_struct(writer, label_stream.getWritten(), value); + } + } + break; + } + } else { + try writer.print("body: unknown tree id\n", .{}); + } + }, + else => { + try writer.print( + "body: unimplemented for block_type={s}\n", + .{@tagName(header.block_type)}, + ); + }, + } +} + +fn format_tree_id(tree_id: u16) []const u8 { + inline for (StateMachine.Forest.tree_infos) |tree_info| { + if (tree_info.tree_id == tree_id) { + return tree_info.tree_name; + } + } else { + return "(unknown)"; + } +} + +fn parse_tree_id(tree_label: []const u8) ?u16 { + const tree_label_integer = stdx.parse_int(u16, tree_label, .{}) catch null; + inline for (StateMachine.Forest.tree_infos) |tree_info| { + if (std.mem.eql(u8, tree_info.tree_name, tree_label)) { + return tree_info.tree_id; + } + + if (tree_label_integer) |tree_id| { + if (tree_info.tree_id == tree_id) { + return tree_id; + } + } + } + return null; +} + +const operation_schemas = list: { + const OperationSchema = struct { + operation: vsr.Operation, + Event: type, + Result: type, + }; + + var list: []const OperationSchema = &[_]OperationSchema{}; + + for (&[_]struct { vsr.Operation, type, type }{ + .{ .reserved, extern struct {}, extern struct {} }, + .{ .root, extern struct {}, extern struct {} }, + // TODO vsr.RegisterRequest once that is merged. + .{ .register, extern struct {}, vsr.RegisterResult }, + .{ .reconfigure, vsr.ReconfigurationRequest, vsr.ReconfigurationResult }, + .{ .pulse, extern struct {}, extern struct {} }, + .{ .upgrade, vsr.UpgradeRequest, extern struct {} }, + }) |operation_schema| { + list = list ++ [_]OperationSchema{.{ + .operation = operation_schema[0], + .Event = operation_schema[1], + .Result = operation_schema[2], + }}; + } + + for (std.enums.values(StateMachine.Operation)) |operation| { + if (operation == .pulse) continue; + list = list ++ [_]OperationSchema{.{ + .operation = operation.to_vsr(), + .Event = operation.EventType(), + .Result = operation.ResultType(), + }}; + } + break :list list; +}; + +fn print_prepare_body(output: std.io.AnyWriter, prepare: []const u8) !void { + const header = std.mem.bytesAsValue(vsr.Header.Prepare, prepare[0..@sizeOf(vsr.Header)]); + inline for (operation_schemas) |operation_schema| { + if (operation_schema.operation == header.operation) { + const event_size = @sizeOf(operation_schema.Event); + const body_size = header.size - @sizeOf(vsr.Header); + if (body_size == 0) { + try output.print("(no body)\n", .{}); + } else if (event_size != 0 and body_size % event_size == 0) { + var label_buffer: [128]u8 = undefined; + for (std.mem.bytesAsSlice( + operation_schema.Event, + prepare[@sizeOf(vsr.Header)..header.size], + ), 0..) |*event, i| { + var label_stream = std.io.fixedBufferStream(&label_buffer); + try label_stream.writer().print("events[{}]: ", .{i}); + try print_struct(output, label_stream.getWritten(), event); + } + } else { + try output.print( + "error: unexpected body size={}, @sizeOf(Event)={}\n", + .{ header.size, event_size }, + ); + } + return; + } + } else { + try output.print("error: unimplemented operation={s}\n", .{@tagName(header.operation)}); + } +} + +fn print_reply_body(output: std.io.AnyWriter, reply: []const u8) !void { + const header = std.mem.bytesAsValue(vsr.Header.Reply, reply[0..@sizeOf(vsr.Header)]); + inline for (operation_schemas) |operation_schema| { + if (operation_schema.operation == header.operation) { + const result_size = @sizeOf(operation_schema.Result); + const body_size = header.size - @sizeOf(vsr.Header); + if (body_size == 0) { + try output.print("(no body)\n", .{}); + } else if (result_size != 0 and body_size % result_size == 0) { + var label_buffer: [128]u8 = undefined; + for (std.mem.bytesAsSlice( + operation_schema.Result, + reply[@sizeOf(vsr.Header)..header.size], + ), 0..) |*result, i| { + var label_stream = std.io.fixedBufferStream(&label_buffer); + try label_stream.writer().print("results[{}]: ", .{i}); + try print_struct(output, label_stream.getWritten(), result); + } + } else { + try output.print( + "error: unexpected body size={}, @sizeOf(Result)={}\n", + .{ header.size, result_size }, + ); + } + return; + } + } else { + try output.print("error: unimplemented operation={s}\n", .{@tagName(header.operation)}); + } +} + +fn print_table_info( + output: std.io.AnyWriter, + comptime tree_info: anytype, + table: *const schema.ManifestNode.TableInfo, +) !void { + try output.print("{c} T={s} L={}", .{ + @as(u8, switch (table.label.event) { + .insert => 'I', + .update => 'U', + // These shouldn't be hit, but included just for completeness' sake: + .remove => 'R', + else => '?', + }), + format_tree_id(table.tree_id), + table.label.level, + }); + + const Key = tree_info.Tree.Table.Key; + const Value = tree_info.Tree.Table.Value; + const key_min = std.mem.bytesAsValue(Key, table.key_min[0..@sizeOf(Key)]).*; + const key_max = std.mem.bytesAsValue(Key, table.key_max[0..@sizeOf(Key)]).*; + + if (comptime is_composite_key(Value)) { + const f: Value = undefined; + const Field = @TypeOf(f.field); + const key_min_timestamp: u64 = @truncate(key_min & std.math.maxInt(u64)); + const key_max_timestamp: u64 = @truncate(key_max & std.math.maxInt(u64)); + const key_min_field: Field = Value.key_prefix(key_min); + const key_max_field: Field = Value.key_prefix(key_max); + + try output.print(" K={:_>6}:{}..{:_>6}:{}", .{ + key_min_field, + key_min_timestamp, + key_max_field, + key_max_timestamp, + }); + } else { + try output.print(" K={}..{}", .{ key_min, key_max }); + } + + if (table.snapshot_max == std.math.maxInt(u64)) { + try output.print(" S={}..max", .{table.snapshot_min}); + } else { + try output.print(" S={}..{}", .{ table.snapshot_min, table.snapshot_max }); + } + + try output.print(" V={:_>6}/{} C={x:0>32} A={} O={}\n", .{ + table.value_count, + tree_info.Tree.Table.value_count_max, + table.checksum, + table.address, + vsr.Zone.offset(.grid, (table.address - 1) * constants.block_size), + }); +} + +fn GroupByType(comptime count_max: usize) type { + return struct { + const GroupBy = @This(); + const BitSet = stdx.BitSetType(count_max); + + count: usize = 0, + checksums: [count_max]?u128 = @splat(null), + matches: [count_max]BitSet = undefined, + + pub fn compare(group_by: *GroupBy, bytes: []const u8) void { + assert(group_by.count < count_max); + defer group_by.count += 1; + + assert(group_by.checksums[group_by.count] == null); + group_by.checksums[group_by.count] = vsr.checksum(bytes); + } + + pub fn groups(group_by: *GroupBy) []const BitSet { + assert(group_by.count == count_max); + + var distinct: usize = 0; + for (&group_by.checksums, 0..) |checksum_a, a| { + var matches: BitSet = .{}; + for (&group_by.checksums, 0..) |checksum_b, b| { + matches.set_value(b, checksum_a.? == checksum_b.?); + } + if (matches.first_set().? == a) { + group_by.matches[distinct] = matches; + distinct += 1; + } + } + assert(distinct > 0); + assert(distinct <= count_max); + return group_by.matches[0..distinct]; + } + }; +} + +fn allocate_block( + allocator: std.mem.Allocator, +) error{OutOfMemory}!*align(constants.sector_size) [constants.block_size]u8 { + const block = try allocator.alignedAlloc(u8, constants.sector_size, constants.block_size); + @memset(block, 0); + return block[0..constants.block_size]; +} diff --git a/ocam/src/tigerbeetle/inspect_integrity.zig b/ocam/src/tigerbeetle/inspect_integrity.zig new file mode 100644 index 00000000..629ed249 --- /dev/null +++ b/ocam/src/tigerbeetle/inspect_integrity.zig @@ -0,0 +1,529 @@ +const std = @import("std"); +const stdx = vsr.stdx; +const assert = std.debug.assert; +const log = std.log.scoped(.integrity); + +const cli = @import("cli.zig"); +const vsr = @import("vsr"); +const constants = vsr.constants; +const IO = vsr.io.IO; +const StateMachine = @import("main.zig").StateMachine; +const Replica = @import("main.zig").Replica; +const Storage = @import("main.zig").Storage; +const Grid = @import("main.zig").Grid; +const Tracer = vsr.trace.Tracer; + +const Forest = StateMachine.Forest; +const CheckpointTrailer = vsr.CheckpointTrailerType(Storage); +const SuperBlock = Replica.SuperBlock; + +/// Special offline GridScrubber type - uses constants.grid_iops_read_max rather than +/// constants.grid_scrubber_reads_max. +const GridScrubber = vsr.GridScrubberType(Forest, constants.grid_iops_read_max); + +pub fn command_inspect_integrity( + gpa: std.mem.Allocator, + io: *IO, + tracer: *Tracer, + args: *const cli.Command.Inspect.Integrity, +) !void { + var integrity: Integrity = undefined; + var checked_bytes: u64 = 0; + + try Integrity.init( + &integrity, + gpa, + io, + tracer, + args.path, + args.lsm_forest_node_count, + ); + defer integrity.deinit(gpa); + + // The superblock is checked as part of initializing and opening the checker - it cannot be + // skipped. A fully corrupt superblock stops any further scrubbing. + const checked_bytes_superblock = try integrity.open(); + checked_bytes += checked_bytes_superblock; + + if (!args.skip_wal) { + const checked_bytes_wal = try integrity.check_wal(); + assert( + checked_bytes_wal == vsr.Zone.wal_headers.size().? + vsr.Zone.wal_prepares.size().?, + ); + + checked_bytes += checked_bytes_wal; + } + + if (!args.skip_client_replies) { + const checked_bytes_client_replies = try integrity.check_client_replies(); + assert(checked_bytes_client_replies == vsr.Zone.client_replies.size().?); + + checked_bytes += checked_bytes_client_replies; + } + + const grid_blocks_expected_count = + (integrity.grid.free_set.count_acquired() - integrity.grid.free_set.count_released()) + + integrity.grid.free_set_checkpoint_blocks_acquired.block_count() + + integrity.grid.free_set_checkpoint_blocks_released.block_count(); + + if (!args.skip_grid) { + // If no seed was given, use a random seed for better coverage. + const seed: u64 = seed_from_arg: { + const seed_argument = args.seed orelse + break :seed_from_arg @truncate(stdx.unique_u128()); + break :seed_from_arg vsr.testing.parse_seed(seed_argument); + }; + + const checked_bytes_grid = try integrity.check_grid(seed); + assert(checked_bytes_grid == grid_blocks_expected_count * constants.block_size); + + checked_bytes += checked_bytes_grid; + } + + const checked_bytes_target: u64 = blk: { + var checked_bytes_target: u64 = 0; + + for (std.enums.values(vsr.Zone)) |zone| { + if (vsr.Zone.size(zone)) |size| { + checked_bytes_target += size; + } + } + checked_bytes_target += grid_blocks_expected_count * constants.block_size; + checked_bytes_target -= vsr.Zone.grid_padding.size().?; + + // At this point, checked_bytes_target is the highest it will ever be if everything were + // checked. Then, subtract off the skipped items explicitly. + + if (args.skip_wal) { + checked_bytes_target -= vsr.Zone.wal_headers.size().? + vsr.Zone.wal_prepares.size().?; + } + + if (args.skip_client_replies) { + checked_bytes_target -= vsr.Zone.client_replies.size().?; + } + + if (args.skip_grid) { + checked_bytes_target -= grid_blocks_expected_count * constants.block_size; + } + + break :blk checked_bytes_target; + }; + + assert(checked_bytes == checked_bytes_target); +} + +const Integrity = @This(); + +io: *IO, +storage: Storage, + +superblock: SuperBlock, +client_sessions_checkpoint: CheckpointTrailer, +grid: Grid, +forest: Forest, +grid_scrubber: GridScrubber, +grid_blocks_scrubbed: std.bit_set.DynamicBitSetUnmanaged, + +buffer_headers: []align(constants.sector_size) u8, +buffer_prepare: []align(constants.sector_size) u8, + +fn init( + integrity: *Integrity, + gpa: std.mem.Allocator, + io: *IO, + tracer: *Tracer, + path: []const u8, + lsm_forest_node_count: u32, +) !void { + integrity.io = io; + + integrity.storage = try Storage.init(io, tracer, .{ + .path = path, + .size_min = vsr.superblock.data_file_size_min, + .purpose = .inspect, + .direct_io = .direct_io_optional, + }); + errdefer integrity.storage.deinit(); + + const data_file_stat = try (std.fs.File{ .handle = integrity.storage.fd }).stat(); + + integrity.superblock = try SuperBlock.init( + gpa, + &integrity.storage, + .{ + .storage_size_limit = std.mem.alignForward( + u64, + data_file_stat.size, + constants.block_size, + ), + }, + ); + errdefer integrity.superblock.deinit(gpa); + + // Opening the forest requires an open superblock, so this is done explicitly here and not in + // open() like the others. + var superblock_context: SuperBlock.Context = undefined; + integrity.superblock.open(struct { + fn superblock_open_callback(_: *SuperBlock.Context) void {} + }.superblock_open_callback, &superblock_context); + while (!integrity.superblock.opened) integrity.superblock.storage.run(); + + // Unlike the other zones, the superblock has redundant copies internally. Consider the + // superblock to be valid if it's valid as a whole, even if an individual copy might be corrupt. + integrity.superblock.working.vsr_state.assert_internally_consistent(); + log.info("superblock opened and checked", .{}); + + const stash_blocks_count = + constants.grid_iops_read_max + + // Scans: *2 is for 1 index and 1 value block (per scan per level). + constants.lsm_scans_max * @as(u64, constants.lsm_levels) * 2 + + Forest.Options.compaction_block_count_min + + vsr.checkpoint_trailer.block_count_for_trailer_size(vsr.ClientSessions.encode_size) + + Forest.manifest_log_compaction_pace.blocks_count() + + 1; // GridScrubber.tour_index_block + + integrity.grid = try Grid.init(gpa, .{ + .superblock = &integrity.superblock, + .trace = tracer, + .cache_blocks_count = Grid.Cache.value_count_max_multiple, + .stash_blocks_count = stash_blocks_count, + .missing_blocks_max = constants.grid_missing_blocks_max, + .missing_tables_max = constants.grid_missing_tables_max, + .blocks_released_prior_checkpoint_durability_max = Forest + .compaction_blocks_released_per_pipeline_max() + + vsr.checkpoint_trailer.block_count_for_trailer_size(vsr.ClientSessions.encode_size), + }); + errdefer integrity.grid.deinit(gpa); + + integrity.client_sessions_checkpoint = try CheckpointTrailer.init( + gpa, + .client_sessions, + vsr.ClientSessions.encode_size, + ); + errdefer integrity.client_sessions_checkpoint.deinit(gpa); + + try integrity.forest.init( + gpa, + &integrity.grid, + .{ + .compaction_block_count = Forest.Options.compaction_block_count_min, + .node_count = lsm_forest_node_count, + }, + StateMachine.forest_options(.{ + .batch_size_limit = constants.message_body_size_max, + .lsm_forest_compaction_block_count = Forest.Options.compaction_block_count_min, + .lsm_forest_node_count = lsm_forest_node_count, + + .cache_entries_accounts = 0, + .cache_entries_transfers = 0, + .cache_entries_transfers_pending = 0, + + .log_trace = false, + .aof_recovery = false, + }), + ); + errdefer integrity.forest.deinit(gpa); + + integrity.grid_scrubber = try GridScrubber.init( + gpa, + &integrity.forest, + &integrity.client_sessions_checkpoint, + ); + errdefer integrity.grid_scrubber.deinit(gpa); + + integrity.grid_blocks_scrubbed = try .initEmpty( + gpa, + // Safe estimation for the maximum number of grid blocks based on file size. Using + // storage_size_limit_max would increase the memory usage dramatically for small data files. + @divFloor(data_file_stat.size, constants.block_size), + ); + errdefer integrity.grid_blocks_scrubbed.deinit(gpa); + + integrity.buffer_headers = try gpa.alignedAlloc( + u8, + constants.sector_size, + constants.journal_size_headers, + ); + errdefer gpa.free(integrity.buffer_headers); + + integrity.buffer_prepare = try gpa.alignedAlloc( + u8, + constants.sector_size, + constants.message_size_max, + ); + errdefer gpa.free(integrity.buffer_prepare); +} + +fn open(integrity: *Integrity) !u64 { + var checked_bytes: u64 = 0; + + assert(integrity.superblock.opened); + integrity.superblock.working.vsr_state.assert_internally_consistent(); + for (integrity.superblock.reading) |_| { + checked_bytes += vsr.superblock.superblock_copy_size; + } + + integrity.client_sessions_checkpoint.open( + &integrity.grid, + integrity.superblock.working.client_sessions_reference(), + struct { + fn client_sessions_checkpoint_callback(_: *CheckpointTrailer) void {} + }.client_sessions_checkpoint_callback, + ); + while (integrity.client_sessions_checkpoint.callback != .none) { + try integrity.io.run_for_ns(constants.tick_ms * std.time.ns_per_ms); + } + log.debug("client sessions checkpoint opened", .{}); + + integrity.grid.open(struct { + fn grid_open_callback(_: *Grid) void {} + }.grid_open_callback); + while (integrity.grid.callback != .none) { + try integrity.io.run_for_ns(constants.tick_ms * std.time.ns_per_ms); + } + log.debug("grid opened", .{}); + + integrity.forest.open(struct { + fn forest_open_callback(_: *Forest) void {} + }.forest_open_callback); + while (integrity.forest.progress != null) { + try integrity.io.run_for_ns(constants.tick_ms * std.time.ns_per_ms); + } + log.debug("forest opened", .{}); + + return checked_bytes; +} + +fn deinit(integrity: *Integrity, gpa: std.mem.Allocator) void { + gpa.free(integrity.buffer_headers); + gpa.free(integrity.buffer_prepare); + + integrity.client_sessions_checkpoint.deinit(gpa); + integrity.grid_blocks_scrubbed.deinit(gpa); + integrity.grid_scrubber.deinit(gpa); + integrity.forest.deinit(gpa); + integrity.grid.deinit(gpa); + integrity.superblock.deinit(gpa); + integrity.storage.deinit(); +} + +/// Checks the WAL headers and prepares, using sync IO. +fn check_wal(integrity: *Integrity) !u64 { + var checked_bytes: u64 = 0; + + const headers_bytes_read = try integrity.sync_read_all( + integrity.buffer_headers, + vsr.Zone.wal_headers.start(), + ); + assert(headers_bytes_read == integrity.buffer_headers.len); + + const wal_headers: []const vsr.Header.Prepare = + std.mem.bytesAsSlice(vsr.Header.Prepare, integrity.buffer_headers); + + for (wal_headers, 0..) |*wal_header, slot| { + const offset = slot * constants.message_size_max; + + const bytes_read = try integrity.sync_read_all( + integrity.buffer_prepare, + vsr.Zone.wal_prepares.start() + offset, + ); + assert(bytes_read == integrity.buffer_prepare.len); + + const wal_prepare: *const vsr.Header.Prepare = + std.mem.bytesAsValue( + vsr.Header.Prepare, + integrity.buffer_prepare[0..@sizeOf(vsr.Header)], + ); + + const wal_prepare_body_valid = + wal_prepare.valid_checksum() and + wal_prepare.valid_checksum_body( + integrity.buffer_prepare[@sizeOf(vsr.Header)..wal_prepare.size], + ); + + assert(wal_header.valid_checksum()); + assert(wal_prepare_body_valid); + assert(wal_header.checksum == wal_prepare.checksum); + checked_bytes += bytes_read; + } + + assert(wal_headers.len == constants.journal_slot_count); + checked_bytes += headers_bytes_read; + + log.info("successfully checked {} wal headers and prepares", .{wal_headers.len}); + return checked_bytes; +} + +/// Checks the client replies, using sync IO. +fn check_client_replies(integrity: *Integrity) !u64 { + var checked_bytes: u64 = 0; + + for (0..constants.clients_max) |slot| { + const offset = slot * constants.message_size_max; + + const bytes_read = try integrity.sync_read_all( + integrity.buffer_prepare, + vsr.Zone.client_replies.start() + offset, + ); + assert(bytes_read == integrity.buffer_prepare.len); + + const reply: *const vsr.Header.Reply = std.mem.bytesAsValue( + vsr.Header.Reply, + integrity.buffer_prepare[0..@sizeOf(vsr.Header)], + ); + + const reply_empty = reply.checksum == 0 and reply.checksum_body == 0; + const reply_valid = reply.valid_checksum() and + reply.valid_checksum_body(integrity.buffer_prepare[@sizeOf(vsr.Header)..reply.size]); + assert(reply_empty or reply_valid); + + checked_bytes += bytes_read; + } + + log.info("successfully checked {} client replies", .{constants.clients_max}); + return checked_bytes; +} + +/// Checks the grid, using the grid scrubber. +fn check_grid(integrity: *Integrity, seed: u64) !u64 { + var checked_bytes: u64 = 0; + const grid = &integrity.grid; + + // The free set isn't included in the grid's acquired count, but is scrubbed. + // Ensure this is accounted for. + const blocks_expected_count = + (grid.free_set.count_acquired() - grid.free_set.count_released()) + + grid.free_set_checkpoint_blocks_acquired.block_count() + + grid.free_set_checkpoint_blocks_released.block_count(); + + log.info("checking {} grid blocks with seed {}...", .{ + blocks_expected_count, + seed, + }); + + var prng = stdx.PRNG.from_seed(seed); + integrity.grid_scrubber.open(&prng); + + const parent_progress_node = std.Progress.start(.{ + .root_name = "checking grid blocks", + .estimated_total_items = blocks_expected_count, + }); + defer parent_progress_node.end(); + + var time: vsr.time.TimeOS = .{}; + const timer = time.monotonic(); + + while (true) { + for (0..integrity.grid_scrubber.reads.available() + 1) |_| { + if (integrity.grid_scrubber.tour == .done) break; + if (!integrity.grid_scrubber.read_next()) break; + } + + while (integrity.grid_scrubber.read_result_next()) |result| { + assert(result.status == .ok); + + // `read_result_next` returns addresses, starting from 1, but the free sets and such + // are zero indexed. + const block_set = integrity.grid_blocks_scrubbed.isSet( + result.block.block_address - 1, + ); + + // Only completeOne() if no existing entry was found, as the grid scrubber will + // run through index blocks multiple times. + if (!block_set) { + integrity.grid_blocks_scrubbed.set(result.block.block_address - 1); + checked_bytes += constants.block_size; + parent_progress_node.completeOne(); + } + } + + // When the .tour is .done, there might still be reads executing. Wait for those, too. + if (integrity.grid_scrubber.tour == .done and + integrity.grid_scrubber.reads.executing() == 0) + { + break; + } + + try integrity.io.run_for_ns(constants.tick_ms * std.time.ns_per_ms); + } + const grid_duration = timer.elapsed(time.monotonic()); + + assert(integrity.grid_scrubber.tour == .done and + integrity.grid_scrubber.reads.executing() == 0); + + // Verify that the blocks checked from the grid scrubber and the free set itself are + // identical. + assert(integrity.grid_blocks_scrubbed.count() == blocks_expected_count); + + var acquired_iterator = grid.free_set.blocks_acquired.iterator(.{}); + while (acquired_iterator.next()) |block| { + if (grid.free_set.blocks_released.isSet(block)) { + continue; + } + + assert(integrity.grid_blocks_scrubbed.isSet(block)); + } + + // Check in reverse, too, that all the blocks we visited are listed in the free set. + var visisted_iterator = integrity.grid_blocks_scrubbed.iterator(.{}); + while (visisted_iterator.next()) |entry| { + const in_free_set = grid.free_set.blocks_acquired.isSet(entry) and + !grid.free_set.blocks_released.isSet(entry); + + assert(in_free_set); + } + + const throughput = @divFloor(@divFloor( + blocks_expected_count * constants.block_size, + stdx.div_ceil(grid_duration.to_ms(), std.time.ms_per_s), + ), 1024 * 1024); + + log.info("successfully checked {} grid blocks in {}ms. ({}MiB/s)", .{ + blocks_expected_count, + grid_duration.to_ms(), + throughput, + }); + + return checked_bytes; +} + +/// Windows doesn't support using sync IO functions like preadAll on the handles IO opens. +fn sync_read_all(integrity: *Integrity, buffer: []u8, offset: u64) !u64 { + var completion: IO.Completion = undefined; + + const Context = struct { + bytes_read: ?u64 = null, + + fn read_callback( + context: *@This(), + _: *IO.Completion, + result: IO.ReadError!usize, + ) void { + context.bytes_read = result catch unreachable; + } + }; + var context: Context = .{}; + var bytes_read: u64 = 0; + + while (bytes_read < buffer.len) { + integrity.io.read( + *Context, + &context, + Context.read_callback, + &completion, + integrity.storage.fd, + buffer[bytes_read..], + offset, + ); + + while (context.bytes_read == null) { + try integrity.io.run(); + } + + if (context.bytes_read.? == 0) break; + bytes_read += context.bytes_read.?; + } + + assert(bytes_read == buffer.len); + return bytes_read; +} diff --git a/ocam/src/tigerbeetle/libtb_client.zig b/ocam/src/tigerbeetle/libtb_client.zig new file mode 100644 index 00000000..dd5de777 --- /dev/null +++ b/ocam/src/tigerbeetle/libtb_client.zig @@ -0,0 +1,36 @@ +//! Entry point for exporting the `tb_client` library. +//! Used by language clients that rely on the shared or static library exposed by `tb_client.h`. +//! For an idiomatic Zig API, use `vsr.tb_client` directly instead. +const builtin = @import("builtin"); +const std = @import("std"); + +pub const vsr = @import("vsr"); +const exports = vsr.tb_client.exports; + +pub const std_options: std.Options = .{ + .log_level = .debug, + .logFn = exports.Logging.application_logger, +}; + +comptime { + if (!builtin.link_libc) { + @compileError("Must be built with libc to export tb_client symbols."); + } + + @export(&exports.init, .{ .name = "tb_client_init", .linkage = .strong }); + @export(&exports.init_echo, .{ .name = "tb_client_init_echo", .linkage = .strong }); + @export(&exports.submit, .{ .name = "tb_client_submit", .linkage = .strong }); + @export(&exports.deinit, .{ .name = "tb_client_deinit", .linkage = .strong }); + @export( + &exports.completion_context, + .{ .name = "tb_client_completion_context", .linkage = .strong }, + ); + @export( + &exports.register_log_callback, + .{ .name = "tb_client_register_log_callback", .linkage = .strong }, + ); + @export( + &exports.init_parameters, + .{ .name = "tb_client_init_parameters", .linkage = .strong }, + ); +} diff --git a/ocam/src/tigerbeetle/main.zig b/ocam/src/tigerbeetle/main.zig new file mode 100644 index 00000000..e131535e --- /dev/null +++ b/ocam/src/tigerbeetle/main.zig @@ -0,0 +1,664 @@ +const builtin = @import("builtin"); +const std = @import("std"); +const assert = std.debug.assert; +const fmt = std.fmt; +const mem = std.mem; +const os = std.os; +const log = std.log.scoped(.main); + +const vsr = @import("vsr"); +const stdx = vsr.stdx; +const constants = vsr.constants; +const config = constants.config; + +const benchmark_driver = @import("benchmark_driver.zig"); +const cli = @import("cli.zig"); +const inspect = @import("inspect.zig"); + +const IO = vsr.io.IO; +const Time = vsr.time.Time; +const TimeOS = vsr.time.TimeOS; +const Tracer = vsr.trace.Tracer; +pub const Storage = vsr.storage.StorageType(IO); +const AOF = vsr.aof.AOFType(IO); + +const MessageBus = vsr.message_bus.MessageBusType(IO); +const MessagePool = vsr.message_pool.MessagePool; +pub const StateMachine = vsr.state_machine.StateMachineType(Storage); +pub const Grid = vsr.GridType(Storage); + +const Client = vsr.ClientType(StateMachine.Operation, MessageBus); +pub const Replica = vsr.ReplicaType(StateMachine, MessageBus, Storage, AOF); +const ReplicaReformat = + vsr.ReplicaReformatType(StateMachine, MessageBus, Storage); +const data_file_size_min = vsr.superblock.data_file_size_min; + +const GeneralPurposeAllocator = std.heap.GeneralPurposeAllocator(.{}); + +const KiB = stdx.KiB; +const MiB = stdx.MiB; +const GiB = stdx.GiB; + +/// The runtime maximum log level. +/// One of: .err, .warn, .info, .debug +pub var log_level_runtime: std.log.Level = .info; + +pub fn log_runtime( + comptime message_level: std.log.Level, + comptime scope: @Type(.enum_literal), + comptime format: []const u8, + args: anytype, +) void { + // A microbenchmark places the cost of this if at somewhere around 1600us for 10 million calls. + if (@intFromEnum(message_level) <= @intFromEnum(log_level_runtime)) { + stdx.log_with_timestamp(message_level, scope, format, args); + } +} + +pub const std_options: std.Options = .{ + // The comptime log_level. This needs to be debug - otherwise messages are compiled out. + // The runtime filtering is handled by log_level_runtime. + .log_level = .debug, + .logFn = log_runtime, +}; + +pub fn main() !void { + if (builtin.os.tag == .windows) try vsr.multiversion.wait_for_parent_to_exit(); + + var allocator = GeneralPurposeAllocator.init; + allocator.backing_allocator = stdx.huge_page_allocator; + const gpa = allocator.allocator(); + defer { + _ = allocator.detectLeaks(); + switch (allocator.deinit()) { + .ok => {}, + .leak => @panic("memory leaked"), + } + } + + var flags = stdx.Flags.init(gpa); + defer flags.deinit(gpa); + + var command = cli.parse_args(&flags); + + if (command == .version) { + try command_version(gpa, command.version.verbose); + return; // Exit early before initializing IO. + } + + log_level_runtime = switch (command) { + .version => unreachable, + .inspect => .info, + inline else => |*args| if (args.log_debug) .debug else .info, + }; + + // Try and init IO early, before a file has even been created, so if it fails (eg, io_uring + // is not available) there won't be a dangling file. + const io_entries: u12 = switch (command) { + // In format, all writes are issued in parallel with no backpressue. It's nice and simple, + // but means a larger loop size is needed to avoid a warning. + .format, .recover => 2048, + else => 128, + }; + var io = try IO.init(io_entries, 0); + defer io.deinit(); + + var time_os: TimeOS = .{}; + const time = time_os.time(); + + var trace_file: ?std.fs.File = null; + defer if (trace_file) |file| file.close(); + + var statsd_address: ?stdx.SocketAddress = null; + var log_trace = true; + + switch (command) { + .start => |*args| { + if (args.trace) |path| { + trace_file = std.fs.cwd().createFile(path, .{ .exclusive = true }) catch |err| { + log.err("error creating trace file '{s}': {}", .{ path, err }); + return err; + }; + } + if (args.statsd) |address| statsd_address = address; + log_trace = args.log_trace; + }, + .benchmark => {}, // Forwards trace and statsd argument to child tigerbeetle. + inline else => |args| comptime { + assert(!@hasField(@TypeOf(args), "trace")); + assert(!@hasField(@TypeOf(args), "statsd")); + }, + } + + var tracer = try Tracer.init(gpa, time, .unknown, .{ + .writer = if (trace_file) |file| file.writer().any() else null, + .statsd_options = if (statsd_address) |address| .{ + .udp = .{ + .io = &io, + .address = address, + }, + } else .log, + .log_trace = log_trace, + }); + defer tracer.deinit(gpa); + + switch (command) { + .version => unreachable, // Handled earlier. + inline .format, .start, .recover => |*args, command_storage| { + const direct_io: vsr.io.DirectIO = + if (!constants.direct_io) + .direct_io_disabled + else if (args.development) + .direct_io_optional + else + .direct_io_required; + + var storage = try Storage.init(&io, &tracer, .{ + .path = args.path, + .size_min = data_file_size_min, + .purpose = switch (command_storage) { + .format, .recover => .format, + .start => .open, + else => comptime unreachable, + }, + .direct_io = direct_io, + }); + defer storage.deinit(); + + switch (command_storage) { + .format => try command_format(gpa, &storage, args), + .start => try command_start(gpa, &io, time, &tracer, &storage, args), + .recover => try command_reformat(gpa, &io, time, &storage, args), + else => comptime unreachable, + } + }, + .repl => |*args| try command_repl(gpa, &io, time, args), + .benchmark => |*args| try benchmark_driver.command_benchmark(gpa, &io, time, args), + .inspect => |*args| try inspect.command_inspect(gpa, &io, &tracer, args), + .multiversion => |*args| { + var stdout_buffer = std.io.bufferedWriter(std.io.getStdOut().writer()); + var stdout_writer = stdout_buffer.writer(); + const stdout = stdout_writer.any(); + + try vsr.multiversion.print_information(gpa, args.path, stdout); + try stdout_buffer.flush(); + }, + .amqp => |*args| try command_amqp(gpa, time, args), + } +} + +fn command_version(gpa: mem.Allocator, verbose: bool) !void { + var stdout_buffer = std.io.bufferedWriter(std.io.getStdOut().writer()); + var stdout_writer = stdout_buffer.writer(); + const stdout = stdout_writer.any(); + + try std.fmt.format(stdout, "TigerBeetle version {}\n", .{constants.semver}); + + if (verbose) { + try stdout.writeAll("\n"); + inline for (.{ "mode", "zig_version" }) |declaration| { + try print_value(stdout, "build." ++ declaration, @field(builtin, declaration)); + } + + // Zig 0.10 doesn't see field_name as comptime if this `comptime` isn't used. + try stdout.writeAll("\n"); + inline for (comptime std.meta.fieldNames(@TypeOf(config.cluster))) |field_name| { + try print_value( + stdout, + "cluster." ++ field_name, + @field(config.cluster, field_name), + ); + } + + try stdout.writeAll("\n"); + inline for (comptime std.meta.fieldNames(@TypeOf(config.process))) |field_name| { + try print_value( + stdout, + "process." ++ field_name, + @field(config.process, field_name), + ); + } + + try stdout.writeAll("\n"); + const self_exe_path = try vsr.multiversion.self_exe_path(gpa); + defer gpa.free(self_exe_path); + + vsr.multiversion.print_information(gpa, self_exe_path, stdout) catch {}; + } + try stdout_buffer.flush(); +} + +fn command_format( + gpa: mem.Allocator, + storage: *Storage, + args: *const cli.Command.Format, +) !void { + try vsr.format(Storage, gpa, storage, .{ + .cluster = args.cluster, + .replica = args.replica, + .replica_count = args.replica_count, + .release = config.process.release, + .view = null, + }); + + log.info("{}: formatted: cluster={} replica_count={}", .{ + args.replica, + args.cluster, + args.replica_count, + }); +} + +fn command_start( + base_allocator: mem.Allocator, + io: *IO, + time: Time, + tracer: *Tracer, + storage: *Storage, + args: *const cli.Command.Start, +) !void { + var counting_allocator = vsr.CountingAllocator.init(base_allocator); + const gpa = counting_allocator.allocator(); + + // TODO Panic if the data file's size is larger that args.storage_size_limit. + // (Here or in Replica.open()?). + + var message_pool = try MessagePool.init(gpa, .{ .replica = .{ + .members_count = args.addresses.members_count(), + .pipeline_requests_limit = args.pipeline_requests_limit, + .message_bus = .tcp, + } }); + defer message_pool.deinit(gpa); + + var aof: ?AOF = if (args.aof_file) |*aof_file| blk: { + break :blk try AOF.init(io, aof_file.const_slice()); + } else null; + defer if (aof != null) aof.?.close(); + + const grid_cache_size = @as(u64, args.cache_grid_blocks) * constants.block_size; + const grid_cache_size_min = constants.block_size * Grid.Cache.value_count_max_multiple; + + // The amount of bytes in `--cache-grid` must be a multiple of + // `constants.block_size` and `SetAssociativeCache.value_count_max_multiple`, + // and it may have been converted to zero if a smaller value is passed in. + if (grid_cache_size == 0) { + if (comptime (grid_cache_size_min >= MiB)) { + vsr.fatal(.cli, "Grid cache must be greater than {}MiB. See --cache-grid", .{ + @divExact(grid_cache_size_min, MiB), + }); + } else { + vsr.fatal(.cli, "Grid cache must be greater than {}KiB. See --cache-grid", .{ + @divExact(grid_cache_size_min, KiB), + }); + } + } + assert(grid_cache_size >= grid_cache_size_min); + + const grid_cache_size_warn = 1 * GiB; + if (grid_cache_size < grid_cache_size_warn) { + log.warn("Grid cache size of {}MiB is small. See --cache-grid", .{ + @divExact(grid_cache_size, MiB), + }); + } + + const nonce = stdx.unique_u128(); + + var self_exe_path: ?[:0]const u8 = null; + defer if (self_exe_path) |path| gpa.free(path); + + var multiversion_os: ?vsr.multiversion.MultiversionOS = null; + defer if (multiversion_os != null) multiversion_os.?.deinit(gpa); + + const multiversion: vsr.multiversion.Multiversion = blk: { + if (constants.config.process.release.value == + vsr.multiversion.Release.minimum.value) + { + log.info("multiversioning: upgrades disabled for development ({}) release.", .{ + constants.config.process.release, + }); + break :blk .single_release(constants.config.process.release); + } + if (args.aof_recovery) { + log.info("multiversioning: upgrades disabled due to aof_recovery.", .{}); + break :blk .single_release(constants.config.process.release); + } + + if (args.addresses.zero) { + log.info("multiversioning: upgrades disabled due to --addresses=0", .{}); + break :blk .single_release(constants.config.process.release); + } + + self_exe_path = try vsr.multiversion.self_exe_path(gpa); + multiversion_os = try vsr.multiversion.MultiversionOS.init( + gpa, + io, + self_exe_path.?, + .native, + ); + // The error from .open_sync() is ignored - timeouts and checking for new binaries are still + // enabled even if the first version fails to load. + multiversion_os.?.open_sync() catch {}; + + break :blk multiversion_os.?.multiversion(); + }; + + log.info("release={}", .{config.process.release}); + log.info("release_client_min={}", .{config.process.release_client_min}); + log.info("releases_bundled={any}", .{multiversion.releases_bundled().slice()}); + log.info("git_commit={?s}", .{config.process.git_commit}); + + const clients_limit = constants.pipeline_prepare_queue_max + args.pipeline_requests_limit; + + var replica: Replica = undefined; + replica.open( + gpa, + time, + storage, + &message_pool, + .{ + .node_count = args.addresses.members_count(), + .release = config.process.release, + .release_client_min = config.process.release_client_min, + .multiversion = multiversion, + .pipeline_requests_limit = args.pipeline_requests_limit, + .storage_size_limit = args.storage_size_limit, + .aof = if (aof != null) &aof.? else null, + .aof_recovery = args.aof_recovery, + .nonce = nonce, + .timeout_prepare_ticks = args.timeout_prepare_ticks, + .timeout_grid_repair_message_ticks = args.timeout_grid_repair_message_ticks, + .commit_stall_probability = args.commit_stall_probability, + .commit_stall_lag_min = args.commit_stall_lag_min, + .commit_stall_lag_max = args.commit_stall_lag_max, + .commit_stall_multiple_max = args.commit_stall_multiple_max, + .state_machine_options = .{ + .batch_size_limit = args.request_size_limit - @sizeOf(vsr.Header), + .lsm_forest_compaction_block_count = args.lsm_forest_compaction_block_count, + .lsm_forest_node_count = args.lsm_forest_node_count, + .cache_entries_accounts = args.cache_accounts, + .cache_entries_transfers = args.cache_transfers, + .cache_entries_transfers_pending = args.cache_transfers_pending, + .log_trace = args.log_trace, + .aof_recovery = args.aof_recovery, + }, + .message_bus_options = .{ + .configuration = args.addresses.slice(), + .io = io, + .trace = tracer, + .time = time, + .clients_limit = clients_limit, + }, + .grid_cache_blocks_count = args.cache_grid_blocks, + .tracer = tracer, + }, + ) catch |err| switch (err) { + error.NoAddress => vsr.fatal(.cli, "all --addresses must be provided", .{}), + else => |e| return e, + }; + + // Mark grid cache as MADV_DONTDUMP, after transitioning to static in replica.open, to reduce + // core dump size. + replica.grid.madv_dont_dump() catch |e| { + log.warn("unable to mark grid cache as MADV_DONTDUMP - " ++ + "core dumps will be large: {}", .{e}); + }; + + if (multiversion_os != null) { + if (builtin.target.os.tag != .linux) { + // Checking for new binaries on disk after the replica has been opened is only + // supported on Linux. + log.info("multiversioning: upgrade polling disabled; only available on Linux", .{}); + } else if (args.development) { + log.info("multiversioning: upgrade polling disabled due to --development.", .{}); + } else { + multiversion_os.?.timeout_start(replica.replica); + + if (args.experimental) { + log.warn("multiversioning: upgrade polling and --experimental enabled - " ++ + "make sure to check CLI argument compatibility before upgrading.", .{}); + log.warn("If the cluster upgrades automatically, and incompatible experimental " ++ + "CLI arguments are set, it will crash.", .{}); + } + } + } + + // Note that this does not account for the fact that any allocations will be rounded up to + // the nearest page by `std.heap.page_allocator`. + log.info("{}: Allocated {}MiB during replica init", .{ + replica.replica, + @divFloor(counting_allocator.live_size(), MiB), + }); + log.info("{}: Grid cache: {}MiB, LSM-tree manifests: {}MiB", .{ + replica.replica, + @divFloor(grid_cache_size, MiB), + @divFloor(args.lsm_forest_node_count * constants.lsm_manifest_node_size, MiB), + }); + + log.info("{}: cluster={}: listening on {}", .{ + replica.replica, + replica.cluster, + replica.message_bus.accept_address.?, + }); + + if (args.aof_recovery) { + log.warn( + "{}: started in AOF recovery mode. This is potentially dangerous - if it's" ++ + " unexpected, please rerun without --aof-recovery flag.", + .{replica.replica}, + ); + } + + if (constants.verify) { + log.info("{}: started with extra verification checks", .{replica.replica}); + } + + if (replica.aof != null) { + log.warn( + "{}: started with --aof - expect much reduced performance.", + .{replica.replica}, + ); + } + + // It is possible to start tigerbeetle passing `0` as an address: + // $ tigerbeetle start --addresses=0 0_0.tigerbeetle + // This enables a couple of special behaviors, useful in tests: + // - The operating system picks a free port, avoiding "address already in use" errors. + // - The port, and only the port, is printed to the stdout, so that the parent process + // can learn it. + // - tigerbeetle process exits when its stdin gets closed. + if (args.addresses.zero) { + const port_actual = replica.message_bus.accept_address.?.port; + const stdout = std.io.getStdOut(); + try stdout.writer().print("{}\n", .{port_actual}); + stdout.close(); + + // While it is possible to integrate stdin with our io_uring loop, using a dedicated + // thread is simpler, and gives us _un_graceful shutdown, which is exactly what we want + // to keep behavior close to the normal case. + const watchdog = try std.Thread.spawn(.{}, struct { + fn thread_main() void { + var buf: [1]u8 = .{0}; + _ = std.io.getStdIn().read(&buf) catch {}; + log.info("stdin closed, exiting", .{}); + std.process.exit(0); + } + }.thread_main, .{}); + watchdog.detach(); + } + + if (!args.development) { + // Try to lock all memory in the process to avoid the kernel swapping pages to disk and + // potentially introducing undetectable disk corruption into memory. + // This is a best-effort attempt and not a hard rule as it may not cover all memory edge + // case. So warn on error to notify the operator to adjust conditions if possible. + stdx.memory_lock_allocated(.{ + .allocated_size = counting_allocator.live_size(), + }) catch { + log.warn( + "If this is a production replica, consider either " ++ + "running the replica with CAP_IPC_LOCK privilege, " ++ + "increasing the MEMLOCK process limit, " ++ + "or disabling swap system-wide.", + .{}, + ); + }; + + if (replica.cluster == 0) { + log.warn("A cluster id of 0 is reserved for testing and benchmarking, " ++ + "do not use in production.", .{}); + } + } + + if (config.process.release.testing()) { + if (replica.cluster != 0) { + // Guard against running test/dev binaries against production clusters. + @panic("This is a test binary, but the cluster id is non-zero."); + } + } + + // Enable tracing for the event loop. Internally, it'll emit its statistics every time + // run_for_ns() is called. + io.stats.tracer = tracer; + + while (true) { + replica.tick(); + try io.run_for_ns(constants.tick_ms * std.time.ns_per_ms); + } +} + +fn command_reformat( + gpa: mem.Allocator, + io: *IO, + time: Time, + storage: *Storage, + args: *const cli.Command.Recover, +) !void { + var message_pool = try MessagePool.init(gpa, .client); + defer message_pool.deinit(gpa); + + var client = try Client.init( + gpa, + time, + &message_pool, + .{ + .id = stdx.unique_u128(), + .cluster = args.cluster, + .replica_count = args.replica_count, + .aof_recovery = false, + + .message_bus_options = .{ + .configuration = args.addresses.slice(), + .io = io, + .trace = null, + .time = time, + .clients_limit = null, + }, + .eviction_callback = &reformat_client_eviction_callback, + }, + ); + defer client.deinit(gpa); + + var reformatter = try ReplicaReformat.init(gpa, &client, storage, .{ + .cluster = args.cluster, + .replica = args.replica, + .replica_count = args.replica_count, + .release = config.process.release, + .view = null, + }); + defer reformatter.deinit(gpa); + + reformatter.start(); + while (reformatter.pending()) { + client.tick(); + try io.run_for_ns(constants.tick_ms * std.time.ns_per_ms); + } + if (reformatter.format()) { + log.info("{}: success", .{args.replica}); + } else |err| { + log.err("{}: error: {s}", .{ args.replica, @errorName(err) }); + return err; + } +} + +fn reformat_client_eviction_callback( + client: *Client, + eviction: *const MessagePool.Message.Eviction, +) void { + _ = client; + std.debug.panic("error: client evicted: {s}", .{@tagName(eviction.header.reason)}); +} + +fn command_repl( + gpa: mem.Allocator, + io: *IO, + time: Time, + args: *const cli.Command.Repl, +) !void { + const Repl = vsr.repl.ReplType(vsr.message_bus.MessageBusType(IO)); + + var repl_instance = try Repl.init(gpa, io, time, .{ + .cluster_id = args.cluster, + .addresses = args.addresses.slice(), + .verbose = args.verbose, + }); + defer repl_instance.deinit(gpa); + + try repl_instance.run(args.statements); +} + +fn command_amqp(gpa: mem.Allocator, time: Time, args: *const cli.Command.AMQP) !void { + var runner: vsr.cdc.Runner = undefined; + try runner.init( + gpa, + time, + .{ + .cluster_id = args.cluster, + .addresses = args.addresses.slice(), + .host = args.host, + .user = args.user, + .password = args.password, + .vhost = args.vhost, + .publish_exchange = args.publish_exchange, + .publish_routing_key = args.publish_routing_key, + .event_count_max = args.event_count_max, + .idle_interval_ms = args.idle_interval_ms, + .requests_per_second_limit = args.requests_per_second_limit, + .amqp_timeout_seconds = args.amqp_timeout_seconds, + .tigerbeetle_timeout_seconds = args.tigerbeetle_timeout_seconds, + .recovery_mode = if (args.timestamp_last) |timestamp_last| + .{ .override = timestamp_last } + else + .recover, + }, + ); + defer runner.deinit(); + + while (true) { + runner.tick(); + } +} + +fn print_value( + writer: anytype, + field: []const u8, + value: anytype, +) !void { + if (@TypeOf(value) == ?[40]u8) { + assert(std.mem.eql(u8, field, "process.git_commit")); + return std.fmt.format(writer, "{s}=\"{?s}\"\n", .{ + field, + value, + }); + } + + switch (@typeInfo(@TypeOf(value))) { + .@"fn" => {}, // Ignore the log() function. + .pointer => try std.fmt.format(writer, "{s}=\"{s}\"\n", .{ + field, + std.fmt.fmtSliceEscapeLower(value), + }), + else => try std.fmt.format(writer, "{s}={any}\n", .{ + field, + value, + }), + } +} diff --git a/ocam/src/time.zig b/ocam/src/time.zig new file mode 100644 index 00000000..ba97b936 --- /dev/null +++ b/ocam/src/time.zig @@ -0,0 +1,241 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const stdx = @import("stdx"); + +const os = std.os; +const posix = std.posix; +const system = posix.system; +const assert = std.debug.assert; +const is_darwin = builtin.target.os.tag.isDarwin(); +const is_windows = builtin.target.os.tag == .windows; +const is_linux = builtin.target.os.tag == .linux; +const Instant = stdx.Instant; + +pub const TimeSim = @import("testing/time.zig").TimeSim; + +pub const Time = struct { + context: *anyopaque, + vtable: *const VTable, + + const VTable = struct { + monotonic: *const fn (*anyopaque) u64, + realtime: *const fn (*anyopaque) i64, + tick: *const fn (*anyopaque) void, + }; + + /// A timestamp to measure elapsed time, meaningful only on the same system, not across reboots. + /// Always use a monotonic timestamp if the goal is to measure elapsed time. + /// This clock is not affected by discontinuous jumps in the system time, for example if the + /// system administrator manually changes the clock. + pub fn monotonic(self: Time) Instant { + return .{ .ns = self.vtable.monotonic(self.context) }; + } + + /// A timestamp to measure real (i.e. wall clock) time, meaningful across systems, and reboots. + /// This clock is affected by discontinuous jumps in the system time. + pub fn realtime(self: Time) i64 { + return self.vtable.realtime(self.context); + } + + pub fn tick(self: Time) void { + self.vtable.tick(self.context); + } +}; + +pub const TimeOS = struct { + /// Hardware and/or software bugs can mean that the monotonic clock may regress. + /// One example (of many): https://bugzilla.redhat.com/show_bug.cgi?id=448449 + /// We crash the process for safety if this ever happens, to protect against infinite loops. + /// It's better to crash and come back with a valid monotonic clock than get stuck forever. + monotonic_guard: u64 = 0, + + pub fn time(self: *TimeOS) Time { + return .{ + .context = self, + .vtable = &.{ + .monotonic = vtable_monotonic, + .realtime = realtime, + .tick = tick, + }, + }; + } + + fn vtable_monotonic(context: *anyopaque) u64 { + const self: *TimeOS = @ptrCast(@alignCast(context)); + return self.monotonic().ns; + } + + pub fn monotonic(self: *TimeOS) Instant { + const m = blk: { + if (is_windows) break :blk monotonic_windows(); + if (is_darwin) break :blk monotonic_darwin(); + if (is_linux) break :blk monotonic_linux(); + @compileError("unsupported OS"); + }; + + // "Oops!...I Did It Again" + if (m < self.monotonic_guard) @panic("a hardware/kernel bug regressed the monotonic clock"); + self.monotonic_guard = m; + return .{ .ns = m }; + } + + fn monotonic_windows() u64 { + assert(is_windows); + // Uses QueryPerformanceCounter() on windows due to it being the highest precision timer + // available while also accounting for time spent suspended by default: + // + // https://docs.microsoft.com/en-us/windows/win32/api/realtimeapiset/nf-realtimeapiset-queryunbiasedinterrupttime#remarks + + // QPF need not be globally cached either as it ends up being a load from read-only memory + // mapped to all processed by the kernel called KUSER_SHARED_DATA (See "QpcFrequency") + // + // https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-kuser_shared_data + // https://www.geoffchappell.com/studies/windows/km/ntoskrnl/inc/api/ntexapi_x/kuser_shared_data/index.htm + const qpc = os.windows.QueryPerformanceCounter(); + const qpf = os.windows.QueryPerformanceFrequency(); + + // 10Mhz (1 qpc tick every 100ns) is a common QPF on modern systems. + // We can optimize towards this by converting to ns via a single multiply. + // + // https://github.com/microsoft/STL/blob/785143a0c73f030238ef618890fd4d6ae2b3a3a0/stl/inc/chrono#L694-L701 + const common_qpf = 10_000_000; + if (qpf == common_qpf) return qpc * (std.time.ns_per_s / common_qpf); + + // Convert qpc to nanos using fixed point to avoid expensive extra divs and + // overflow. + const scale = (std.time.ns_per_s << 32) / qpf; + return @as(u64, @truncate((@as(u96, qpc) * scale) >> 32)); + } + + fn monotonic_darwin() u64 { + assert(is_darwin); + // Uses mach_continuous_time() instead of mach_absolute_time() as it counts while suspended. + // + // https://developer.apple.com/documentation/kernel/1646199-mach_continuous_time + // https://opensource.apple.com/source/Libc/Libc-1158.1.2/gen/clock_gettime.c.auto.html + const darwin = struct { + const mach_timebase_info_t = system.mach_timebase_info_data; + extern "c" fn mach_timebase_info(info: *mach_timebase_info_t) system.kern_return_t; + extern "c" fn mach_continuous_time() u64; + }; + + // mach_timebase_info() called through libc already does global caching for us + // + // https://opensource.apple.com/source/xnu/xnu-7195.81.3/libsyscall/wrappers/mach_timebase_info.c.auto.html + var info: darwin.mach_timebase_info_t = undefined; + if (darwin.mach_timebase_info(&info) != 0) @panic("mach_timebase_info() failed"); + + const now = darwin.mach_continuous_time(); + return (now * info.numer) / info.denom; + } + + fn monotonic_linux() u64 { + assert(is_linux); + // The true monotonic clock on Linux is not in fact CLOCK_MONOTONIC: + // + // CLOCK_MONOTONIC excludes elapsed time while the system is suspended (e.g. VM migration). + // + // CLOCK_BOOTTIME is the same as CLOCK_MONOTONIC but includes elapsed time during a suspend. + // + // For more detail and why CLOCK_MONOTONIC_RAW is even worse than CLOCK_MONOTONIC, see + // https://github.com/ziglang/zig/pull/933#discussion_r656021295. + const ts: posix.timespec = posix.clock_gettime(posix.CLOCK.BOOTTIME) catch { + @panic("CLOCK_BOOTTIME required"); + }; + return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec)); + } + + fn realtime(_: *anyopaque) i64 { + if (is_windows) return realtime_windows(); + // macos has supported clock_gettime() since 10.12: + // https://opensource.apple.com/source/Libc/Libc-1158.1.2/gen/clock_gettime.3.auto.html + if (is_darwin or is_linux) return realtime_unix(); + @compileError("unsupported OS"); + } + + fn realtime_windows() i64 { + // TODO(zig): Maybe use `std.time.nanoTimestamp()`. + // https://github.com/ziglang/zig/pull/22871 + assert(is_windows); + var ft: os.windows.FILETIME = undefined; + stdx.windows.GetSystemTimePreciseAsFileTime(&ft); + const ft64 = (@as(u64, ft.dwHighDateTime) << 32) | ft.dwLowDateTime; + + // FileTime is in units of 100 nanoseconds + // and uses the NTFS/Windows epoch of 1601-01-01 instead of Unix Epoch 1970-01-01. + const epoch_adjust = std.time.epoch.windows * (std.time.ns_per_s / 100); + return (@as(i64, @bitCast(ft64)) + epoch_adjust) * 100; + } + + fn realtime_unix() i64 { + assert(is_darwin or is_linux); + const ts: posix.timespec = posix.clock_gettime(posix.CLOCK.REALTIME) catch unreachable; + return @as(i64, ts.sec) * std.time.ns_per_s + ts.nsec; + } + + fn tick(_: *anyopaque) void {} +}; + +test "Time monotonic smoke" { + var time_os: TimeOS = .{}; + const time = time_os.time(); + const instant_1 = time.monotonic(); + const instant_2 = time.monotonic(); + assert(instant_1.elapsed(instant_1).ns == 0); + assert(instant_1.elapsed(instant_2).ns >= 0); +} + +/// Equivalent to `std.time.Timer`, +/// but using the `vsr.Time` interface as the source of time. +pub const Timer = struct { + time: Time, + started: Instant, + + pub fn init(time: Time) Timer { + return .{ + .time = time, + .started = time.monotonic(), + }; + } + + /// Reads the timer value since start or the last reset. + pub fn read(self: *Timer) stdx.Duration { + const current = self.time.monotonic(); + assert(current.ns >= self.started.ns); + return self.started.elapsed(current); + } + + /// Resets the timer. + pub fn reset(self: *Timer) void { + const current = self.time.monotonic(); + assert(current.ns >= self.started.ns); + self.started = current; + } +}; + +const fixtures = @import("testing/fixtures.zig"); +const testing = std.testing; + +test Timer { + var time_sim = fixtures.init_time(.{ .resolution = 1 }); + const time = time_sim.time(); + + var timer = Timer.init(time); + // Repeat the cycle read/reset multiple times: + for (0..3) |_| { + const time_0 = timer.read(); + try testing.expectEqual(@as(u64, 0), time_0.ns); + time.tick(); + + const time_1 = timer.read(); + try testing.expectEqual(@as(u64, 1), time_1.ns); + time.tick(); + + const time_2 = timer.read(); + try testing.expectEqual(@as(u64, 2), time_2.ns); + time.tick(); + + timer.reset(); + } +} diff --git a/ocam/src/trace.zig b/ocam/src/trace.zig new file mode 100644 index 00000000..17bc1293 --- /dev/null +++ b/ocam/src/trace.zig @@ -0,0 +1,577 @@ +//! Log IO/CPU event spans for analysis/visualization. +//! +//! Example: +//! +//! $ ./tigerbeetle start --experimental --trace=trace.json +//! +//! or: +//! +//! $ ./tigerbeetle benchmark --trace=trace.json +//! +//! The trace JSON output is compatible with: +//! - https://ui.perfetto.dev/ +//! - https://gravitymoth.com/spall/spall.html +//! - chrome://tracing/ +//! +//! Example integrations: +//! +//! // Trace a synchronous event. +//! // The second argument is a `anytype` struct, corresponding to the struct argument to +//! // `log.debug()`. +//! tree.grid.trace.start(.{ .compact_mutable = .{ .tree = tree.config.name } }); +//! defer tree.grid.trace.stop(.{ .compact_mutable = .{ .tree = tree.config.name } }); +//! +//! Note that only one of each Event can be running at a time: +//! +//! // good +//! trace.start(.{.foo = .{}}); +//! trace.stop(.{ .foo = .{} }); +//! trace.start(.{ .bar = .{} }); +//! trace.stop(.{ .bar = .{} }); +//! +//! // good +//! trace.start(.{ .foo = .{} }); +//! trace.start(.{ .bar = .{} }); +//! trace.stop(.{ .foo = .{} }); +//! trace.stop(.{ .bar = .{} }); +//! +//! // bad +//! trace.start(.{ .foo = .{} }); +//! trace.start(.{ .foo = .{} }); +//! +//! // bad +//! trace.stop(.{ .foo = .{} }); +//! trace.start(.{ .foo = .{} }); +//! +//! If an event is is cancelled rather than properly stopped, use .reset(): +//! - Reset is safe to call regardless of whether the event is currently started. +//! - For events with multiple instances (e.g. IO reads and writes), .reset() will +//! cancel all running traces of the same event. +//! +//! // good +//! trace.start(.{ .foo = .{} }); +//! trace.cancel(.foo); +//! trace.start(.{ .foo = .{} }); +//! trace.stop(.{ .foo = .{} }); +//! +//! Notes: +//! - When enabled, traces are written to stdout (as opposed to logs, which are written to stderr). +//! - The JSON output is a "[" followed by a comma-separated list of JSON objects. The JSON array is +//! never closed with a "]", but Chrome, Spall, and Perfetto all handle this. +//! - Event pairing (start/stop) is asserted at runtime. +//! - `trace.start()/.stop()/.reset()` will `log.debug()` regardless of whether tracing is enabled. +//! +//! The JSON output looks like: +//! +//! { +//! // Process id: +//! // The replica index is encoded as the "process id" of trace events, so events from +//! // multiple replicas of a cluster can be unified to visualize them on the same timeline. +//! "pid": 0, +//! +//! // Thread id: +//! "tid": 0, +//! +//! // Category. +//! "cat": "replica_commit", +//! +//! // Phase. +//! "ph": "B", +//! +//! // Timestamp: +//! // Microseconds since program start. +//! "ts": 934327, +//! +//! // Event name: +//! // Includes the event name and a *low cardinality subset* of the second argument to +//! // `trace.start()`. (Low-cardinality part so that tools like Perfetto can distinguish +//! // events usefully.) +//! "name": "replica_commit stage='next_pipeline'", +//! +//! // Extra event arguments. (Encoded from the second argument to `trace.start()`). +//! "args": { +//! "stage": "next_pipeline", +//! "op": 1 +//! }, +//! }, +//! +const builtin = @import("builtin"); +const std = @import("std"); +const assert = std.debug.assert; +const log = std.log.scoped(.trace); + +const stdx = @import("stdx"); +const KiB = stdx.KiB; +const Duration = stdx.Duration; +const IO = @import("io.zig").IO; +const Time = @import("time.zig").Time; +const StatsD = @import("trace/statsd.zig").StatsD; +pub const Event = @import("trace/event.zig").Event; +pub const EventMetric = @import("trace/event.zig").EventMetric; +pub const EventTracing = @import("trace/event.zig").EventTracing; +pub const EventTiming = @import("trace/event.zig").EventTiming; +pub const EventTimingAggregate = @import("trace/event.zig").EventTimingAggregate; +pub const EventMetricAggregate = @import("trace/event.zig").EventMetricAggregate; + +const trace_span_size_max = 1 * KiB; + +pub const Tracer = @This(); + +time: Time, +process_id: ProcessID, +options: Options, +buffer: []u8, +statsd: StatsD, + +events_started: [EventTracing.stack_count]?stdx.Instant = @splat(null), +events_metric: []?EventMetricAggregate, +events_timing: []?EventTimingAggregate, + +time_start: stdx.Instant, + +log_trace: bool, + +pub const ProcessID = union(enum) { + unknown, + replica: struct { + cluster: u128, + replica: u8, + }, + + pub fn format( + self: ProcessID, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + try switch (self) { + .unknown => writer.writeByte('_'), + .replica => |replica| try writer.print("{d}", .{replica.replica}), + }; + } + + pub fn json(self: ProcessID) u8 { + return switch (self) { + .unknown => 0, + .replica => |replica| replica.replica, + }; + } +}; + +pub const Options = struct { + /// The tracer still validates start/stop state even when writer=null. + writer: ?std.io.AnyWriter = null, + statsd_options: union(enum) { + log, + udp: struct { + io: *IO, + address: stdx.SocketAddress, + }, + } = .log, + log_trace: bool = true, +}; + +pub fn init( + allocator: std.mem.Allocator, + time: Time, + process_id: ProcessID, + options: Options, +) !Tracer { + if (options.writer) |writer| { + try writer.writeAll("[\n"); + } + + const buffer = try allocator.alloc(u8, trace_span_size_max); + errdefer allocator.free(buffer); + + var statsd = try switch (options.statsd_options) { + .log => StatsD.init_log(allocator, process_id), + .udp => |statsd_options| StatsD.init_udp( + allocator, + process_id, + statsd_options.io, + statsd_options.address, + ), + }; + errdefer statsd.deinit(allocator); + + const events_metric = + try allocator.alloc(?EventMetricAggregate, EventMetric.slot_count); + errdefer allocator.free(events_metric); + @memset(events_metric, null); + + const events_timing = + try allocator.alloc(?EventTimingAggregate, EventTiming.slot_count); + errdefer allocator.free(events_timing); + @memset(events_timing, null); + + return .{ + .time = time, + .process_id = process_id, + .options = options, + .buffer = buffer, + .statsd = statsd, + + .events_metric = events_metric, + .events_timing = events_timing, + + .time_start = time.monotonic(), + + .log_trace = options.log_trace, + }; +} + +pub fn deinit(tracer: *Tracer, allocator: std.mem.Allocator) void { + allocator.free(tracer.events_timing); + allocator.free(tracer.events_metric); + tracer.statsd.deinit(allocator); + allocator.free(tracer.buffer); + tracer.* = undefined; +} + +/// We learn the cluster id and replica index after opening the datafile. +pub fn set_replica(tracer: *Tracer, options: struct { cluster: u128, replica: u8 }) void { + const process_id: ProcessID = .{ .replica = .{ + .cluster = options.cluster, + .replica = options.replica, + } }; + tracer.process_id = process_id; + tracer.statsd.process_id = process_id; +} + +/// Gauges work on a last-set wins. Multiple calls to .gauge() followed by an emit will / result in +/// only the last value being submitted. +// Takes an i65 to keep calling code simple: lots of places want to call this with a u64, and +// requiring an @intCast and checks at every call site is cumbersome. +pub fn gauge(tracer: *Tracer, event: EventMetric, value: i65) void { + const timing_slot = event.slot(); + tracer.events_metric[timing_slot] = .{ + .event = event, + .value = value, + }; +} + +/// Counters are cumulative values that only increase. +pub fn count(tracer: *Tracer, event: EventMetric, value: u64) void { + const timing_slot = event.slot(); + if (tracer.events_metric[timing_slot]) |*metric| { + metric.value +|= value; + } else { + tracer.events_metric[timing_slot] = .{ + .event = event, + .value = value, + }; + } +} + +pub fn start(tracer: *Tracer, event: Event) void { + const event_tracing = event.as(EventTracing); + const event_timing = event.as(EventTiming); + const stack = event_tracing.stack(); + + const time_now = tracer.time.monotonic(); + + assert(tracer.events_started[stack] == null); + tracer.events_started[stack] = time_now; + + if (event_tracing.aggregate_only()) { + return; + } + + if (tracer.log_trace) { + log.debug( + "{}: {s}({}): start: {}", + .{ tracer.process_id, @tagName(event), event_tracing, event_timing }, + ); + } + + const writer = tracer.options.writer orelse return; + const time_elapsed = tracer.time_start.elapsed(time_now); + + var buffer_stream = std.io.fixedBufferStream(tracer.buffer); + + // String tid's would be much more useful. + // They are supported by both Chrome and Perfetto, but rejected by Spall. + buffer_stream.writer().print("{{" ++ + "\"pid\":{[process_id]}," ++ + "\"tid\":{[thread_id]}," ++ + "\"ph\":\"{[event]c}\"," ++ + "\"ts\":{[timestamp]}," ++ + "\"cat\":\"{[category]s}\"," ++ + "\"name\":\"{[category]s} {[event_tracing]} {[event_timing]}\"," ++ + "\"args\":{[args]s}" ++ + "}},\n", .{ + .process_id = tracer.process_id.json(), + .thread_id = event_tracing.stack(), + .category = @tagName(event), + .event = 'B', + .timestamp = time_elapsed.to_us(), + .event_tracing = event_tracing, + .event_timing = event_timing, + .args = std.json.Formatter(Event){ .value = event, .options = .{} }, + }) catch { + log.err("{}: {s}({}): event too large: {}", .{ + tracer.process_id, + @tagName(event), + event_tracing, + event_timing, + }); + return; + }; + + writer.writeAll(buffer_stream.getWritten()) catch |err| { + std.debug.panic("Tracer.start: {}\n", .{err}); + }; +} + +pub fn stop(tracer: *Tracer, event: Event) void { + const us_log_threshold_ns = 5 * std.time.ns_per_ms; + + const event_tracing = event.as(EventTracing); + const event_timing = event.as(EventTiming); + const stack = event_tracing.stack(); + + const event_start = tracer.events_started[stack].?; + const event_end = tracer.time.monotonic(); + const event_duration = event_start.elapsed(event_end); + + assert(tracer.events_started[stack] != null); + tracer.events_started[stack] = null; + + tracer.timing(event_timing, event_duration); + + if (event_tracing.aggregate_only()) { + return; + } + + if (tracer.log_trace) { + // Double leading space to align with 'start: '. + log.debug("{}: {s}({}): stop: {} (duration={}{s})", .{ + tracer.process_id, + @tagName(event), + event_tracing, + event_timing, + if (event_duration.ns < us_log_threshold_ns) + event_duration.to_us() + else + event_duration.to_ms(), + if (event_duration.ns < us_log_threshold_ns) "us" else "ms", + }); + } + + tracer.write_stop(stack, tracer.time_start.elapsed(event_end)); +} + +pub fn cancel(tracer: *Tracer, event_tag: Event.Tag) void { + const stack_base = EventTracing.stack_bases.get(event_tag); + const cardinality = EventTracing.stack_limits.get(event_tag); + const event_end = tracer.time.monotonic(); + for (stack_base..stack_base + cardinality) |stack| { + if (tracer.events_started[stack]) |_| { + if (tracer.log_trace) { + log.debug("{}: {s}: cancel", .{ tracer.process_id, @tagName(event_tag) }); + } + + const event_duration = tracer.time_start.elapsed(event_end); + + tracer.events_started[stack] = null; + tracer.write_stop(@intCast(stack), event_duration); + } + } +} + +fn write_stop(tracer: *Tracer, stack: u32, time_elapsed: stdx.Duration) void { + const writer = tracer.options.writer orelse return; + var buffer_stream = std.io.fixedBufferStream(tracer.buffer); + + buffer_stream.writer().print( + "{{" ++ + "\"pid\":{[process_id]}," ++ + "\"tid\":{[thread_id]}," ++ + "\"ph\":\"{[event]c}\"," ++ + "\"ts\":{[timestamp]}" ++ + "}},\n", + .{ + .process_id = tracer.process_id.json(), + .thread_id = stack, + .event = 'E', + .timestamp = time_elapsed.to_us(), + }, + ) catch unreachable; + + writer.writeAll(buffer_stream.getWritten()) catch |err| { + std.debug.panic("Tracer.stop: {}\n", .{err}); + }; +} + +pub fn emit_metrics(tracer: *Tracer) void { + tracer.start(.metrics_emit); + defer tracer.stop(.metrics_emit); + + const metrics_statsd_packets = tracer.statsd.emit( + tracer.events_metric, + tracer.events_timing, + ) catch |err| switch (err) { + error.Busy, error.UnknownProcess => return, + }; + + // For statsd, the right thing is to reset metrics between emitting. For something like + // Prometheus, this would have to be removed. + @memset(tracer.events_metric, null); + @memset(tracer.events_timing, null); + + tracer.gauge(.metrics_statsd_packets, metrics_statsd_packets); +} + +// Timing works by storing the min, max, sum and count of each value provided. The avg is calculated +// from sum and count at emit time. +// +// When these are emitted upstream (via statsd, currently), upstream must apply different +// aggregations: +// * min/max/avg are considered gauges for aggregation: last value wins. +// * sum/count are considered counters for aggregation: they are added to the existing values. +// +// This matches the default behavior of the `g` and `c` statsd types respectively. +pub fn timing(tracer: *Tracer, event_timing: EventTiming, duration: Duration) void { + const timing_slot = event_timing.slot(); + + tracer.timing_warn(event_timing, duration); + + if (tracer.events_timing[timing_slot]) |*event_timing_existing| { + assert(std.meta.eql(event_timing_existing.event, event_timing)); + + const timing_existing = event_timing_existing.values; + event_timing_existing.values = .{ + .duration_min = timing_existing.duration_min.min(duration), + .duration_max = timing_existing.duration_max.max(duration), + .duration_sum = .{ .ns = timing_existing.duration_sum.ns +| duration.ns }, + .count = timing_existing.count +| 1, + }; + } else { + tracer.events_timing[timing_slot] = .{ + .event = event_timing, + .values = .{ + .duration_min = duration, + .duration_max = duration, + .duration_sum = duration, + .count = 1, + }, + }; + } +} + +/// Log warnings for slow timings, to have redundancy with metrics. +/// Perhaps thresholds should be runtime-configurable in main, but let's simply hard-code for now. +pub fn timing_warn(tracer: *Tracer, event_timing: EventTiming, duration: Duration) void { + const fast = comptime builtin.target.os.tag == .linux and builtin.mode != .Debug; + const threshold: Duration = switch (event_timing) { + .loop_run_for_ns => if (fast) .ms(50) else .ms(500), + else => return, + }; + if (duration.ns >= threshold.ns) { + log.warn("{}: timing: {s} too slow ({} > {})", .{ + tracer.process_id, + @tagName(event_timing), + duration, + threshold, + }); + } +} + +const fixtures = @import("testing/fixtures.zig"); + +test "trace json and statsd" { + const Snap = stdx.Snap; + const snap = Snap.snap_fn("src"); + const gpa = std.testing.allocator; + + var trace_buffer: std.ArrayListUnmanaged(u8) = .empty; + defer trace_buffer.deinit(gpa); + + var time_sim = fixtures.init_time(.{}); + + var trace = try fixtures.init_tracer(gpa, time_sim.time(), .{ + .writer = trace_buffer.writer(gpa).any(), + .process_id = .unknown, + }); + defer trace.deinit(gpa); + + // Check that JSON is valid even while process id not known. + trace.start(.metrics_emit); + time_sim.ticks += 10; + trace.stop(.metrics_emit); + + trace.set_replica(.{ .cluster = 1, .replica = 1 }); + + trace.start(.{ .replica_commit = .{ .stage = .idle, .op = 123 } }); + time_sim.ticks += 1; + trace.start(.{ .compact_beat = .{ .tree = @enumFromInt(1), .level_b = 1 } }); + time_sim.ticks += 2; + trace.stop(.{ .compact_beat = .{ .tree = @enumFromInt(1), .level_b = 1 } }); + time_sim.ticks += 3; + trace.stop(.{ .replica_commit = .{ .stage = .idle, .op = 456 } }); + + try snap(@src(), + \\[ + \\{"pid":0,"tid":136,"ph":"B","ts":0,"cat":"metrics_emit","name":"metrics_emit ","args":""}, + \\{"pid":0,"tid":136,"ph":"E","ts":100000}, + \\{"pid":1,"tid":0,"ph":"B","ts":100000,"cat":"replica_commit","name":"replica_commit stage=idle","args":{"stage":"idle","op":123}}, + \\{"pid":1,"tid":8,"ph":"B","ts":110000,"cat":"compact_beat","name":"compact_beat tree=Account.id","args":{"tree":"Account.id","level_b":1}}, + \\{"pid":1,"tid":8,"ph":"E","ts":130000}, + \\{"pid":1,"tid":0,"ph":"E","ts":160000}, + \\ + ).diff(trace_buffer.items); + + trace.start(.metrics_emit); + time_sim.ticks += 1; + trace.stop(.metrics_emit); + + trace.start(.metrics_emit); + time_sim.ticks += 5; + trace.stop(.metrics_emit); + + trace.emit_metrics(); + + try snap(@src(), + \\tb.replica_commit_us.min:60000|g|#cluster:00000000000000000000000000000001,replica:1,stage:idle + \\tb.replica_commit_us.max:60000|g|#cluster:00000000000000000000000000000001,replica:1,stage:idle + \\tb.replica_commit_us.avg:60000|g|#cluster:00000000000000000000000000000001,replica:1,stage:idle + \\tb.replica_commit_us.sum:60000|c|#cluster:00000000000000000000000000000001,replica:1,stage:idle + \\tb.replica_commit_us.count:1|c|#cluster:00000000000000000000000000000001,replica:1,stage:idle + \\tb.compact_beat_us.min:20000|g|#cluster:00000000000000000000000000000001,replica:1,tree:Account.id + \\tb.compact_beat_us.max:20000|g|#cluster:00000000000000000000000000000001,replica:1,tree:Account.id + \\tb.compact_beat_us.avg:20000|g|#cluster:00000000000000000000000000000001,replica:1,tree:Account.id + \\tb.compact_beat_us.sum:20000|c|#cluster:00000000000000000000000000000001,replica:1,tree:Account.id + \\tb.compact_beat_us.count:1|c|#cluster:00000000000000000000000000000001,replica:1,tree:Account.id + \\tb.metrics_emit_us.min:10000|g|#cluster:00000000000000000000000000000001,replica:1 + \\tb.metrics_emit_us.max:100000|g|#cluster:00000000000000000000000000000001,replica:1 + \\tb.metrics_emit_us.avg:53333|g|#cluster:00000000000000000000000000000001,replica:1 + \\tb.metrics_emit_us.sum:160000|c|#cluster:00000000000000000000000000000001,replica:1 + \\tb.metrics_emit_us.count:3|c|#cluster:00000000000000000000000000000001,replica:1 + \\ + ).diff(trace.statsd.log_buffer.?.items); +} + +test "timing overflow" { + const gpa = std.testing.allocator; + + var time_sim = fixtures.init_time(.{}); + var trace = try fixtures.init_tracer(gpa, time_sim.time(), .{}); + defer trace.deinit(gpa); + + trace.set_replica(.{ .cluster = 0, .replica = 0 }); + + const event: EventTiming = .replica_aof_write; + const value: Duration = .{ .ns = std.math.maxInt(u64) - 1 }; + trace.timing(event, value); + trace.timing(event, value); + + const aggregate = trace.events_timing[event.slot()].?; + + assert(aggregate.values.count == 2); + assert(aggregate.values.duration_min.ns == value.ns); + assert(aggregate.values.duration_max.ns == value.ns); + assert(aggregate.values.duration_sum.ns == std.math.maxInt(u64)); +} diff --git a/ocam/src/trace/event.zig b/ocam/src/trace/event.zig new file mode 100644 index 00000000..e6646dfe --- /dev/null +++ b/ocam/src/trace/event.zig @@ -0,0 +1,849 @@ +const std = @import("std"); +const stdx = @import("stdx"); +const assert = std.debug.assert; + +const constants = @import("../constants.zig"); + +const vsr = @import("../vsr.zig"); +const Command = vsr.Command; +const Peer = vsr.Peer; +const Zone = vsr.Zone; +const CommitStage = @import("../vsr/replica.zig").CommitStage; +const tigerbeetle = @import("../tigerbeetle.zig"); +const Duration = stdx.Duration; + +const Operation = operation_enum: { + var operation_fields: []const std.builtin.Type.EnumField = &[_]std.builtin.Type.EnumField{}; + + for (.{ vsr.Operation, tigerbeetle.Operation }, 0..) |Operation_, i| { + for (std.meta.fieldNames(Operation_)) |field_name| { + if (i == 1 and std.mem.eql(u8, field_name, "pulse")) { + // Pulse is included by both Operation types. + continue; + } + operation_fields = operation_fields ++ &[_]std.builtin.Type.EnumField{.{ + .name = "Operation." ++ field_name, + .value = @intFromEnum(@field(Operation_, field_name)), + }}; + } + } + + break :operation_enum @Type(.{ .@"enum" = .{ + .tag_type = u8, + .fields = operation_fields, + .decls = &.{}, + .is_exhaustive = true, + } }); +}; + +const TreeEnum = tree_enum: { + const tree_ids = @import("../state_machine.zig").tree_ids; + var tree_fields: []const std.builtin.Type.EnumField = &[_]std.builtin.Type.EnumField{}; + + for (std.meta.declarations(tree_ids)) |groove_field| { + const tree_ids_groove = @field(tree_ids, groove_field.name); + for (std.meta.fieldNames(@TypeOf(tree_ids_groove))) |field_name| { + tree_fields = tree_fields ++ &[_]std.builtin.Type.EnumField{.{ + .name = groove_field.name ++ "." ++ field_name, + .value = @field(tree_ids_groove, field_name), + }}; + } + } + + break :tree_enum @Type(.{ .@"enum" = .{ + .tag_type = u32, + .fields = tree_fields, + .decls = &.{}, + .is_exhaustive = true, + } }); +}; + +const GrooveEnum = groove_enum: { + const tree_ids = @import("../state_machine.zig").tree_ids; + var groove_fields: []const std.builtin.Type.EnumField = &[_]std.builtin.Type.EnumField{}; + + for (std.meta.declarations(tree_ids)) |groove_field| { + const tree_ids_groove = @field(tree_ids, groove_field.name); + groove_fields = groove_fields ++ &[_]std.builtin.Type.EnumField{.{ + .name = groove_field.name, + .value = @field(tree_ids_groove, "timestamp"), + }}; + } + + break :groove_enum @Type(.{ .@"enum" = .{ + .tag_type = u32, + .fields = groove_fields, + .decls = &.{}, + .is_exhaustive = true, + } }); +}; + +/// Returns the count of an exhaustive enum. +fn enum_count(EnumOrUnion: type) u8 { + const type_info = @typeInfo(EnumOrUnion); + assert(type_info == .@"enum" or type_info == .@"union"); + + const Enum = if (type_info == .@"enum") + type_info.@"enum" + else + @typeInfo(type_info.@"union".tag_type.?).@"enum"; + assert(Enum.is_exhaustive); + + return Enum.fields.len; +} + +/// Maps an exhaustive enum value from an enum type that might potentially start with a non-zero +/// value or be sparse to a continuous index that fits within enum_count(). +fn index_from_enum(enum_tag: anytype) u8 { + const type_info = @typeInfo(@TypeOf(enum_tag)); + assert(type_info == .@"enum" or type_info == .@"union"); + + const Enum = if (type_info == .@"enum") + type_info.@"enum" + else + @typeInfo(type_info.@"union".tag_type.?).@"enum"; + assert(Enum.is_exhaustive); + + inline for (Enum.fields, 0..) |enum_field, i| { + if (enum_field.value == @intFromEnum(enum_tag)) { + return i; + } + } else unreachable; +} + +const EventOperationData = struct { + operation: Operation, + + pub fn from(operation: vsr.Operation) EventOperationData { + assert(operation.valid(tigerbeetle.Operation)); + return .{ .operation = @enumFromInt(@intFromEnum(operation)) }; + } +}; + +// TODO: It should be possible to get rid of all unbounded cardinality (eg, level_b being a u8) and +// replace them with enums instead. This would allow for calculating the stack limit automatically. + +/// Base {Timing,Tracing} Event. This is further split up into two different Events that share the +/// same tag: ones for timing and ones for tracing. +/// +/// This is because there's a difference between tracing and aggregate timing. When doing tracing, +/// the code needs to worry about the static allocation required for _concurrent_ traces. That is, +/// there might be multiple `scan_tree`s, with different `index`es happening at once. +/// +/// When timing, this is flipped on its head: the timing code doesn't need space for concurrency +/// because it is called once, when an event has finished, and internally aggregates. The +/// aggregation is needed because there can be an unknown number of calls between flush intervals, +/// compared to tracing which is emitted as it happens. +/// +/// Rather, it needs space for the cardinality of the tags you'd like to emit. In the case of +/// `scan_tree`s, this would be the tree it's scanning over, instead of the index of the scan. +pub const Event = union(enum) { + replica_commit: struct { stage: CommitStage.Tag, op: ?usize = null }, + replica_aof_write: struct { op: usize }, + replica_aof_checkpoint, + replica_sync_table: struct { index: usize }, + replica_request: EventOperationData, + replica_request_execute: EventOperationData, + replica_request_local: EventOperationData, + + compact_beat: struct { tree: TreeEnum, level_b: u8 }, + compact_beat_merge: struct { tree: TreeEnum, level_b: u8 }, + compact_manifest, + compact_mutable: struct { tree: TreeEnum }, + compact_mutable_suffix: struct { tree: TreeEnum }, + + lookup: struct { tree: TreeEnum }, + lookup_worker: struct { index: u8, tree: TreeEnum }, + + scan_tree: struct { index: u8, tree: TreeEnum }, + scan_tree_level: struct { index: u8, tree: TreeEnum, level: u8 }, + + grid_read: struct { iop: usize }, + grid_write: struct { iop: usize }, + storage_read: struct { zone: Zone }, + storage_write: struct { zone: Zone }, + + metrics_emit, + + client_request_round_trip: EventOperationData, + + loop_run_for_ns, + loop_tick, + loop_callbacks, + loop_kernel, + + pub const Tag = std.meta.Tag(Event); + + /// Normally, Zig would stringify a union(enum) like this as `{"compact_beat": {"tree": ...}}`. + /// Remove this extra layer of indirection. + pub fn jsonStringify(event: Event, jw: anytype) !void { + switch (event) { + inline else => |payload, tag| { + if (@TypeOf(payload) == void) { + try jw.write(""); + } else if (tag == .replica_commit) { + try jw.write(.{ .stage = @tagName(payload.stage), .op = payload.op }); + } else { + try jw.write(payload); + } + }, + } + } + + /// Convert the base event to an EventTiming or EventMetric. + pub fn as(event: *const Event, EventType: type) EventType { + @setEvalBranchQuota(32_000); + return switch (event.*) { + inline else => |source_payload, tag| { + const TargetPayload = @FieldType(EventType, @tagName(tag)); + const target_payload_info = @typeInfo(TargetPayload); + assert(target_payload_info == .void or target_payload_info == .@"struct"); + + const target_payload: TargetPayload = switch (@typeInfo(TargetPayload)) { + .void => {}, + .@"struct" => blk: { + var target_payload: TargetPayload = undefined; + inline for (comptime std.meta.fieldNames(TargetPayload)) |field| { + @field(target_payload, field) = @field(source_payload, field); + } + break :blk target_payload; + }, + else => unreachable, + }; + + return @unionInit(EventType, @tagName(tag), target_payload); + }, + }; + } +}; + +pub const EventTiming = union(Event.Tag) { + replica_commit: struct { stage: CommitStage.Tag }, + replica_aof_write, + replica_aof_checkpoint, + replica_sync_table, + replica_request: EventOperationData, + replica_request_execute: EventOperationData, + replica_request_local: EventOperationData, + + compact_beat: struct { tree: TreeEnum }, + compact_beat_merge: struct { tree: TreeEnum }, + compact_manifest, + compact_mutable: struct { tree: TreeEnum }, + compact_mutable_suffix: struct { tree: TreeEnum }, + + lookup: struct { tree: TreeEnum }, + lookup_worker: struct { tree: TreeEnum }, + + scan_tree: struct { tree: TreeEnum }, + scan_tree_level: struct { tree: TreeEnum }, + + grid_read, + grid_write, + storage_read: struct { zone: Zone }, + storage_write: struct { zone: Zone }, + + metrics_emit, + + client_request_round_trip: EventOperationData, + + loop_run_for_ns, + loop_tick, + loop_callbacks, + loop_kernel, + + pub const slot_limits = std.enums.EnumArray(Event.Tag, u32).init(.{ + .replica_commit = enum_count(CommitStage.Tag), + .replica_aof_write = 1, + .replica_aof_checkpoint = 1, + .replica_sync_table = 1, + .replica_request = enum_count(Operation), + .replica_request_execute = enum_count(Operation), + .replica_request_local = enum_count(Operation), + .compact_beat = enum_count(TreeEnum), + .compact_beat_merge = enum_count(TreeEnum), + .compact_manifest = 1, + .compact_mutable = enum_count(TreeEnum), + .compact_mutable_suffix = enum_count(TreeEnum), + .lookup = enum_count(TreeEnum), + .lookup_worker = enum_count(TreeEnum), + .scan_tree = enum_count(TreeEnum), + .scan_tree_level = enum_count(TreeEnum), + .grid_read = 1, + .grid_write = 1, + .metrics_emit = 1, + .storage_read = enum_count(Zone), + .storage_write = enum_count(Zone), + .client_request_round_trip = enum_count(Operation), + .loop_run_for_ns = 1, + .loop_tick = 1, + .loop_callbacks = 1, + .loop_kernel = 1, + }); + + pub const slot_bases = array: { + var array = std.enums.EnumArray(Event.Tag, u32).initFill(0); + var next: u32 = 0; + for (std.enums.values(Event.Tag)) |event_type| { + array.set(event_type, next); + next += slot_limits.get(event_type); + } + break :array array; + }; + + pub const slot_count = count: { + var count: u32 = 0; + for (std.enums.values(Event.Tag)) |event_type| { + count += slot_limits.get(event_type); + } + break :count count; + }; + + // Unlike with EventTracing, which neatly organizes related events underneath one another, the + // order here does not matter. + // + // TODO: This could be computed automatically, at least in the cases where all, or all except + // one of the values are enums. + pub fn slot(event: *const EventTiming) u32 { + switch (event.*) { + // Single payload: CommitStage.Tag + inline .replica_commit => |data| { + const stage = index_from_enum(data.stage); + assert(stage < slot_limits.get(event.*)); + + return slot_bases.get(event.*) + stage; + }, + // Single payload: Operation + inline .replica_request, + .replica_request_execute, + .replica_request_local, + .client_request_round_trip, + => |data| { + const operation = index_from_enum(data.operation); + assert(operation < slot_limits.get(event.*)); + + return slot_bases.get(event.*) + operation; + }, + // Single payload: TreeEnum + inline .compact_mutable, + .compact_mutable_suffix, + .lookup, + .lookup_worker, + .scan_tree, + => |data| { + const tree_id = index_from_enum(data.tree); + assert(tree_id < slot_limits.get(event.*)); + + return slot_bases.get(event.*) + tree_id; + }, + inline .compact_beat, .compact_beat_merge => |data| { + const tree_id = index_from_enum(data.tree); + const offset = tree_id; + assert(offset < slot_limits.get(event.*)); + + return slot_bases.get(event.*) + offset; + }, + inline .scan_tree_level => |data| { + const tree_id = index_from_enum(data.tree); + const offset = tree_id; + assert(offset < slot_limits.get(event.*)); + + return slot_bases.get(event.*) + offset; + }, + inline .storage_read, .storage_write => |data| { + const zone = index_from_enum(data.zone); + const offset = zone; + assert(offset < slot_limits.get(event.*)); + + return slot_bases.get(event.*) + offset; + }, + inline else => |data, event_tag| { + comptime assert(@TypeOf(data) == void); + comptime assert(slot_limits.get(event_tag) == 1); + + return comptime slot_bases.get(event_tag); + }, + } + } + + pub fn format( + event: *const EventTiming, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + + switch (event.*) { + inline else => |data| { + try format_data(data, writer); + }, + } + } +}; + +pub const EventTracing = union(Event.Tag) { + replica_commit, + replica_aof_write, + replica_aof_checkpoint, + replica_sync_table: struct { index: usize }, + replica_request, + replica_request_execute, + replica_request_local, + + compact_beat, + compact_beat_merge, + compact_manifest, + compact_mutable, + compact_mutable_suffix, + + lookup, + lookup_worker: struct { index: u8 }, + + scan_tree: struct { index: u8 }, + scan_tree_level: struct { index: u8, level: u8 }, + + grid_read: struct { iop: usize }, + grid_write: struct { iop: usize }, + storage_read, + storage_write, + + metrics_emit, + + client_request_round_trip, + + loop_run_for_ns, + loop_tick, + loop_callbacks, + loop_kernel, + + pub const stack_limits = std.enums.EnumArray(Event.Tag, u32).init(.{ + .replica_commit = 1, + .replica_aof_write = 1, + .replica_aof_checkpoint = 1, + .replica_sync_table = constants.grid_missing_tables_max, + .replica_request = 1, + .replica_request_execute = 1, + .replica_request_local = 1, + .compact_beat = 1, + .compact_beat_merge = 1, + .compact_manifest = 1, + .compact_mutable = 1, + .compact_mutable_suffix = 1, + .lookup = 1, + .lookup_worker = constants.grid_iops_read_max, + .scan_tree = constants.lsm_scans_max, + .scan_tree_level = constants.lsm_scans_max * @as(u32, constants.lsm_levels), + .grid_read = constants.grid_iops_read_max, + .grid_write = constants.grid_iops_write_max, + .storage_read = 1, + .storage_write = 1, + .metrics_emit = 1, + .client_request_round_trip = 1, + .loop_run_for_ns = 1, + .loop_tick = 1, + .loop_callbacks = 1, + .loop_kernel = 1, + }); + + pub const stack_bases = array: { + var array = std.enums.EnumArray(Event.Tag, u32).initDefault(0, .{}); + var next: u32 = 0; + for (std.enums.values(Event.Tag)) |event_type| { + array.set(event_type, next); + next += stack_limits.get(event_type); + } + break :array array; + }; + + pub const stack_count = count: { + var count: u32 = 0; + for (std.enums.values(Event.Tag)) |event_type| { + count += stack_limits.get(event_type); + } + break :count count; + }; + + // Stack is a u32 since it must be losslessly encoded as a JSON integer. + pub fn stack(event: *const EventTracing) u32 { + switch (event.*) { + inline .replica_sync_table, + .lookup_worker, + => |data| { + assert(data.index < stack_limits.get(event.*)); + const stack_base = stack_bases.get(event.*); + return stack_base + @as(u32, @intCast(data.index)); + }, + .scan_tree => |data| { + assert(data.index < constants.lsm_scans_max); + // This event has "nested" sub-events, so its offset is calculated + // with padding to accommodate `scan_tree_level` events in between. + const stack_base = stack_bases.get(event.*); + const scan_tree_offset = (constants.lsm_levels + 1) * data.index; + return stack_base + scan_tree_offset; + }, + .scan_tree_level => |data| { + assert(data.index < constants.lsm_scans_max); + assert(data.level < constants.lsm_levels); + // This is a "nested" event, so its offset is calculated + // relative to the parent `scan_tree`'s offset. + const stack_base = stack_bases.get(.scan_tree); + const scan_tree_offset = (constants.lsm_levels + 1) * data.index; + const scan_tree_level_offset = data.level + 1; + return stack_base + scan_tree_offset + scan_tree_level_offset; + }, + inline .grid_read, .grid_write => |data| { + assert(data.iop < stack_limits.get(event.*)); + const stack_base = stack_bases.get(event.*); + return stack_base + @as(u32, @intCast(data.iop)); + }, + inline else => |data, event_tag| { + comptime assert(@TypeOf(data) == void); + comptime assert(stack_limits.get(event_tag) == 1); + return comptime stack_bases.get(event_tag); + }, + } + } + + pub fn format( + event: *const EventTracing, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + + switch (event.*) { + inline else => |data| { + try format_data(data, writer); + }, + } + } + + // Some traces are very frequent, and would otherwise drown out useful information. These can be + // captured in aggregate only, meaning that the aggregate timing statistics will still be + // captured, but no per trace logs or JSON will be emitted. + pub fn aggregate_only(event: *const EventTracing) bool { + return switch (event.*) { + .loop_run_for_ns, + .loop_tick, + .loop_callbacks, + .loop_kernel, + => true, + else => false, + }; + } +}; + +pub const EventMetric = union(enum) { + const Tag = std.meta.Tag(EventMetric); + + table_count_visible: struct { tree: TreeEnum }, + table_count_visible_max: struct { tree: TreeEnum }, + value_count_visible: struct { tree: TreeEnum }, + replica_start, + replica_status, + replica_view, + replica_log_view, + replica_op, + replica_op_checkpoint, + replica_commit_min, + replica_commit_max, + replica_commit_timestamp, + replica_pipeline_queue_length, + replica_sync_stage, + replica_sync_op_min, + replica_sync_op_max, + replica_messages_in: struct { command: Command }, + replica_messages_out: struct { command: Command }, + journal_dirty, + journal_faulty, + grid_blocks_acquired, + grid_blocks_missing, + grid_cache_hits, + grid_cache_misses, + lsm_object_cache_entries: struct { groove: GrooveEnum }, + lsm_object_cache_entries_max: struct { groove: GrooveEnum }, + lsm_nodes_free, + lsm_manifest_block_count, + metrics_statsd_packets, + release, + release_seen_client_min, + release_seen_client_max, + clock_delta_ns, + message_bus_connections: struct { peer: std.meta.Tag(Peer) }, + message_bus_connections_max, + compaction_values_physical: struct { tree: TreeEnum }, + compaction_values_logical: struct { tree: TreeEnum }, + + loop_syscalls, + + pub const slot_limits = std.enums.EnumArray(Tag, u32).init(.{ + .table_count_visible = enum_count(TreeEnum), + .table_count_visible_max = enum_count(TreeEnum), + .value_count_visible = enum_count(TreeEnum), + .replica_start = 1, + .replica_status = 1, + .replica_view = 1, + .replica_log_view = 1, + .replica_op = 1, + .replica_op_checkpoint = 1, + .replica_commit_min = 1, + .replica_commit_max = 1, + .replica_commit_timestamp = 1, + .replica_pipeline_queue_length = 1, + .replica_sync_stage = 1, + .replica_sync_op_min = 1, + .replica_sync_op_max = 1, + .replica_messages_in = enum_count(Command), + .replica_messages_out = enum_count(Command), + .journal_dirty = 1, + .journal_faulty = 1, + .grid_blocks_acquired = 1, + .grid_blocks_missing = 1, + .grid_cache_hits = 1, + .grid_cache_misses = 1, + .lsm_object_cache_entries = enum_count(GrooveEnum), + .lsm_object_cache_entries_max = enum_count(GrooveEnum), + .lsm_nodes_free = 1, + .lsm_manifest_block_count = 1, + .metrics_statsd_packets = 1, + .release = 1, + .release_seen_client_min = 1, + .release_seen_client_max = 1, + .clock_delta_ns = 1, + .message_bus_connections = enum_count(Peer), + .message_bus_connections_max = 1, + .compaction_values_physical = enum_count(TreeEnum), + .compaction_values_logical = enum_count(TreeEnum), + .loop_syscalls = 1, + }); + + pub const slot_bases = array: { + var array = std.enums.EnumArray(Tag, u32).initDefault(0, .{}); + var next: u32 = 0; + for (std.enums.values(Tag)) |event_type| { + array.set(event_type, next); + next += slot_limits.get(event_type); + } + break :array array; + }; + + pub const slot_count = count: { + var count: u32 = 0; + for (std.enums.values(Tag)) |event_type| { + count += slot_limits.get(event_type); + } + break :count count; + }; + + pub fn slot(event: *const EventMetric) u32 { + switch (event.*) { + inline .table_count_visible, + .table_count_visible_max, + .value_count_visible, + .compaction_values_physical, + .compaction_values_logical, + => |data| { + const tree_id = index_from_enum(data.tree); + const offset = tree_id; + assert(offset < slot_limits.get(event.*)); + + return slot_bases.get(event.*) + offset; + }, + inline .lsm_object_cache_entries, + .lsm_object_cache_entries_max, + => |data| { + const groove = index_from_enum(data.groove); + const offset = groove; + assert(offset < slot_limits.get(event.*)); + + return slot_bases.get(event.*) + offset; + }, + inline .replica_messages_in, .replica_messages_out => |data| { + const command = index_from_enum(data.command); + const offset = command; + assert(offset < slot_limits.get(event.*)); + + return slot_bases.get(event.*) + offset; + }, + inline .message_bus_connections => |data| { + const peer = index_from_enum(data.peer); + const offset = peer; + assert(offset < slot_limits.get(event.*)); + + return slot_bases.get(event.*) + offset; + }, + else => { + return slot_bases.get(event.*); + }, + } + } +}; + +pub fn format_data( + data: anytype, + writer: anytype, +) !void { + const Data = @TypeOf(data); + if (Data == void) return; + + const fields = std.meta.fields(Data); + inline for (fields, 0..) |data_field, i| { + assert(data_field.type == bool or + @typeInfo(data_field.type) == .int or + @typeInfo(data_field.type) == .@"enum" or + @typeInfo(data_field.type) == .@"union"); + + const data_field_value = @field(data, data_field.name); + try writer.writeAll(data_field.name); + try writer.writeByte('='); + + if (@typeInfo(data_field.type) == .@"enum" or + @typeInfo(data_field.type) == .@"union") + { + try writer.print("{s}", .{@tagName(data_field_value)}); + } else { + try writer.print("{}", .{data_field_value}); + } + + if (i != fields.len - 1) { + try writer.writeByte(' '); + } + } +} + +pub const EventTimingAggregate = struct { + event: EventTiming, + values: struct { + duration_min: Duration, + duration_max: Duration, + duration_sum: Duration, + + count: u64, + }, +}; + +pub const EventMetricAggregate = struct { + pub const ValueType = i65; + + event: EventMetric, + value: ValueType, +}; + +test "EventMetric slot doesn't have collisions" { + const allocator = std.testing.allocator; + var stacks: std.ArrayListUnmanaged(u32) = .{}; + defer stacks.deinit(allocator); + + var g: @import("../testing/exhaustigen.zig") = .{}; + while (!g.done()) { + const event: EventMetric = switch (g.enum_value(EventMetric.Tag)) { + .table_count_visible => .{ .table_count_visible = .{ + .tree = g.enum_value(TreeEnum), + } }, + .table_count_visible_max => .{ .table_count_visible_max = .{ + .tree = g.enum_value(TreeEnum), + } }, + .value_count_visible => .{ .value_count_visible = .{ + .tree = g.enum_value(TreeEnum), + } }, + .lsm_object_cache_entries => .{ .lsm_object_cache_entries = .{ + .groove = g.enum_value(GrooveEnum), + } }, + .lsm_object_cache_entries_max => .{ .lsm_object_cache_entries_max = .{ + .groove = g.enum_value(GrooveEnum), + } }, + .compaction_values_physical => .{ .compaction_values_physical = .{ + .tree = g.enum_value(TreeEnum), + } }, + .compaction_values_logical => .{ .compaction_values_logical = .{ + .tree = g.enum_value(TreeEnum), + } }, + .replica_messages_in => .{ .replica_messages_in = .{ + .command = g.enum_value(Command), + } }, + .replica_messages_out => .{ .replica_messages_out = .{ + .command = g.enum_value(Command), + } }, + .message_bus_connections => .{ .message_bus_connections = .{ + .peer = g.enum_value(std.meta.Tag(Peer)), + } }, + inline else => |tag| tag, + }; + try stacks.append(allocator, event.slot()); + } + for (0..stacks.items.len) |i| { + for (0..i) |j| { + assert(stacks.items[i] != stacks.items[j]); + } + } +} + +test "EventTiming slot doesn't have collisions" { + const allocator = std.testing.allocator; + var stacks: std.ArrayListUnmanaged(u32) = .{}; + defer stacks.deinit(allocator); + + var g: @import("../testing/exhaustigen.zig") = .{}; + while (!g.done()) { + const event: EventTiming = switch (g.enum_value(Event.Tag)) { + .replica_commit => .{ .replica_commit = .{ .stage = g.enum_value(CommitStage.Tag) } }, + .replica_aof_write => .replica_aof_write, + .replica_aof_checkpoint => .replica_aof_checkpoint, + .replica_sync_table => .replica_sync_table, + .replica_request => .{ .replica_request = .{ .operation = g.enum_value(Operation) } }, + .replica_request_execute => .{ .replica_request_execute = .{ + .operation = g.enum_value(Operation), + } }, + .replica_request_local => .{ .replica_request_local = .{ + .operation = g.enum_value(Operation), + } }, + .compact_beat => .{ .compact_beat = .{ + .tree = g.enum_value(TreeEnum), + } }, + .compact_beat_merge => .{ .compact_beat_merge = .{ + .tree = g.enum_value(TreeEnum), + } }, + .compact_manifest => .compact_manifest, + .compact_mutable => .{ .compact_mutable = .{ + .tree = g.enum_value(TreeEnum), + } }, + .compact_mutable_suffix => .{ .compact_mutable_suffix = .{ + .tree = g.enum_value(TreeEnum), + } }, + .lookup => .{ .lookup = .{ + .tree = g.enum_value(TreeEnum), + } }, + .lookup_worker => .{ .lookup_worker = .{ + .tree = g.enum_value(TreeEnum), + } }, + .scan_tree => .{ .scan_tree = .{ + .tree = g.enum_value(TreeEnum), + } }, + .scan_tree_level => .{ .scan_tree_level = .{ + .tree = g.enum_value(TreeEnum), + } }, + .grid_read => .grid_read, + .grid_write => .grid_write, + .storage_read => .{ .storage_read = .{ .zone = g.enum_value(Zone) } }, + .storage_write => .{ .storage_write = .{ .zone = g.enum_value(Zone) } }, + .metrics_emit => .metrics_emit, + .client_request_round_trip => .{ .client_request_round_trip = .{ + .operation = g.enum_value(Operation), + } }, + .loop_run_for_ns => .loop_run_for_ns, + .loop_tick => .loop_tick, + .loop_callbacks => .loop_callbacks, + .loop_kernel => .loop_kernel, + }; + try stacks.append(allocator, event.slot()); + } + for (0..stacks.items.len) |i| { + for (0..i) |j| { + assert(stacks.items[i] != stacks.items[j]); + } + } +} diff --git a/ocam/src/trace/statsd.zig b/ocam/src/trace/statsd.zig new file mode 100644 index 00000000..98d74ba8 --- /dev/null +++ b/ocam/src/trace/statsd.zig @@ -0,0 +1,462 @@ +const std = @import("std"); +const stdx = @import("stdx"); +const assert = std.debug.assert; + +const constants = @import("../constants.zig"); + +const ProcessID = @import("../trace.zig").ProcessID; +const IO = @import("../io.zig").IO; + +const EventMetric = @import("event.zig").EventMetric; +const EventMetricAggregate = @import("event.zig").EventMetricAggregate; +const EventTiming = @import("event.zig").EventTiming; +const EventTimingAggregate = @import("event.zig").EventTimingAggregate; + +const log = std.log.scoped(.statsd); + +/// A reasonable value to keep the total length of the packet under a single MTU, for a local +/// network. +/// +/// https://github.com/statsd/statsd/blob/master/docs/metric_types.md#multi-metric-packets +const packet_size_max = 1400; + +/// No single metric may be larger than this value. If it is, it'll be dropped with an error +/// message. Since this is calculated at comptime, that means there's a bug in the calculation +/// logic. +const statsd_line_size_max = line_size_max: { + // For each type of event, build a payload containing the maximum possible values for that + // event. This is essentially maxInt for unsigned integer payloads, minInt for signed integer + // payloads, and the longest enum tag name for enum payloads. + var events_metric: [std.meta.fieldNames(EventMetric).len]EventMetricAggregate = undefined; + for (&events_metric, std.meta.fields(EventMetric)) |*event_metric, EventMetricInner| { + event_metric.* = .{ + .event = @unionInit( + EventMetric, + EventMetricInner.name, + struct_size_max(EventMetricInner.type), + ), + .value = if (@typeInfo(EventMetricAggregate.ValueType).int.signedness == .signed) + std.math.minInt(EventMetricAggregate.ValueType) + else + std.math.maxInt(EventMetricAggregate.ValueType), + }; + } + + var events_timing: [std.meta.fieldNames(EventTiming).len]EventTimingAggregate = undefined; + for (&events_timing, std.meta.fields(EventTiming)) |*event_timing, EventTimingInner| { + event_timing.* = .{ + .event = @unionInit( + EventTiming, + EventTimingInner.name, + struct_size_max(EventTimingInner.type), + ), + .values = .{ + .duration_min = .{ .ns = std.math.maxInt(u64) }, + .duration_max = .{ .ns = std.math.maxInt(u64) }, + .duration_sum = .{ .ns = std.math.maxInt(u64) }, + .count = std.math.maxInt(u64), + }, + }; + } + + var buffer: [packet_size_max]u8 = undefined; + var buffer_stream = std.io.fixedBufferStream(&buffer); + const buffer_writer = buffer_stream.writer(); + + var line_size_max: u32 = 0; + for (events_metric) |event| { + buffer_stream.reset(); + format_metric( + buffer_writer, + .{ .metric = .{ .aggregate = event } }, + .{ .cluster = std.math.maxInt(u128), .replica = constants.members_max - 1 }, + ) catch unreachable; + line_size_max = @max(line_size_max, buffer_stream.getPos() catch unreachable); + } + for (events_timing) |event| { + for (std.enums.values(TimingStat)) |stat| { + buffer_stream.reset(); + format_metric( + buffer_writer, + .{ .timing = .{ .aggregate = event, .stat = stat } }, + .{ .cluster = std.math.maxInt(u128), .replica = constants.members_max - 1 }, + ) catch unreachable; + line_size_max = @max(line_size_max, buffer_stream.getPos() catch unreachable); + } + } + break :line_size_max line_size_max; +}; + +const packet_messages_max = @divFloor(packet_size_max, statsd_line_size_max); + +comptime { + assert(statsd_line_size_max <= packet_size_max); + assert(packet_messages_max > 0); +} + +/// This implementation emits on an open-loop: on the emit interval, it fires off up to +/// packet_count_max UDP packets, without waiting for completions. +/// +/// The emit interval needs to be large enough that the kernel will have finished processing them +/// before emitting again. If not, an error will be logged. +const packet_count_max = stdx.div_ceil( + EventMetric.slot_count + (EventTiming.slot_count * std.enums.values(TimingStat).len), + packet_messages_max, +); + +comptime { + // Sanity-check: + assert(packet_count_max > 0); + assert(packet_count_max < 512); +} + +pub const StatsD = struct { + process_id: ProcessID, + implementation: union(enum) { + udp: struct { + socket: std.posix.socket_t, + io: *IO, + send_callback_error_count: u64 = 0, + }, + log, + }, + + send_buffer: *[packet_count_max * packet_size_max]u8, + send_completions: [packet_count_max]IO.Completion = undefined, + send_in_flight_count: u32 = 0, + + log_buffer: ?std.ArrayListUnmanaged(u8) = null, + + /// Creates a statsd instance, which will send UDP packets via the IO instance provided. + pub fn init_udp( + allocator: std.mem.Allocator, + process_id: ProcessID, + io: *IO, + address: stdx.SocketAddress, + ) !StatsD { + const socket = try io.open_socket_udp(address.ip.family()); + errdefer io.close_socket(socket); + + const send_buffer = try allocator.create([packet_count_max * packet_size_max]u8); + errdefer allocator.destroy(send_buffer); + + const address_std = address.to_std(); + // 'Connect' the UDP socket, so we can just send() to it normally. + try std.posix.connect(socket, &address_std.any, address_std.getOsSockLen()); + + log.info("{}: sending statsd metrics to {}", .{ process_id, address }); + + return .{ + .process_id = process_id, + .implementation = .{ + .udp = .{ + .socket = socket, + .io = io, + }, + }, + .send_buffer = send_buffer, + }; + } + + // Creates a statsd instance, which will log out the packets that would have been sent. Useful + // so that all of the other code can run and be tested in the simulator. + pub fn init_log( + allocator: std.mem.Allocator, + process_id: ProcessID, + ) !StatsD { + const send_buffer = try allocator.create([packet_count_max * packet_size_max]u8); + errdefer allocator.destroy(send_buffer); + + const log_buffer = try std.ArrayListUnmanaged(u8).initCapacity( + allocator, + packet_count_max * packet_size_max, + ); + errdefer log_buffer.deinit(allocator); + + return .{ + .process_id = process_id, + .implementation = .log, + .send_buffer = send_buffer, + .log_buffer = log_buffer, + }; + } + + pub fn deinit(self: *StatsD, allocator: std.mem.Allocator) void { + if (self.implementation == .udp) { + self.implementation.udp.io.close_socket(self.implementation.udp.socket); + } + + if (self.log_buffer) |*log_buffer| { + log_buffer.deinit(allocator); + } + + allocator.destroy(self.send_buffer); + + self.* = undefined; + } + + pub fn emit( + self: *StatsD, + events_metric: []const ?EventMetricAggregate, + events_timing: []const ?EventTimingAggregate, + ) error{ Busy, UnknownProcess }!u32 { + const cluster, const replica = switch (self.process_id) { + .unknown => { + log.err("{}: process id unknown; skipping emit", .{self.process_id}); + return error.UnknownProcess; + }, + .replica => |replica| .{ replica.cluster, replica.replica }, + }; + + // This really should not happen; it means we're emitting so many packets, on a short + // enough emit timeout, that the kernel hasn't been able to process them all (UDP doesn't + // block or provide back-pressure like a TCP socket). + // + // Keep it as a log, rather than assert, to avoid the common pitfall of metrics killing + // the whole system. + // + // This is also a load-bearing check: see send_callback(). + if (self.send_in_flight_count != 0) { + log.err("{}: {} / {} packets still in flight; skipping emit", .{ + self.process_id, + self.send_in_flight_count, + packet_count_max, + }); + return error.Busy; + } + + // If there's a log buffer, clear it out before starting to emit. + if (self.log_buffer) |*log_buffer| { + log_buffer.clearRetainingCapacity(); + } + + if (self.implementation == .udp and self.implementation.udp.send_callback_error_count > 0) { + log.warn( + "{}: failed to send {} packets", + .{ self.process_id, self.implementation.udp.send_callback_error_count }, + ); + self.implementation.udp.send_callback_error_count = 0; + } + + var send_ready: u32 = 0; + var send_sizes = stdx.BoundedArrayType(u32, packet_count_max){}; + var send_stream = std.io.fixedBufferStream(self.send_buffer); + const send_writer = send_stream.writer(); + inline for (.{ events_metric, events_timing }) |events| { + for (events) |event_new_maybe| { + const event_new = event_new_maybe orelse continue; + const stats = switch (@TypeOf(event_new)) { + EventMetricAggregate => [_]Stat{.{ .metric = .{ .aggregate = event_new } }}, + EventTimingAggregate => [_]Stat{ + .{ .timing = .{ .aggregate = event_new, .stat = .min } }, + .{ .timing = .{ .aggregate = event_new, .stat = .max } }, + .{ .timing = .{ .aggregate = event_new, .stat = .avg } }, + .{ .timing = .{ .aggregate = event_new, .stat = .sum } }, + .{ .timing = .{ .aggregate = event_new, .stat = .count } }, + }, + else => unreachable, + }; + + for (stats) |stat| { + const send_position_before = send_stream.getPos() catch unreachable; + format_metric(send_writer, stat, .{ + .cluster = cluster, + .replica = replica, + }) catch |err| switch (err) { + // This shouldn't ever happen, but don't allow metrics to kill the system. + error.NoSpaceLeft => { + log.err("{}: insufficient buffer space", .{self.process_id}); + break; + }, + }; + + const send_position_after = send_stream.getPos() catch unreachable; + const send_size: u32 = @intCast(send_position_after - send_position_before); + assert(send_size > 0); + if (send_ready + send_size > packet_size_max) { + assert(send_ready > 0); + if (send_sizes.full()) { + log.err("{}: insufficient packet count", .{self.process_id}); + break; + } else { + send_sizes.push(send_ready); + } + send_ready = send_size; + } else { + send_ready += send_size; + } + } + } + } + if (send_ready > 0) { + if (send_sizes.full()) { + log.err("{}: insufficient packet count", .{self.process_id}); + } else { + send_sizes.push(send_ready); + } + } + + var send_offset: u32 = 0; + for (send_sizes.const_slice()) |send_size| { + if (self.send_in_flight_count >= self.send_completions.len) { + // This shouldn't ever happen, but don't allow metrics to kill the system. + log.err("{}: insufficient packets to emit any metrics", .{self.process_id}); + return 0; + } + const completion = &self.send_completions[self.send_in_flight_count]; + self.send_in_flight_count += 1; + self.emit_buffer(completion, self.send_buffer[send_offset..][0..send_size]); + send_offset += send_size; + } + + return @intCast(send_sizes.count()); + } + + fn emit_buffer(self: *StatsD, send_completion: *IO.Completion, send_buffer: []const u8) void { + switch (self.implementation) { + .udp => |udp| { + udp.io.send( + *StatsD, + self, + StatsD.send_callback, + send_completion, + udp.socket, + send_buffer, + ); + }, + .log => { + self.log_buffer.?.appendSliceAssumeCapacity(send_buffer); + StatsD.send_callback(self, send_completion, send_buffer.len); + }, + } + } + + /// The UDP packets containing the metrics are sent in a fire-and-forget manner. + fn send_callback(self: *StatsD, completion: *IO.Completion, result: IO.SendError!usize) void { + _ = result catch { + // Errors are only supported when using UDP; not if calling this loopback. + assert(self.implementation == .udp); + self.implementation.udp.send_callback_error_count += 1; + }; + + // Completions can be returned in any order: this is _only_ safe because their emitting is + // guarded on `self.send_in_flight_count != 0`. + self.send_in_flight_count -= 1; + completion.* = undefined; + } +}; + +const TimingStat = enum { min, max, avg, sum, count }; +const Stat = union(enum) { + metric: struct { aggregate: EventMetricAggregate }, + timing: struct { aggregate: EventTimingAggregate, stat: TimingStat }, +}; + +fn format_metric( + writer: anytype, + stat: Stat, + options: struct { cluster: u128, replica: u8 }, +) error{NoSpaceLeft}!void { + const stat_name = switch (stat) { + inline else => |stat_data| @tagName(stat_data.aggregate.event), + }; + + const stat_suffix, const stat_type, const stat_value = switch (stat) { + .metric => |data| .{ "", "g", data.aggregate.value }, + .timing => |data| switch (data.stat) { + .count => .{ "_us.count", "c", data.aggregate.values.count }, + .sum => .{ "_us.sum", "c", data.aggregate.values.duration_sum.to_us() }, + .min => .{ "_us.min", "g", data.aggregate.values.duration_min.to_us() }, + .max => .{ "_us.max", "g", data.aggregate.values.duration_max.to_us() }, + .avg => .{ "_us.avg", "g", @divFloor( + data.aggregate.values.duration_sum.to_us(), + data.aggregate.values.count, + ) }, + }, + }; + + try writer.print("tb.{[name]s}{[name_suffix]s}:{[value]d}|{[statsd_type]s}" ++ + "|#cluster:{[cluster]x:0>32},replica:{[replica]d}", .{ + .name = stat_name, + .name_suffix = stat_suffix, + .statsd_type = stat_type, + .value = stat_value, + .cluster = options.cluster, + .replica = options.replica, + }); + + switch (stat) { + inline else => |stat_data| { + switch (stat_data.aggregate.event) { + inline else => |data| { + const Tags = @TypeOf(data); + if (@typeInfo(Tags) == .@"struct") { + const fields = std.meta.fields(@TypeOf(data)); + inline for (fields) |data_field| { + comptime assert(!std.mem.eql(u8, data_field.name, "cluster")); + comptime assert(!std.mem.eql(u8, data_field.name, "replica")); + comptime assert(@typeInfo(data_field.type) == .int or + @typeInfo(data_field.type) == .@"enum" or + @typeInfo(data_field.type) == .@"union"); + + const data_field_value = @field(data, data_field.name); + try writer.writeByte(','); + try writer.writeAll(data_field.name); + try writer.writeByte(':'); + + if (@typeInfo(data_field.type) == .@"enum" or + @typeInfo(data_field.type) == .@"union") + { + try writer.print("{s}", .{@tagName(data_field_value)}); + } else { + try writer.print("{}", .{data_field_value}); + } + } + } else { + assert(@TypeOf(data) == void); + } + }, + } + }, + } + try writer.writeByte('\n'); +} + +/// Returns an instance of a Struct (or void) with all fields set to what would result in the +/// longest length when formatted. +/// +/// Integers get maxInt, and Enums get a value corresponding to `enum_size_max()`. +fn struct_size_max(StructOrVoid: type) StructOrVoid { + if (@typeInfo(StructOrVoid) == .void) return {}; + + assert(@typeInfo(StructOrVoid) == .@"struct"); + const Struct = StructOrVoid; + + var output: Struct = undefined; + + for (std.meta.fields(Struct)) |field| { + const type_info = @typeInfo(field.type); + assert(type_info == .int or type_info == .@"enum"); + assert(type_info != .int or type_info.int.signedness == .unsigned); + switch (type_info) { + .int => @field(output, field.name) = std.math.maxInt(field.type), + .@"enum" => @field(output, field.name) = + std.enums.nameCast(field.type, enum_size_max(field.type)), + else => @compileError("unsupported type"), + } + } + + return output; +} + +/// Returns the longest @tagName for a given Enum. +fn enum_size_max(Enum: type) []const u8 { + @setEvalBranchQuota(10_000); + var tag_longest: []const u8 = ""; + for (std.meta.fieldNames(Enum)) |field_name| { + if (tag_longest.len < field_name.len) { + tag_longest = field_name; + } + } + return tag_longest; +} diff --git a/ocam/src/unit_tests.zig b/ocam/src/unit_tests.zig new file mode 100644 index 00000000..2fbfd328 --- /dev/null +++ b/ocam/src/unit_tests.zig @@ -0,0 +1,318 @@ +comptime { + _ = @import("aof.zig"); + _ = @import("cdc/amqp.zig"); + _ = @import("cdc/amqp/protocol.zig"); + _ = @import("cdc/runner.zig"); + _ = @import("clients/c/tb_client.zig"); + _ = @import("clients/c/tb_client/context.zig"); + _ = @import("clients/c/tb_client/signal.zig"); + _ = @import("clients/c/test.zig"); + _ = @import("copyhound.zig"); + _ = @import("ewah.zig"); + _ = @import("ewah_benchmark.zig"); + _ = @import("io/test.zig"); + _ = @import("lsm/binary_search.zig"); + _ = @import("lsm/binary_search_benchmark.zig"); + _ = @import("lsm/cache_map.zig"); + _ = @import("lsm/composite_key.zig"); + _ = @import("lsm/forest.zig"); + _ = @import("lsm/forest_table_iterator.zig"); + _ = @import("lsm/k_way_merge.zig"); + _ = @import("lsm/k_way_merge_benchmark.zig"); + _ = @import("lsm/manifest_level.zig"); + _ = @import("lsm/node_pool.zig"); + _ = @import("lsm/scratch_memory.zig"); + _ = @import("lsm/segmented_array.zig"); + _ = @import("lsm/segmented_array_benchmark.zig"); + _ = @import("lsm/set_associative_cache.zig"); + _ = @import("lsm/table.zig"); + _ = @import("lsm/table_memory.zig"); + _ = @import("lsm/tree.zig"); + _ = @import("lsm/unique_key.zig"); + _ = @import("lsm/zig_zag_merge.zig"); + _ = @import("message_buffer.zig"); + _ = @import("multiversion.zig"); + _ = @import("queue.zig"); + _ = @import("repl/completion.zig"); + _ = @import("repl/parser.zig"); + _ = @import("repl/terminal.zig"); + _ = @import("scripts/cfo.zig"); + _ = @import("scripts/changelog.zig"); + _ = @import("stack.zig"); + _ = @import("state_machine.zig"); + _ = @import("state_machine_fuzz.zig"); + _ = @import("state_machine_tests.zig"); + _ = @import("testing/exhaustigen.zig"); + _ = @import("testing/id.zig"); + _ = @import("testing/marks.zig"); + _ = @import("testing/table.zig"); + _ = @import("testing/vortex/supervisor.zig"); + _ = @import("tidy.zig"); + _ = @import("time.zig"); + _ = @import("trace.zig"); + _ = @import("trace/event.zig"); + _ = @import("vsr.zig"); + _ = @import("vsr/checksum.zig"); + _ = @import("vsr/checksum_benchmark.zig"); + _ = @import("vsr/clock.zig"); + _ = @import("vsr/fault_detector.zig"); + _ = @import("vsr/free_set.zig"); + _ = @import("vsr/grid_scrubber.zig"); + _ = @import("vsr/journal.zig"); + _ = @import("vsr/marzullo.zig"); + _ = @import("vsr/message_header.zig"); + _ = @import("vsr/multi_batch.zig"); + _ = @import("vsr/replica_format.zig"); + _ = @import("vsr/replica_test.zig"); + _ = @import("vsr/superblock.zig"); + _ = @import("vsr/superblock_quorums_fuzz.zig"); +} + +const quine = + \\const std = @import("std"); + \\const stdx = @import("stdx"); + \\const builtin = @import("builtin"); + \\const assert = std.debug.assert; + \\ + \\const MiB = stdx.MiB; + \\ + \\test quine { + \\ var arena_instance = std.heap.ArenaAllocator.init(std.testing.allocator); + \\ defer arena_instance.deinit(); + \\ + \\ const arena = arena_instance.allocator(); + \\ + \\ // build.zig runs this in the root dir. + \\ var src_dir = try std.fs.cwd().openDir("src", .{ + \\ .access_sub_paths = true, + \\ .iterate = true, + \\ }); + \\ + \\ var unit_tests_contents = std.ArrayList(u8).init(arena); + \\ const writer = unit_tests_contents.writer(); + \\ try writer.writeAll("comptime {\n"); + \\ + \\ for (try unit_test_files(arena, src_dir)) |unit_test_file| { + \\ try writer.print(" _ = @import(\"{s}\");\n", .{unit_test_file}); + \\ } + \\ + \\ try writer.writeAll("}\n\n"); + \\ + \\ var quine_lines = std.mem.splitScalar(u8, quine, '\n'); + \\ try writer.writeAll("const quine =\n"); + \\ while (quine_lines.next()) |line| { + \\ try writer.print(" \\\\{s}\n", .{line}); + \\ } + \\ try writer.writeAll(";\n\n"); + \\ + \\ try writer.writeAll(quine); + \\ + \\ assert(std.mem.eql(u8, @src().file, "unit_tests.zig")); + \\ const unit_tests_contents_disk = try src_dir.readFileAlloc(arena, @src().file, 1 * MiB); + \\ assert(std.mem.startsWith(u8, unit_tests_contents_disk, "comptime {")); + \\ assert(std.mem.endsWith(u8, unit_tests_contents.items, "}\n")); + \\ + \\ const unit_tests_needs_update = !std.mem.startsWith( + \\ u8, + \\ unit_tests_contents_disk, + \\ unit_tests_contents.items, + \\ ); + \\ + \\ if (unit_tests_needs_update) { + \\ if (std.process.hasEnvVarConstant("SNAP_UPDATE")) { + \\ // Add the rest of the real file on disk to the generated in-memory file. + \\ try src_dir.writeFile(.{ + \\ .sub_path = "unit_tests.zig", + \\ .data = unit_tests_contents.items, + \\ .flags = .{ .exclusive = false, .truncate = true }, + \\ }); + \\ } else { + \\ std.debug.print("unit_tests.zig needs updating.\n", .{}); + \\ std.debug.print( + \\ "Rerun with SNAP_UPDATE=1 environmental variable to update the contents.\n", + \\ .{}, + \\ ); + \\ assert(false); + \\ } + \\ } + \\} + \\ + \\fn unit_test_files(arena: std.mem.Allocator, src_dir: std.fs.Dir) ![]const []const u8 { + \\ // Different platforms can walk the directory in different orders. + \\ // Store the paths and sort them to ensure consistency. + \\ var result = std.ArrayList([]const u8).init(arena); + \\ + \\ var src_walker = try src_dir.walk(arena); + \\ defer src_walker.deinit(); + \\ + \\ while (try src_walker.next()) |entry| { + \\ if (entry.kind != .file) continue; + \\ + \\ const entry_path = try arena.dupe(u8, entry.path); + \\ + \\ // Replace the path separator to be Unix-style, for consistency on Windows. + \\ // Don't use entry.path directly! + \\ if (builtin.os.tag == .windows) { + \\ std.mem.replaceScalar(u8, entry_path, '\\', '/'); + \\ } + \\ + \\ if (!std.mem.endsWith(u8, entry_path, ".zig")) continue; + \\ + \\ if (std.mem.eql(u8, entry_path, "unit_tests.zig")) continue; + \\ if (std.mem.eql(u8, entry_path, "integration_tests.zig")) continue; + \\ if (std.mem.eql(u8, entry_path, "inspect_snapshot.zig")) continue; + \\ if (std.mem.startsWith(u8, entry_path, "stdx/")) continue; + \\ if (std.mem.startsWith(u8, entry_path, "clients/") and + \\ !std.mem.startsWith(u8, entry_path, "clients/c")) continue; + \\ if (std.mem.eql(u8, entry_path, "clients/c/tb_client_header_test.zig")) continue; + \\ if (std.mem.eql(u8, entry_path, "tigerbeetle/libtb_client.zig")) continue; + \\ + \\ const contents = try src_dir.readFileAlloc(arena, entry_path, 1 * MiB); + \\ var line_iterator = std.mem.splitScalar(u8, contents, '\n'); + \\ while (line_iterator.next()) |line| { + \\ const line_trimmed = std.mem.trimLeft(u8, line, " "); + \\ if (std.mem.startsWith(u8, line_trimmed, "test ")) { + \\ try result.append(entry_path); + \\ break; + \\ } + \\ } + \\ } + \\ + \\ std.mem.sort( + \\ []const u8, + \\ result.items, + \\ {}, + \\ struct { + \\ fn less_than_fn(_: void, a: []const u8, b: []const u8) bool { + \\ return std.mem.order(u8, a, b) == .lt; + \\ } + \\ }.less_than_fn, + \\ ); + \\ + \\ return result.items; + \\} + \\ +; + +const std = @import("std"); +const stdx = @import("stdx"); +const builtin = @import("builtin"); +const assert = std.debug.assert; + +const MiB = stdx.MiB; + +test quine { + var arena_instance = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_instance.deinit(); + + const arena = arena_instance.allocator(); + + // build.zig runs this in the root dir. + var src_dir = try std.fs.cwd().openDir("src", .{ + .access_sub_paths = true, + .iterate = true, + }); + + var unit_tests_contents = std.ArrayList(u8).init(arena); + const writer = unit_tests_contents.writer(); + try writer.writeAll("comptime {\n"); + + for (try unit_test_files(arena, src_dir)) |unit_test_file| { + try writer.print(" _ = @import(\"{s}\");\n", .{unit_test_file}); + } + + try writer.writeAll("}\n\n"); + + var quine_lines = std.mem.splitScalar(u8, quine, '\n'); + try writer.writeAll("const quine =\n"); + while (quine_lines.next()) |line| { + try writer.print(" \\\\{s}\n", .{line}); + } + try writer.writeAll(";\n\n"); + + try writer.writeAll(quine); + + assert(std.mem.eql(u8, @src().file, "unit_tests.zig")); + const unit_tests_contents_disk = try src_dir.readFileAlloc(arena, @src().file, 1 * MiB); + assert(std.mem.startsWith(u8, unit_tests_contents_disk, "comptime {")); + assert(std.mem.endsWith(u8, unit_tests_contents.items, "}\n")); + + const unit_tests_needs_update = !std.mem.startsWith( + u8, + unit_tests_contents_disk, + unit_tests_contents.items, + ); + + if (unit_tests_needs_update) { + if (std.process.hasEnvVarConstant("SNAP_UPDATE")) { + // Add the rest of the real file on disk to the generated in-memory file. + try src_dir.writeFile(.{ + .sub_path = "unit_tests.zig", + .data = unit_tests_contents.items, + .flags = .{ .exclusive = false, .truncate = true }, + }); + } else { + std.debug.print("unit_tests.zig needs updating.\n", .{}); + std.debug.print( + "Rerun with SNAP_UPDATE=1 environmental variable to update the contents.\n", + .{}, + ); + assert(false); + } + } +} + +fn unit_test_files(arena: std.mem.Allocator, src_dir: std.fs.Dir) ![]const []const u8 { + // Different platforms can walk the directory in different orders. + // Store the paths and sort them to ensure consistency. + var result = std.ArrayList([]const u8).init(arena); + + var src_walker = try src_dir.walk(arena); + defer src_walker.deinit(); + + while (try src_walker.next()) |entry| { + if (entry.kind != .file) continue; + + const entry_path = try arena.dupe(u8, entry.path); + + // Replace the path separator to be Unix-style, for consistency on Windows. + // Don't use entry.path directly! + if (builtin.os.tag == .windows) { + std.mem.replaceScalar(u8, entry_path, '\\', '/'); + } + + if (!std.mem.endsWith(u8, entry_path, ".zig")) continue; + + if (std.mem.eql(u8, entry_path, "unit_tests.zig")) continue; + if (std.mem.eql(u8, entry_path, "integration_tests.zig")) continue; + if (std.mem.eql(u8, entry_path, "inspect_snapshot.zig")) continue; + if (std.mem.startsWith(u8, entry_path, "stdx/")) continue; + if (std.mem.startsWith(u8, entry_path, "clients/") and + !std.mem.startsWith(u8, entry_path, "clients/c")) continue; + if (std.mem.eql(u8, entry_path, "clients/c/tb_client_header_test.zig")) continue; + if (std.mem.eql(u8, entry_path, "tigerbeetle/libtb_client.zig")) continue; + + const contents = try src_dir.readFileAlloc(arena, entry_path, 1 * MiB); + var line_iterator = std.mem.splitScalar(u8, contents, '\n'); + while (line_iterator.next()) |line| { + const line_trimmed = std.mem.trimLeft(u8, line, " "); + if (std.mem.startsWith(u8, line_trimmed, "test ")) { + try result.append(entry_path); + break; + } + } + } + + std.mem.sort( + []const u8, + result.items, + {}, + struct { + fn less_than_fn(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; + } + }.less_than_fn, + ); + + return result.items; +} diff --git a/ocam/src/vopr.zig b/ocam/src/vopr.zig new file mode 100644 index 00000000..c7965112 --- /dev/null +++ b/ocam/src/vopr.zig @@ -0,0 +1,1805 @@ +const std = @import("std"); +const stdx = @import("stdx"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const mem = std.mem; +const ratio = stdx.PRNG.ratio; +const Ratio = stdx.PRNG.Ratio; +const range_inclusive_ms = @import("./testing/fuzz.zig").range_inclusive_ms; + +const constants = @import("constants.zig"); +const schema = @import("lsm/schema.zig"); +const vsr = @import("vsr.zig"); +const fuzz = @import("./testing/fuzz.zig"); +const Header = vsr.Header; + +pub const vsr_options = .{ + .config_verify = true, + .git_commit = @import("vsr_options").git_commit, + .release = @import("vsr_options").release, + .release_client_min = @import("vsr_options").release_client_min, +}; +const vsr_vopr_options = @import("vsr_vopr_options"); + +const state_machine = vsr_vopr_options.state_machine; +const StateMachineType = switch (state_machine) { + .accounting => @import("state_machine.zig").StateMachineType, + .testing => @import("testing/state_machine.zig").StateMachineType, +}; + +const Cluster = @import("testing/cluster.zig").ClusterType(StateMachineType); +const Release = @import("testing/cluster.zig").Release; +const StateMachine = Cluster.StateMachine; +const Failure = @import("testing/cluster.zig").Failure; +const PartitionMode = @import("testing/packet_simulator.zig").PartitionMode; +const PartitionSymmetry = @import("testing/packet_simulator.zig").PartitionSymmetry; +const Core = @import("testing/cluster/network.zig").Network.Core; +const ReplySequence = @import("testing/reply_sequence.zig").ReplySequence; +const Message = @import("message_pool.zig").MessagePool.Message; + +const MiB = stdx.MiB; + +const releases = [_]Release{ + .{ + .release = vsr.Release.from(.{ .major = 0, .minor = 0, .patch = 1 }), + .release_client_min = vsr.Release.from(.{ .major = 0, .minor = 0, .patch = 1 }), + }, + .{ + .release = vsr.Release.from(.{ .major = 0, .minor = 0, .patch = 2 }), + .release_client_min = vsr.Release.from(.{ .major = 0, .minor = 0, .patch = 1 }), + }, + .{ + .release = vsr.Release.from(.{ .major = 0, .minor = 0, .patch = 3 }), + .release_client_min = vsr.Release.from(.{ .major = 0, .minor = 0, .patch = 1 }), + }, +}; + +const log = std.log.scoped(.simulator); + +pub const std_options: std.Options = .{ + // The -vopr-log= build option selects two logging modes. + // In "short" mode, only state transitions are printed (see `Cluster.log_replica`). + // "full" mode is the usual logging according to the level. + .log_level = if (vsr_vopr_options.log == .short) .info else .debug, + .logFn = log_override, + + // Uncomment if you need per-scope control over the log levels. + // pub const log_scope_levels: []const std.log.ScopeLevel = &.{ + // .{ .scope = .cluster, .level = .info }, + // .{ .scope = .replica, .level = .debug }, + // }; +}; + +pub const tigerbeetle_config = @import("config.zig").configs.test_min; + +const cluster_id = 0; + +const CLIArgs = struct { + // "lite" mode runs a small cluster and only looks for crashes. + lite: bool = false, + performance: bool = false, + // Feel free to add more runtime overrides here! + ticks_max_requests: u32 = 40_000_000, + ticks_max_convergence: u32 = 10_000_000, + packet_loss_ratio: ?Ratio = null, + replica_missing: ?u8 = null, + replica_missing_until_request: ?u32 = null, + requests_max: ?u32 = null, + + @"--": void, + seed: ?[]const u8 = null, +}; + +pub fn main() !void { + comptime assert(constants.verify); + // This must be initialized at runtime as stderr is not comptime known on e.g. Windows. + log_buffer.unbuffered_writer = std.io.getStdErr().writer(); + fuzz.limit_ram(); + + var gpa_instance: std.heap.GeneralPurposeAllocator(.{}) = .{}; + defer { + _ = gpa_instance.detectLeaks(); + switch (gpa_instance.deinit()) { + .ok => {}, + .leak => @panic("memory leaked"), + } + } + + const gpa = gpa_instance.allocator(); + + var flags = stdx.Flags.init(gpa); + defer flags.deinit(gpa); + + const cli_args = flags.parse(CLIArgs); + if (cli_args.lite and cli_args.performance) { + return vsr.fatal(.cli, "--lite and --performance are mutually exclusive", .{}); + } + if (cli_args.replica_missing != null and !cli_args.performance) { + return vsr.fatal(.cli, "--replica-missing requires --performance", .{}); + } + if (cli_args.replica_missing == null and cli_args.replica_missing_until_request != null) { + return vsr.fatal(.cli, "--replica-missing-until-request requires --replica-missing", .{}); + } + + log_performance_mode = cli_args.performance; + + const seed_random = std.crypto.random.int(u64); + const seed = seed_from_arg: { + const seed_argument = cli_args.seed orelse break :seed_from_arg seed_random; + break :seed_from_arg vsr.testing.parse_seed(seed_argument); + }; + + // We do not support ReleaseFast or ReleaseSmall because they disable assertions. + comptime assert(builtin.mode == .Debug or builtin.mode == .ReleaseSafe); + + if (seed == seed_random) { + if (builtin.mode != .ReleaseSafe) { + // If no seed is provided, than Debug is too slow and ReleaseSafe is much faster. + return vsr.fatal( + .cli, + "no seed provided: the simulator must be run with -OReleaseSafe", + .{}, + ); + } + if (vsr_vopr_options.log != .short) { + log.warn("no seed provided: full debug logs are enabled, this will be slow", .{}); + } + } + + var prng = stdx.PRNG.from_seed(seed); + + var options = if (cli_args.lite) + options_lite(&prng) + else if (cli_args.performance) + options_performance(&prng) + else + options_swarm(&prng); + + options.replica_missing = cli_args.replica_missing; + options.replica_missing_until_request = cli_args.replica_missing_until_request; + if (cli_args.packet_loss_ratio) |packet_loss_ratio| { + options.network.packet_loss_probability = packet_loss_ratio; + } + if (cli_args.requests_max) |requests_max| { + options.requests_max = requests_max; + } + + if (options.replica_missing_until_request != null and + options.requests_max < options.replica_missing_until_request.?) + { + return vsr.fatal(.cli, "--requests-max < --replica-missing-until-request", .{}); + } + + log.info( + \\ + \\ SEED={} + \\ + \\ replicas={} + \\ standbys={} + \\ clients={} + \\ request_probability={} + \\ idle_on_probability={} + \\ idle_off_probability={} + \\ one_way_delay_mean={} + \\ one_way_delay_min={} + \\ packet_loss_probability={} + \\ path_maximum_capacity={} messages + \\ path_clog_duration_mean={} + \\ path_clog_probability={} + \\ packet_replay_probability={} + \\ partition_mode={s} + \\ partition_symmetry={s} + \\ partition_probability={} + \\ unpartition_probability={} + \\ partition_stability={} ticks + \\ unpartition_stability={} ticks + \\ read_latency_min={} + \\ read_latency_mean={} + \\ write_latency_min={} + \\ write_latency_mean={} + \\ read_fault_probability={} + \\ write_fault_probability={} + \\ crash_probability={} + \\ crash_stability={} ticks + \\ restart_probability={} + \\ restart_stability={} ticks + , .{ + seed, + options.cluster.replica_count, + options.cluster.standby_count, + options.cluster.client_count, + options.request_probability, + options.request_idle_on_probability, + options.request_idle_off_probability, + options.network.one_way_delay_mean, + options.network.one_way_delay_min, + options.network.packet_loss_probability, + options.network.path_maximum_capacity, + options.network.path_clog_duration_mean, + options.network.path_clog_probability, + options.network.packet_replay_probability, + @tagName(options.network.partition_mode), + @tagName(options.network.partition_symmetry), + options.network.partition_probability, + options.network.unpartition_probability, + options.network.partition_stability, + options.network.unpartition_stability, + options.storage.read_latency_min, + options.storage.read_latency_mean, + options.storage.write_latency_min, + options.storage.write_latency_mean, + options.storage.read_fault_probability, + options.storage.write_fault_probability, + options.replica_crash_probability, + options.replica_crash_stability, + options.replica_restart_probability, + options.replica_restart_stability, + }); + + var simulator = try Simulator.init(gpa, &prng, options); + defer simulator.deinit(gpa); + + if (cli_args.performance) { + // Simulate a missing replica by crashing it with ∞ stability. + if (options.replica_missing) |replica_index| { + if (replica_index > options.network.node_count) { + vsr.fatal(.cli, "--replica-index too large", .{}); + } + simulator.cluster.replica_crash(replica_index); + simulator.replica_crash_stability[replica_index] = std.math.maxInt(u32); + } + + // Warm-up the cluster before performance testing to get past the initial view change. + simulator.options.request_probability = Ratio.zero(); + for (0..500) |_| simulator.tick(); + simulator.options.request_probability = options.request_probability; + } + + for (0..options.cluster.client_count) |client_index| { + simulator.cluster.register(client_index); + } + + // Safety: replicas crash and restart; at any given point in time arbitrarily many replicas may + // be crashed, but each replica restarts eventually. The cluster must process all requests + // without split-brain. + var tick_total: u64 = 0; + var tick: u64 = 0; + var requests_done: bool = false; + var upgrades_done: bool = false; + + while (tick < cli_args.ticks_max_requests) : (tick += 1) { + const requests_replied_old = simulator.requests_replied; + simulator.tick(); + tick_total += 1; + if (simulator.requests_replied > requests_replied_old) { + tick = 0; + } + requests_done = simulator.requests_replied == simulator.options.requests_max; + upgrades_done = + for (simulator.cluster.replicas, simulator.cluster.replica_health) |*replica, health| { + if (health != .up) continue; + const release_latest = releases[simulator.replica_releases_limit - 1].release; + if (replica.release.value == release_latest.value) { + break true; + } + } else false; + + if (requests_done and upgrades_done) break; + } + + if (cli_args.lite) { + // Don't care about convergence. + } else if (cli_args.performance) { + assert(requests_done and upgrades_done); + + var core = full_core( + simulator.options.cluster.replica_count, + simulator.options.cluster.standby_count, + ); + if (cli_args.replica_missing) |replica_missing| { + // If replica is permanently missing then exclude it from the core. + if (cli_args.replica_missing_until_request == null) core.unset(replica_missing); + } + simulator.transition_to_liveness_mode(core); + + tick = 0; + while (tick < cli_args.ticks_max_convergence) : (tick += 1) { + simulator.tick(); + tick_total += 1; + if (simulator.pending() == null) break; + } + assert(simulator.pending() == null); + } else { + const core = if (requests_done and upgrades_done) + // Liveness: a core set of replicas is up and fully connected. The rest of the replicas + // might be crashed or partitioned permanently. The core should converge to the same + // state. + random_core( + simulator.prng, + simulator.options.cluster.replica_count, + simulator.options.cluster.standby_count, + ) + else + // Safety mode ran out of ticks without completing its requests, so now we check whether + // it was correct to do so. + // + // Run a fully-connected core of replicas to repair all faulty grid blocks, headers, and + // prepares that can be repaired. Thereafter, only correlated faults should remain. + full_core( + simulator.options.cluster.replica_count, + simulator.options.cluster.standby_count, + ); + + simulator.transition_to_liveness_mode(core); + + tick = 0; + while (tick < cli_args.ticks_max_convergence) : (tick += 1) { + simulator.tick(); + tick_total += 1; + if (simulator.pending() == null) { + break; + } + } + + if (simulator.pending()) |reason| { + if (try simulator.cluster_recoverable(gpa)) { + log.info("no liveness, final cluster state (core={b}):", .{simulator.core.bits}); + simulator.cluster.log_cluster(); + log.err("you can reproduce this failure with seed={}", .{seed}); + fatal(.liveness, "no state convergence: {s}", .{reason}); + } + } else { + const commits = simulator.cluster.state_checker.commits.items; + const last_checksum = commits[commits.len - 1].header.checksum; + for (simulator.cluster.aofs, 0..) |*aof, replica_index| { + if (simulator.core.is_set(replica_index)) { + try aof.validate(gpa, last_checksum); + } else { + try aof.validate(gpa, null); + } + } + } + } + + if (cli_args.performance) { + log.info("\nMessages:\n{}", .{simulator.cluster.network.message_summary}); + } else { + log.debug("\nMessages:\n{}", .{simulator.cluster.network.message_summary}); + } + + log.info("\n PASSED ({} ticks)", .{tick_total}); +} + +fn options_swarm(prng: *stdx.PRNG) Simulator.Options { + const replica_count = prng.range_inclusive(u8, 1, constants.replicas_max); + const standby_count = prng.int_inclusive(u8, constants.standbys_max); + const node_count = replica_count + standby_count; + // -1 since otherwise it is possible that all clients will evict each other. + // (Due to retried register messages from the first set of evicted clients. + // See the "Cluster: eviction: session_too_low" replica test for a related scenario.) + const client_count = prng.range_inclusive(u8, 1, constants.clients_max * 2 - 1); + + const batch_size_limit_min = comptime batch_size_limit_min: { + var event_size_max: u32 = @sizeOf(vsr.RegisterRequest); + for (std.enums.values(StateMachine.Operation)) |operation| { + event_size_max = @max(event_size_max, operation.event_size()); + } + break :batch_size_limit_min event_size_max; + }; + const batch_size_limit: u32 = if (prng.boolean()) + constants.message_body_size_max + else + prng.range_inclusive(u32, batch_size_limit_min, constants.message_body_size_max); + + const multi_batch_per_request_limit: u32 = multi_batch_per_request_limit: { + const event_max = @divFloor(batch_size_limit, batch_size_limit_min); + assert(event_max > 0); + break :multi_batch_per_request_limit if (event_max == 1) 1 else prng.range_inclusive( + u32, + 1, + event_max - 1, // Minus one for the multi-batch trailer. + ); + }; + + const storage_size_limit = vsr.sector_floor( + 200 * MiB - prng.int_inclusive(u64, 20 * MiB), + ); + + const cluster_options: Cluster.Options = .{ + .cluster_id = cluster_id, + .replica_count = replica_count, + .standby_count = standby_count, + .client_count = client_count, + .storage_size_limit = storage_size_limit, + .seed = prng.int(u64), + .releases = &releases, + .client_release = releases[0].release, + .reformats_max = replica_count + 2, // Arbitrary reformat limit. + + .state_machine = switch (state_machine) { + .testing => .{ + .batch_size_limit = batch_size_limit, + .lsm_forest_node_count = 4096, + }, + .accounting => .{ + .batch_size_limit = batch_size_limit, + .lsm_forest_compaction_block_count = prng.int_inclusive(u32, 256) + + StateMachine.Forest.Options.compaction_block_count_min, + .lsm_forest_node_count = 4096, + .cache_entries_accounts = if (prng.boolean()) 256 else 0, + .cache_entries_transfers = if (prng.boolean()) 256 else 0, + .cache_entries_transfers_pending = if (prng.boolean()) 256 else 0, + .log_trace = true, + .aof_recovery = false, + }, + }, + }; + + const network_options: Cluster.NetworkOptions = .{ + .node_count = node_count, + .client_count = client_count, + + .seed = prng.int(u64), + + .one_way_delay_min = range_inclusive_ms(prng, 0, 30), + .one_way_delay_mean = range_inclusive_ms(prng, 30, 100), + .packet_loss_probability = ratio(prng.int_inclusive(u8, 30), 100), + .path_maximum_capacity = prng.range_inclusive(u8, 2, 20), + .path_clog_duration_mean = range_inclusive_ms(prng, 0, 5_000), + .path_clog_probability = ratio(prng.int_inclusive(u8, 2), 100), + .packet_replay_probability = ratio(prng.int_inclusive(u8, 50), 100), + + .partition_mode = prng.enum_uniform(PartitionMode), + .partition_symmetry = prng.enum_uniform(PartitionSymmetry), + .partition_probability = ratio(prng.int_inclusive(u8, 3), 100), + .unpartition_probability = ratio(prng.range_inclusive(u8, 1, 10), 100), + .partition_stability = 100 + prng.int_inclusive(u32, 100), + .unpartition_stability = prng.int_inclusive(u32, 20), + }; + + const read_latency_min = range_inclusive_ms(prng, 0, 30); + const write_latency_min = range_inclusive_ms(prng, 0, 30); + const storage_options: Cluster.Storage.Options = .{ + .size = cluster_options.storage_size_limit, + .seed = prng.int(u64), + .read_latency_min = read_latency_min, + .read_latency_mean = range_inclusive_ms(prng, read_latency_min, 100), + .write_latency_min = write_latency_min, + .write_latency_mean = range_inclusive_ms(prng, write_latency_min, 1_000), + .read_fault_probability = ratio(prng.range_inclusive(u8, 0, 10), 100), + .write_fault_probability = ratio(prng.range_inclusive(u8, 0, 10), 100), + .write_misdirect_probability = ratio(prng.range_inclusive(u8, 0, 10), 100), + .crash_fault_probability = ratio(prng.range_inclusive(u8, 80, 100), 100), + }; + const storage_fault_atlas: Cluster.StorageFaultAtlas.Options = .{ + .faulty_superblock = true, + .faulty_wal_headers = replica_count > 1, + .faulty_wal_prepares = replica_count > 1, + .faulty_client_replies = replica_count > 1, + // >2 instead of >1 because in R=2, a lagging replica may sync to the leading replica, + // but then the leading replica may have the only copy of a block in the cluster. + .faulty_grid = replica_count > 2, + }; + + const workload_options = StateMachine.Workload.Options.generate(prng, .{ + .batch_size_limit = batch_size_limit, + .multi_batch_per_request_limit = multi_batch_per_request_limit, + .client_count = client_count, + // TODO(DJ) Once Workload no longer needs in_flight_max, make stalled_queue_capacity + // private. Also maybe make it dynamic (computed from the client_count instead of + // clients_max). + .in_flight_max = ReplySequence.stalled_queue_capacity * + multi_batch_per_request_limit, + }); + + return .{ + .cluster = cluster_options, + .network = network_options, + .storage = storage_options, + .storage_fault_atlas = storage_fault_atlas, + .workload = workload_options, + // TODO Swarm testing: Test long+few crashes and short+many crashes separately. + .replica_crash_probability = ratio(2, 10_000_000), + .replica_crash_stability = prng.int_inclusive(u32, 1_000), + .replica_restart_probability = ratio(2, 1_000_000), + .replica_restart_stability = prng.int_inclusive(u32, 1_000), + .replica_reformat_probability = ratio(30, 100), + + .replica_pause_probability = ratio(8, 10_000_000), + .replica_pause_stability = prng.int_inclusive(u32, 1_000), + .replica_unpause_probability = ratio(8, 1_000_000), + .replica_unpause_stability = prng.int_inclusive(u32, 1_000), + + .replica_release_advance_probability = ratio(1, 1_000_000), + .replica_release_catchup_probability = ratio(1, 100_000), + + .requests_max = constants.journal_slot_count * 3, + .request_probability = ratio(prng.range_inclusive(u8, 1, 100), 100), + .request_idle_on_probability = ratio(prng.range_inclusive(u8, 0, 20), 100), + .request_idle_off_probability = ratio(prng.range_inclusive(u8, 10, 20), 100), + }; +} + +fn options_lite(prng: *stdx.PRNG) Simulator.Options { + var base = options_swarm(prng); + base.cluster.replica_count = 3; + base.cluster.standby_count = 0; + base.network.node_count = 3; + return base; +} + +fn options_performance(prng: *stdx.PRNG) Simulator.Options { + const cluster_options: Cluster.Options = .{ + .cluster_id = cluster_id, + .replica_count = 6, + .standby_count = 0, + .client_count = 4, + .storage_size_limit = vsr.sector_floor(200 * MiB), + .seed = prng.int(u64), + .releases = releases[0..1], + .client_release = releases[0].release, + .reformats_max = 0, + + .state_machine = switch (state_machine) { + .testing => .{ + .batch_size_limit = constants.message_body_size_max, + .lsm_forest_node_count = 4096, + }, + .accounting => .{ + .batch_size_limit = constants.message_body_size_max, + .lsm_forest_compaction_block_count = 128 + + StateMachine.Forest.Options.compaction_block_count_min, + .lsm_forest_node_count = 4096, + .cache_entries_accounts = 256, + .cache_entries_transfers = 0, + .cache_entries_transfers_pending = 0, + .log_trace = true, + .aof_recovery = false, + }, + }, + }; + + const network_options: Cluster.NetworkOptions = .{ + .node_count = cluster_options.replica_count, + .client_count = cluster_options.client_count, + + .seed = prng.int(u64), + + .one_way_delay_mean = .ms(50), + .one_way_delay_min = .{ .ns = 0 }, + .packet_loss_probability = Ratio.zero(), + .path_maximum_capacity = 10, + .path_clog_duration_mean = .ms(2_000), + .path_clog_probability = Ratio.zero(), + .packet_replay_probability = Ratio.zero(), + + .partition_mode = .none, + .partition_symmetry = .symmetric, + .partition_probability = Ratio.zero(), + .unpartition_probability = Ratio.zero(), + .partition_stability = 100, + .unpartition_stability = 10, + }; + + const storage_options: Cluster.Storage.Options = .{ + .size = cluster_options.storage_size_limit, + .seed = prng.int(u64), + .read_latency_min = .{ .ns = 0 }, + .read_latency_mean = .{ .ns = 0 }, + .write_latency_min = .{ .ns = 0 }, + .write_latency_mean = .{ .ns = 0 }, + .read_fault_probability = Ratio.zero(), + .write_fault_probability = Ratio.zero(), + .write_misdirect_probability = Ratio.zero(), + .crash_fault_probability = Ratio.zero(), + }; + const storage_fault_atlas: Cluster.StorageFaultAtlas.Options = .{ + .faulty_superblock = false, + .faulty_wal_headers = false, + .faulty_wal_prepares = false, + .faulty_client_replies = false, + .faulty_grid = false, + }; + + var workload_prng = stdx.PRNG.from_seed(92); // Fix workload for perf testing. + const workload_options = StateMachine.Workload.Options.generate(&workload_prng, .{ + .batch_size_limit = constants.message_body_size_max, + .multi_batch_per_request_limit = 1, + .client_count = cluster_options.client_count, + .in_flight_max = ReplySequence.stalled_queue_capacity, + }); + + return .{ + .cluster = cluster_options, + .network = network_options, + .storage = storage_options, + .storage_fault_atlas = storage_fault_atlas, + .workload = workload_options, + .replica_crash_probability = Ratio.zero(), + .replica_crash_stability = 500, + .replica_restart_probability = Ratio.zero(), + .replica_restart_stability = 500, + .replica_reformat_probability = ratio(0, 100), + + .replica_pause_probability = Ratio.zero(), + .replica_pause_stability = 500, + .replica_unpause_probability = Ratio.zero(), + .replica_unpause_stability = 500, + + .replica_release_advance_probability = Ratio.zero(), + .replica_release_catchup_probability = Ratio.zero(), + + .requests_max = constants.journal_slot_count * 8, + .request_probability = ratio(100, 100), + .request_idle_on_probability = Ratio.zero(), + .request_idle_off_probability = ratio(100, 100), + }; +} + +pub const Simulator = struct { + pub const Options = struct { + cluster: Cluster.Options, + network: Cluster.NetworkOptions, + storage: Cluster.Storage.Options, + storage_fault_atlas: Cluster.StorageFaultAtlas.Options, + + workload: StateMachine.Workload.Options, + + /// Probability per tick that a crash will occur. + replica_crash_probability: Ratio, + /// Minimum duration of a crash. + replica_crash_stability: u32, + /// Probability per tick that a crashed replica will recovery. + replica_restart_probability: Ratio, + /// Minimum time a replica is up until it is crashed again. + replica_restart_stability: u32, + /// Probability per restart that a replica will be reformatted with `tigerbeetle recover` + /// (immediately before being restarted). + replica_reformat_probability: Ratio, + + // A replica permanently or temporarily missing from the cluster, used in performance mode. + replica_missing: ?u8 = null, + /// Restart `replica_missing` after the specified request has received its reply. + replica_missing_until_request: ?u32 = null, + + replica_pause_probability: Ratio, + replica_pause_stability: u32, + replica_unpause_probability: Ratio, + replica_unpause_stability: u32, + + /// Probability per tick that a healthy replica will be crash-upgraded. + /// This probability is set to 0 during liveness mode. + replica_release_advance_probability: Ratio, + /// Probability that a crashed with an outdated version will be upgraded as it restarts. + /// This helps ensure that when the cluster upgrades, that replicas without the newest + /// version don't take too long to receive that new version. + /// This probability is set to 0 during liveness mode. + replica_release_catchup_probability: Ratio, + + /// The total number of requests to send. Does not count `register` messages. + requests_max: usize, + request_probability: Ratio, + request_idle_on_probability: Ratio, + request_idle_off_probability: Ratio, + }; + + prng: *stdx.PRNG, + options: Options, + cluster: *Cluster, + workload: StateMachine.Workload, + + // The number of releases in each replica's "binary". + replica_releases: []usize, + /// The maximum number of releases available in any replica's "binary". + /// (i.e. the maximum of any `replica_releases`.) + replica_releases_limit: usize = 1, + + /// Keep track of which replicas have possibly "lost" data. + // TODO We could unset this when a replica fully recovers. + replica_reformats: Core = .{}, + + /// Protect a replica from fast successive crash/restarts. + replica_crash_stability: []usize, + reply_sequence: ReplySequence, + reply_op_next: u64 = 1, // Skip the root op. + + /// Fully-connected subgraph of replicas for liveness checking. + core: Core = .{}, + + /// Total number of requests sent, including those that have not been delivered. + /// Does not include `register` messages. + requests_sent: usize = 0, + /// Total number of replies received by non-evicted clients. + /// Does not include `register` messages. + requests_replied: usize = 0, + requests_idle: bool = false, + + pub fn init( + gpa: std.mem.Allocator, + prng: *stdx.PRNG, + options: Options, + ) !Simulator { + assert(options.requests_max > 0); + assert(options.request_probability.numerator > 0); + assert(options.request_idle_off_probability.numerator > 0); + + var cluster = try Cluster.init(gpa, .{ + .cluster = options.cluster, + .network = options.network, + .storage = options.storage, + .storage_fault_atlas = options.storage_fault_atlas, + .callbacks = .{ + .on_cluster_reply = on_cluster_reply, + .on_client_reply = on_client_reply, + }, + }); + errdefer cluster.deinit(); + + var workload = try StateMachine.Workload.init(gpa, prng, options.workload); + errdefer workload.deinit(gpa); + + const replica_releases = try gpa.alloc( + usize, + options.cluster.replica_count + options.cluster.standby_count, + ); + errdefer gpa.free(replica_releases); + @memset(replica_releases, 1); + + const replica_crash_stability = try gpa.alloc( + usize, + options.cluster.replica_count + options.cluster.standby_count, + ); + errdefer gpa.free(replica_crash_stability); + @memset(replica_crash_stability, 0); + + var reply_sequence = try ReplySequence.init(gpa); + errdefer reply_sequence.deinit(gpa); + + return Simulator{ + .prng = prng, + .options = options, + .cluster = cluster, + .workload = workload, + .replica_releases = replica_releases, + .replica_crash_stability = replica_crash_stability, + .reply_sequence = reply_sequence, + }; + } + + pub fn deinit(simulator: *Simulator, gpa: std.mem.Allocator) void { + gpa.free(simulator.replica_releases); + gpa.free(simulator.replica_crash_stability); + simulator.reply_sequence.deinit(gpa); + simulator.workload.deinit(gpa); + simulator.cluster.deinit(); + } + + pub fn pending(simulator: *const Simulator) ?[]const u8 { + assert(simulator.core.count() > 0); + assert(simulator.requests_sent - simulator.cluster.client_eviction_requests_cancelled <= + simulator.options.requests_max); + assert(simulator.reply_sequence.empty()); + for (simulator.cluster.clients) |*client_maybe| { + if (client_maybe.*) |client| { + if (client.request_inflight) |_| return "pending request"; + } + } + + // Even though there are no client requests in progress, the cluster may be upgrading. + const release_max = simulator.core_release_max(); + for (simulator.cluster.replicas) |*replica| { + if (simulator.core.is_set(replica.replica)) { + // (If down, the replica is waiting to be upgraded.) + maybe(simulator.cluster.replica_health[replica.replica] == .down); + + if (replica.release.value != release_max.value) return "pending upgrade"; + } + } + + for (simulator.cluster.replicas) |*replica| { + if (simulator.core.is_set(replica.replica)) { + if (!simulator.cluster.state_checker.replica_convergence(replica.replica)) { + return "pending replica convergence"; + } + } + } + + simulator.cluster.state_checker.assert_cluster_convergence(); + + // Check whether the replica is still repairing prepares/tables/replies. + const commit_max: u64 = simulator.cluster.state_checker.commits.items.len - 1; + for (simulator.cluster.replicas) |*replica| { + if (simulator.core.is_set(replica.replica)) { + for (replica.op_checkpoint() + 1..commit_max + 1) |op| { + const header = simulator.cluster.state_checker.header_with_op(op); + if (!replica.journal.has_prepare(&header)) return "pending journal"; + } + // It's okay for a replica to miss some prepares older than the current checkpoint. + maybe(replica.journal.faulty.count > 0); + + if (!replica.sync_content_done()) return "pending sync content"; + } + } + + // Expect that all core replicas have arrived at an identical (non-divergent) checkpoint. + var checkpoint_id: ?u128 = null; + for (simulator.cluster.replicas) |*replica| { + if (simulator.core.is_set(replica.replica)) { + const replica_checkpoint_id = replica.superblock.working.checkpoint_id(); + if (checkpoint_id) |id| { + assert(checkpoint_id == id); + } else { + checkpoint_id = replica_checkpoint_id; + } + } + } + assert(checkpoint_id != null); + + return null; + } + + pub fn tick(simulator: *Simulator) void { + // TODO(Zig): Remove (see on_cluster_reply()). + simulator.cluster.context = simulator; + + simulator.cluster.tick(); + simulator.tick_requests(); + simulator.tick_upgrade(); + simulator.tick_crash(); + simulator.tick_pause(); + + if (simulator.options.replica_missing_until_request) |request| { + if (simulator.requests_replied >= request) { + simulator.options.replica_missing_until_request = null; + simulator.replica_restart(simulator.options.replica_missing.?, false); + } + } + } + + pub fn cluster_recoverable(simulator: *Simulator, gpa: std.mem.Allocator) !bool { + if (simulator.core_missing_primary()) { + unimplemented("repair requires reachable primary"); + } else if (simulator.core_missing_quorum()) { + log.warn("no liveness, core replicas cannot view-change", .{}); + } else if (try simulator.core_missing_prepare(gpa)) |op| { + log.warn("no liveness, op={} is not available in core", .{op}); + } else if (try simulator.core_missing_blocks(gpa)) |blocks| { + log.warn("no liveness, {} blocks are not available in core", .{blocks}); + } else if (simulator.core_missing_reply()) |header| { + log.warn("no liveness, reply op={} is not available in core", .{header.op}); + } else if (simulator.core_reformat_evicted()) { + log.warn("no liveness, one or more reformat clients was evicted", .{}); + } else { + return true; + } + + return false; + } + + /// Executes the following: + /// * Restart any core replicas that are down at the moment + /// * Heal all network partitions between core replicas + /// * Disable storage faults on the core replicas + /// * For all failures involving non-core replicas, make those failures permanent. + /// + /// See https://tigerbeetle.com/blog/2023-07-06-simulation-testing-for-liveness for broader + /// context. + pub fn transition_to_liveness_mode(simulator: *Simulator, core: Core) void { + log.debug("transition_to_liveness_mode: core={b}", .{core.bits}); + assert(simulator.core.count() == 0); + defer assert(simulator.core.count() > 0); + + simulator.core = core; + + var it = core.iterate(); + while (it.next()) |replica_index| { + const fault = false; + if (simulator.cluster.replica_health[replica_index] == .down) { + simulator.replica_restart(@intCast(replica_index), fault); + } + + const replica_health = simulator.cluster.replica_health[replica_index]; + if (replica_health == .up and replica_health.up.paused) { + simulator.cluster.replica_unpause(@intCast(replica_index)); + } + + simulator.cluster.storages[replica_index].transition_to_liveness_mode(); + } + + simulator.cluster.network.transition_to_liveness_mode(simulator.core); + simulator.options.replica_crash_probability = Ratio.zero(); + simulator.options.replica_restart_probability = Ratio.zero(); + simulator.options.replica_reformat_probability = Ratio.zero(); + simulator.options.replica_pause_probability = Ratio.zero(); + simulator.options.replica_release_advance_probability = Ratio.zero(); + simulator.options.replica_release_catchup_probability = Ratio.zero(); + } + + // If a primary ends up being outside of a core, and is only partially connected to the core, + // the core might fail to converge, as parts of the repair protocol rely on primary-sent + // `.exit_view` messages. Until we fix this issue, we special-case this scenario in + // VOPR and don't treat it as a liveness failure. + // + // TODO: make sure that .recovering_head replicas can transition to normal even without direct + // connection to the primary + pub fn core_missing_primary(simulator: *const Simulator) bool { + assert(simulator.core.count() > 0); + + for (simulator.cluster.replicas) |*replica| { + if (simulator.cluster.replica_health[replica.replica] == .up and + replica.status == .normal and replica.primary() and + !simulator.core.is_set(replica.replica)) + { + // `replica` considers itself a primary, check that at least part of the core thinks + // so as well. + var it = simulator.core.iterate(); + while (it.next()) |replica_core_index| { + if (simulator.cluster.replicas[replica_core_index].view == replica.view) { + return true; + } + } + } + } + return false; + } + + /// The core contains at least a view-change quorum of replicas. But if one or more of those + /// replicas are in status=recovering_head (due to corruption) or are stuck reformatting, then + /// that may be insufficient. + pub fn core_missing_quorum(simulator: *const Simulator) bool { + assert(simulator.core.count() > 0); + + var core_replicas: u8 = 0; + var core_recovering: u8 = 0; + for ( + simulator.cluster.replicas, + simulator.cluster.replica_health, + ) |*replica, health| { + if (simulator.core.is_set(replica.replica) and !replica.standby()) { + core_replicas += 1; + switch (health) { + .up => core_recovering += @intFromBool(replica.status == .recovering_head), + .down => unreachable, + .reformatting => core_recovering += 1, + } + } + } + + const quorums = vsr.quorums(simulator.options.cluster.replica_count); + assert(quorums.view_change <= core_replicas); + return quorums.view_change > core_replicas - core_recovering; + } + + fn core_repairable_replica( + simulator: *const Simulator, + comptime Replica: type, + replica: *const Replica, + ) bool { + if (!simulator.core.is_set(replica.replica)) return false; + if (replica.standby()) return false; + if (simulator.cluster.replica_health[replica.replica] == .reformatting) return false; + assert(simulator.cluster.replica_health[replica.replica] == .up); + + switch (replica.status) { + .normal => return true, + .recovering_head => return false, + // Lagging replicas do not initiate WAL repair during view change. + .view_change => return !vsr.Checkpoint.durable( + replica.op_checkpoint_next(), + replica.commit_max, + ), + .recovering => unreachable, + } + } + + // Returns an op for a prepare which can't be repaired by the core due to storage faults. + // + // If a replica cannot make progress on committing, then it may be stuck while repairing either + // missing headers *or* prepares (see `repair` in replica.zig). This function checks for both. + // + // When generating a FaultAtlas, we don't try to protect core from excessive errors. Instead, + // if the core gets stuck, we verify that this is indeed due to storage faults. + pub fn core_missing_prepare( + simulator: *const Simulator, + gpa: std.mem.Allocator, + ) error{OutOfMemory}!?u64 { + assert(simulator.core.count() > 0); + const replica_count = simulator.options.cluster.replica_count; + + var cluster_op_head: u64 = 0; + var cluster_commit_max: u64 = 0; + var cluster_log_view: u32 = 0; + // maxInt(u16) is more than enough to accommodate `requests_max`. + var cluster_op_repair_min: u64 = std.math.maxInt(u16); + + for (simulator.cluster.replicas) |replica| { + if (!simulator.core_repairable_replica(Cluster.Replica, &replica)) continue; + + if (replica.log_view > cluster_log_view) { + maybe(cluster_op_head > replica.op); + cluster_op_head = replica.op; + } else if (replica.log_view == cluster_log_view) { + cluster_op_head = @max(cluster_op_head, replica.op); + } + + cluster_log_view = @max(cluster_log_view, replica.log_view); + cluster_commit_max = @max(cluster_commit_max, replica.commit_max); + cluster_op_repair_min = @min(cluster_op_repair_min, replica.op_repair_min()); + } + assert(cluster_commit_max <= cluster_op_head); + + // Use replicas with the largest log_view to infer uncommitted headers. Replicas with a + // smaller log_view may have an outdated version of the same uncommitted op. There may be + // at most a pipeline of uncommitted headers in the cluster. + const pipeline_max = constants.pipeline_prepare_queue_max; + var uncommitted_headers: [pipeline_max]?vsr.Header.Prepare = @splat(null); + if (cluster_commit_max < cluster_op_head) { + for (simulator.cluster.replicas) |replica| { + if (!simulator.core_repairable_replica(Cluster.Replica, &replica)) continue; + + if (replica.log_view < cluster_log_view) continue; + for (cluster_commit_max + 1..cluster_op_head + 1) |op| { + if (header: { + if (replica.superblock.working.vsr_state.log_view < + replica.superblock.working.vsr_state.view) + { + // When we are view-changing, our journal headers may contain headers + // which were truncated then restored due to restart. If a view change + // completes then that will be resolved, but if the view-change is stuck + // (e.g. due to "quorum received, awaiting repair") then they may + // linger, so if we only looked at the journal headers it would appear + // as if the replicas disagreed about the uncommitted headers. + const headers_count = replica.superblock.working.view_headers_count; + const headers = + replica.superblock.working.view_headers_all[0..headers_count]; + for (headers) |*header| { + if (header.op == op) { + break :header switch (vsr.Headers.jv_header_type(header)) { + .valid => header, + .blank => null, + }; + } + } else break :header null; + } else { + break :header replica.journal.header_with_op(op); + } + }) |header| { + if (uncommitted_headers[op % pipeline_max]) |header_existing| { + assert(header_existing.op == header.op); + assert(header_existing.view == header.view); + assert(header_existing.checksum == header.checksum); + } else { + uncommitted_headers[op % pipeline_max] = header.*; + } + } + } + } + } + + for (cluster_op_repair_min..cluster_op_head + 1) |op| { + if (op > cluster_commit_max) { + if (uncommitted_headers[op % pipeline_max] == null) { + // We can only be missing an uncommitted *header* (and be unable to nack it) + // if at least one replica was reformatted. + var core_replicas = simulator.core.iterate(); + while (core_replicas.next()) |replica| { + if (simulator.replica_reformats.is_set(replica)) break; + } else unreachable; + + return op; + } + } + } + + const ReplicaSet = stdx.BitSetType(constants.replicas_max); + var replicas_missing_ops = try gpa.alloc( + ReplicaSet, + cluster_op_head - cluster_op_repair_min + 1, + ); + defer gpa.free(replicas_missing_ops); + + for (replicas_missing_ops, cluster_op_repair_min..) |*replicas_missing_op, op| { + replicas_missing_op.* = .{}; + const header = blk: { + if (op > cluster_commit_max) { + const uncommitted_header = uncommitted_headers[op % pipeline_max].?; + assert(uncommitted_header.op == op); + break :blk uncommitted_header; + } else { + break :blk simulator.cluster.state_checker.header_with_op(op); + } + }; + for (simulator.cluster.replicas) |replica| { + // Replicas should be able to repair using any other replica in the core. + if (replica.standby()) continue; + if (simulator.cluster.replica_health[replica.replica] == .reformatting or + !simulator.core.is_set(replica.replica) or + !replica.journal.has_prepare(&header)) + { + replicas_missing_op.set(replica.replica); + } + } + } + + // Check whether any of the uncommitted headers is corrupted on more than a nack + // quorum of replicas. If so, the cluster cannot initiate repair or commit (see the + // awaiting_repair and complete_invalid cases in the JVQuorum). + const nack_quorum = vsr.quorums(replica_count).nack_prepare; + for (cluster_commit_max..cluster_op_head + 1) |op| { + if (replicas_missing_ops[op - cluster_op_repair_min].count() >= nack_quorum) { + const header = blk: { + if (op > cluster_commit_max) { + const uncommitted_header = uncommitted_headers[op % pipeline_max].?; + assert(uncommitted_header.op == op); + break :blk uncommitted_header; + } else { + break :blk simulator.cluster.state_checker.header_with_op(op); + } + }; + return header.op; + } + } + + for (simulator.cluster.replicas) |replica| { + if (!simulator.core_repairable_replica(Cluster.Replica, &replica)) continue; + if (simulator.cluster.replica_health[replica.replica] == .reformatting) continue; + + // Check prepares between (commit_min, commit_max], replicas repair these first + // as commit progress depends on them. + if (replica.commit_min < replica.commit_max) { + for (replica.commit_min + 1..replica.commit_max + 1) |op| { + if (replicas_missing_ops[op - cluster_op_repair_min].count() == replica_count) { + return op; + } + } + } + + // Check prepares between [op_repair_min, commit_min] as view changing replicas + // cannot step up as primary unless they have all prepares intact. + if (replica.op_repair_min() <= replica.commit_min) { + for (replica.op_repair_min()..replica.commit_min + 1) |op| { + if (replicas_missing_ops[op - cluster_op_repair_min].count() == replica_count) { + return op; + } + } + } + } + return null; + } + + /// Check whether the cluster is stuck because the entire core is missing the same block[s]. + pub fn core_missing_blocks( + simulator: *const Simulator, + gpa: std.mem.Allocator, + ) error{OutOfMemory}!?usize { + assert(simulator.core.count() > 0); + + const FaultyReplicas = stdx.BitSetType(constants.members_max); + var blocks_missing = std.AutoArrayHashMap( + struct { address: u64, checksum: u128 }, + FaultyReplicas, + ).init(gpa); + defer blocks_missing.deinit(); + + // Find all blocks that any replica in the core is missing. + for (simulator.cluster.replicas) |replica| { + if (!simulator.core.is_set(replica.replica)) continue; + if (simulator.cluster.replica_health[replica.replica] == .reformatting) continue; + + const storage = &simulator.cluster.storages[replica.replica]; + + var fault_iterator = replica.grid.read_global_queue.iterate(); + while (fault_iterator.next()) |faulty_read| { + const v = try blocks_missing.getOrPut(.{ + .address = faulty_read.address, + .checksum = faulty_read.checksum, + }); + + if (!v.found_existing) v.value_ptr.* = .{}; + v.value_ptr.set(replica.replica); + + log.debug("{}: core_missing_blocks: " ++ + "missing address={} checksum={x:0>32} corrupt={} (remote read)", .{ + replica.replica, + faulty_read.address, + faulty_read.checksum, + storage.area_faulty(.{ .grid = .{ .address = faulty_read.address } }), + }); + } + + var repair_iterator = replica.grid.blocks_missing.faulty_blocks.iterator(); + while (repair_iterator.next()) |fault| { + const v = try blocks_missing.getOrPut(.{ + .address = fault.key_ptr.*, + .checksum = fault.value_ptr.checksum, + }); + + if (!v.found_existing) v.value_ptr.* = .{}; + v.value_ptr.set(replica.replica); + + log.debug("{}: core_missing_blocks: " ++ + "missing address={} checksum={x:0>32} corrupt={} (GridBlocksMissing)", .{ + replica.replica, + fault.key_ptr.*, + fault.value_ptr.checksum, + storage.area_faulty(.{ .grid = .{ .address = fault.key_ptr.* } }), + }); + } + } + + // Check whether every replica in the core is missing the blocks. + // (If any core replica has the block, then that is a bug, since it should have repaired.) + var blocks_missing_iterator = blocks_missing.iterator(); + while (blocks_missing_iterator.next()) |block_missing_and_faulty_replicas| { + const block_missing = block_missing_and_faulty_replicas.key_ptr; + const faulty_replicas = block_missing_and_faulty_replicas.value_ptr; + for (simulator.cluster.replicas) |replica| { + const storage = &simulator.cluster.storages[replica.replica]; + + // A replica might actually have the block that it is requesting, but not know. + // This can occur after state sync: if we compact and create a table, but then skip + // over that table via state sync, we will try to sync the table anyway. + if (faulty_replicas.is_set(replica.replica)) continue; + + if (!simulator.core.is_set(replica.replica)) continue; + if (simulator.cluster.replica_health[replica.replica] == .reformatting) continue; + if (replica.standby()) continue; + if (storage.area_faulty(.{ + .grid = .{ .address = block_missing.address }, + })) continue; + + const block = storage.grid_block(block_missing.address) orelse continue; + const block_header = schema.header_from_block(block); + if (block_header.checksum == block_missing.checksum) { + log.err("{}: core_missing_blocks: found address={} checksum={x:0>32}", .{ + replica.replica, + block_missing.address, + block_missing.checksum, + }); + @panic("block found in core"); + } + } + } + + if (blocks_missing.count() == 0) { + return null; + } else { + return blocks_missing.count(); + } + } + + /// Check whether the cluster is stuck because the entire core is missing the same reply[s]. + pub fn core_missing_reply(simulator: *const Simulator) ?vsr.Header.Reply { + assert(simulator.core.count() > 0); + + for (simulator.cluster.state_checker.client_replies.values()) |reply| { + const reply_in_core = reply_in_core: for (simulator.cluster.replicas) |replica| { + if (simulator.cluster.replica_health[replica.replica] == .reformatting) continue; + + const storage = &simulator.cluster.storages[replica.replica]; + const storage_replies = storage.client_replies(); + if (simulator.core.is_set(replica.replica) and !replica.standby()) { + for (storage_replies, 0..) |storage_reply, reply_slot| { + if (storage_reply.header.checksum == reply.checksum and + !storage.area_faulty(.{ .client_replies = .{ .slot = reply_slot } })) + { + break :reply_in_core true; + } + } + } + } else false; + + if (!reply_in_core) return reply; + } + + return null; + } + + /// The cluster was unable to upgrade because one or more of its reformat clients were evicted. + /// This is not strictly related to the core -- an upgrade requires all (non-standby) replicas. + pub fn core_reformat_evicted(simulator: *const Simulator) bool { + assert(simulator.core.count() > 0); + + const eviction_reasons = simulator.cluster.client_eviction_reasons; + const eviction_reasons_reformats = + eviction_reasons[simulator.cluster.options.client_count..]; + assert(eviction_reasons_reformats.len == simulator.cluster.options.reformats_max); + + for (eviction_reasons_reformats) |reason_or_null| { + if (reason_or_null) |reason| { + log.err("reformat evicted with {s}", .{@tagName(reason)}); + assert(reason == .no_session or reason == .session_too_low); + return true; + } + } + return false; + } + + fn core_release_max(simulator: *const Simulator) vsr.Release { + assert(simulator.core.count() > 0); + + var release_max: vsr.Release = vsr.Release.zero; + for (simulator.cluster.replicas) |*replica| { + if (simulator.core.is_set(replica.replica)) { + release_max = release_max.max(replica.release); + if (replica.upgrade_release) |release| { + release_max = release_max.max(release); + } + } + } + assert(release_max.value > 0); + return release_max; + } + + fn on_cluster_reply( + cluster: *Cluster, + reply_client: ?usize, + prepare: *const Message.Prepare, + reply: *const Message.Reply, + ) void { + assert((reply_client == null) == (prepare.header.client == 0)); + + const simulator: *Simulator = @ptrCast(@alignCast(cluster.context.?)); + + if (reply.header.op < simulator.reply_op_next) return; + if (simulator.reply_sequence.contains(reply)) return; + + simulator.reply_sequence.insert(reply_client, prepare, reply); + + while (!simulator.reply_sequence.empty()) { + const op = simulator.reply_op_next; + const prepare_header = simulator.cluster.state_checker.commits.items[op].header; + assert(prepare_header.op == op); + + if (simulator.reply_sequence.peek(op)) |commit| { + defer simulator.reply_sequence.next(); + + simulator.reply_op_next += 1; + + assert(commit.reply.references == 1); + assert(commit.reply.header.op == op); + assert(commit.reply.header.command == .reply); + assert(commit.reply.header.request == commit.prepare.header.request); + assert(commit.reply.header.operation == commit.prepare.header.operation); + assert(commit.prepare.references == 1); + assert(commit.prepare.header.checksum == prepare_header.checksum); + assert(commit.prepare.header.command == .prepare); + + log.debug("consume_stalled_replies: op={} operation={} client={} request={}", .{ + commit.reply.header.op, + commit.reply.header.operation, + commit.prepare.header.client, + commit.prepare.header.request, + }); + + if (prepare_header.operation == .pulse) { + simulator.workload.on_pulse( + prepare_header.operation.cast(StateMachine.Operation), + prepare_header.timestamp, + ); + } + + if (!commit.prepare.header.operation.vsr_reserved()) { + simulator.workload.on_reply( + commit.client_index.?, + commit.reply.header.operation.cast(StateMachine.Operation), + commit.reply.header.timestamp, + commit.prepare.body_used(), + commit.reply.body_used(), + ); + } + } + } + } + + fn on_client_reply( + cluster: *Cluster, + reply_client: usize, + request: *const Message.Request, + reply: *const Message.Reply, + ) void { + _ = reply; + + const simulator: *Simulator = @ptrCast(@alignCast(cluster.context.?)); + assert(simulator.cluster.client_eviction_reasons[reply_client] == null); + + if (!request.header.operation.vsr_reserved()) { + simulator.requests_replied += 1; + } + } + + /// Maybe send a request from one of the cluster's clients. + fn tick_requests(simulator: *Simulator) void { + if (simulator.requests_idle) { + if (simulator.prng.chance(simulator.options.request_idle_off_probability)) { + simulator.requests_idle = false; + } + } else { + if (simulator.prng.chance(simulator.options.request_idle_on_probability)) { + simulator.requests_idle = true; + } + } + + if (simulator.requests_idle) return; + if (simulator.requests_sent - simulator.cluster.client_eviction_requests_cancelled == + simulator.options.requests_max) return; + if (!simulator.prng.chance(simulator.options.request_probability)) return; + + const client_index = index: { + const client_count = simulator.options.cluster.client_count; + const client_index_base = + simulator.prng.int_inclusive(usize, client_count - 1); + for (0..client_count) |offset| { + const client_index = (client_index_base + offset) % client_count; + if (simulator.cluster.client_eviction_reasons[client_index] == null) { + break :index client_index; + } + } else { + for (0..client_count) |index| { + assert(simulator.cluster.client_eviction_reasons[index] != null); + assert(simulator.cluster.client_eviction_reasons[index] == .no_session or + simulator.cluster.client_eviction_reasons[index] == .session_too_low); + } + unimplemented("client replacement; all clients were evicted"); + } + }; + + var client = &simulator.cluster.clients[client_index].?; + + // Messages aren't added to the ReplySequence until a reply arrives. + // Before sending a new message, make sure there will definitely be room for it. + var reserved: usize = 0; + for ( + simulator.cluster.clients, + ) |*client_maybe| { + if (client_maybe.*) |*c| { + // Count the number of clients that are still waiting for a `register` to complete, + // since they may start one at any time. + reserved += @intFromBool(c.session == 0); + // Count the number of non-register requests queued. + reserved += @intFromBool(c.request_inflight != null); + } + } + // +1 for the potential request — is there room in the sequencer's queue? + if (reserved + 1 > simulator.reply_sequence.free()) return; + + // Make sure that the client is ready to send a new request. + if (client.request_inflight != null) return; + const request_message = client.get_message(); + errdefer client.release_message(request_message); + + const request_metadata = simulator.workload.build_request( + client_index, + request_message.buffer[@sizeOf(vsr.Header)..constants.message_size_max], + ); + assert(request_metadata.size <= constants.message_body_size_max); + + simulator.cluster.request( + client_index, + request_metadata.operation, + request_message, + request_metadata.size, + ); + // Since we already checked the client's request queue for free space, `client.request()` + // should always queue the request. + assert(request_message == client.request_inflight.?.message.base()); + assert(request_message.header.size == @sizeOf(vsr.Header) + request_metadata.size); + assert(request_message.header.into(.request).?.operation.cast(StateMachine.Operation) == + request_metadata.operation); + + simulator.requests_sent += 1; + assert(simulator.requests_sent - simulator.cluster.client_eviction_requests_cancelled <= + simulator.options.requests_max); + } + + fn tick_upgrade(simulator: *Simulator) void { + for (simulator.cluster.replicas) |*replica| { + const upgrade = + simulator.replica_releases[replica.replica] < releases.len and + simulator.prng.chance(simulator.options.replica_release_advance_probability); + if (upgrade) simulator.replica_upgrade(replica.replica); + } + } + + fn tick_crash(simulator: *Simulator) void { + for (simulator.cluster.replicas) |*replica| { + simulator.replica_crash_stability[replica.replica] -|= 1; + if (simulator.replica_crash_stability[replica.replica] > 0) continue; + + switch (simulator.cluster.replica_health[replica.replica]) { + .up => |up| { + if (!up.paused) simulator.tick_crash_up(replica); + }, + .down => simulator.tick_crash_down(replica), + .reformatting => {}, + } + } + } + + fn tick_crash_up(simulator: *Simulator, replica: *Cluster.Replica) void { + const replica_storage = &simulator.cluster.storages[replica.replica]; + const replica_writes = replica_storage.writes.count(); + + var crash_probability = simulator.options.replica_crash_probability; + if (replica_writes > 0) crash_probability.numerator *= 10; + + const crash_random = simulator.prng.chance(crash_probability); + + if (!crash_random) return; + + log.debug("{}: crash replica", .{replica.replica}); + simulator.cluster.replica_crash(replica.replica); + + simulator.replica_crash_stability[replica.replica] = + simulator.options.replica_crash_stability; + } + + fn tick_crash_down(simulator: *Simulator, replica: *Cluster.Replica) void { + // If we are in liveness mode, we need to make sure that all replicas + // (eventually) make it to the same release. + const restart_upgrade = + simulator.replica_releases[replica.replica] < + simulator.replica_releases_limit and + (simulator.core.is_set(replica.replica) or + simulator.prng.chance(simulator.options.replica_release_catchup_probability)); + if (restart_upgrade) simulator.replica_upgrade(replica.replica); + + const restart_random = + simulator.prng.chance(simulator.options.replica_restart_probability); + + if (!restart_upgrade and !restart_random) return; + + const recoverable_count_min = + vsr.quorums(simulator.options.cluster.replica_count).view_change; + + var recoverable_count: usize = 0; + for (simulator.cluster.replicas, 0..) |*r, i| { + recoverable_count += @intFromBool(simulator.cluster.replica_health[i] == .up and + !simulator.replica_reformats.is_set(replica.replica) and + !r.standby() and + r.status != .recovering_head and + r.syncing == .idle); + } + + // To improve VOPR utilization, try to prevent the replica from going into + // `.recovering_head` state if the replica is needed to form a quorum. + const fault = recoverable_count >= recoverable_count_min or replica.standby(); + if (fault) { + const reformat_random = + !replica.standby() and + simulator.cluster.reformat_count < simulator.cluster.options.reformats_max and + simulator.prng.chance(simulator.options.replica_reformat_probability); + if (reformat_random) { + log.debug("{}: reformat replica", .{replica.replica}); + + simulator.replica_reformats.set(replica.replica); + simulator.cluster.replica_reformat(replica.replica) catch unreachable; + return; + } + } + simulator.replica_restart(replica.replica, fault); + maybe(!fault and replica.status == .recovering_head); + } + + fn replica_restart(simulator: *Simulator, replica_index: u8, fault: bool) void { + assert(simulator.cluster.replica_health[replica_index] == .down); + + const replica_storage = &simulator.cluster.storages[replica_index]; + const replica: *const Cluster.Replica = &simulator.cluster.replicas[replica_index]; + + { + // If the entire Zone.wal_headers is corrupted, the replica becomes permanently + // unavailable (returns `WALInvalid` from `open`). In the simulator, there are only two + // WAL sectors, which could both get corrupted when a replica crashes while writing them + // simultaneously. Repair both sectors so that even if one of them becomes corrupted on + // startup, the replica still remains operational. + // + // In production `journal_iops_write_max < header_sector_count`, which makes is + // impossible to get torn writes for all journal header sectors at the same time. + const header_sector_offset = + @divExact(vsr.Zone.wal_headers.start(), constants.sector_size); + const header_sector_count = + @divExact(constants.journal_size_headers, constants.sector_size); + for (0..header_sector_count) |header_sector_index| { + replica_storage.faults.unset(header_sector_offset + header_sector_index); + } + // TODO Clear misdirects? Waiting for a seed to confirm. + } + + var header_prepare_view_mismatch: bool = false; + if (!fault) { + // The journal writes redundant headers of faulty ops as zeroes to ensure + // that they remain faulty after a crash/recover. Since that fault cannot + // be disabled by `storage.faulty`, we must manually repair it here to + // ensure a cluster cannot become stuck in status=recovering_head. + // See recover_slots() for more detail. + const headers_offset = vsr.Zone.wal_headers.offset(0); + const headers_size = vsr.Zone.wal_headers.size().?; + const headers_bytes = replica_storage.memory[headers_offset..][0..headers_size]; + for ( + mem.bytesAsSlice(vsr.Header.Prepare, headers_bytes), + replica_storage.wal_prepares(), + ) |*wal_header, *wal_prepare| { + if (wal_header.checksum == 0) { + wal_header.* = wal_prepare.header; + } else { + if (wal_header.view != wal_prepare.header.view) { + header_prepare_view_mismatch = true; + } + } + } + } + + log.debug("{}: restart replica (faults={} releases={})", .{ + replica_index, + fault, + simulator.replica_releases[replica_index], + }); + + replica_storage.faulty = fault; + simulator.cluster.replica_restart(replica_index) catch unreachable; + + if (replica.status == .recovering_head) { + // Even with faults disabled, a replica may wind up in status=recovering_head. + assert(fault or header_prepare_view_mismatch); + } + + replica_storage.faulty = true; + simulator.replica_crash_stability[replica_index] = + simulator.options.replica_restart_stability; + } + + fn replica_upgrade(simulator: *Simulator, replica_index: u8) void { + simulator.replica_releases[replica_index] = + @min(simulator.replica_releases[replica_index] + 1, releases.len); + simulator.replica_releases_limit = + @max(simulator.replica_releases[replica_index], simulator.replica_releases_limit); + + const replica_releases = simulator.replica_release_list(replica_index); + simulator.cluster.replica_set_releases(replica_index, &replica_releases); + } + + fn replica_release_list(simulator: *const Simulator, replica_index: u8) vsr.ReleaseList { + const replica_releases_count = simulator.replica_releases[replica_index]; + var release_list: vsr.ReleaseList = .empty; + for (0..replica_releases_count) |i| { + release_list.push(releases[i].release); + } + release_list.verify(); + return release_list; + } + + // Randomly pause replicas. A paused replica doesn't tick and doesn't complete any asynchronous + // work. The goals of pausing are: + // - catch more interesting interleaving of events, + // - simulate real-world scenario of VM migration. + fn tick_pause(simulator: *Simulator) void { + for ( + simulator.cluster.replicas, + simulator.replica_crash_stability, + 0.., + ) |*replica, *stability, replica_index| { + stability.* -|= 1; + if (stability.* > 0) continue; + + if (simulator.cluster.replica_health[replica.replica] != .up) continue; + const paused = simulator.cluster.replica_health[replica.replica].up.paused; + const pause = simulator.prng.chance(simulator.options.replica_pause_probability); + const unpause = simulator.prng.chance(simulator.options.replica_unpause_probability); + + if (!paused and pause) { + simulator.cluster.replica_pause(@intCast(replica_index)); + stability.* = simulator.options.replica_pause_stability; + } else if (paused and unpause) { + simulator.cluster.replica_unpause(@intCast(replica_index)); + stability.* = simulator.options.replica_unpause_stability; + } + } + } +}; + +/// Print an error message and then exit with an exit code. +fn fatal(failure: Failure, comptime fmt_string: []const u8, args: anytype) noreturn { + log.err(fmt_string, args); + std.process.exit(@intFromEnum(failure)); +} + +/// Signal that something is not yet fully implemented, and abort the process. +/// +/// In VOPR, this will exit with status 0, to make it easy to find "real" failures by running +/// the simulator in a loop. +fn unimplemented(comptime message: []const u8) noreturn { + const full_message = "unimplemented: " ++ message; + log.info(full_message, .{}); + log.info("not crashing in VOPR", .{}); + std.process.exit(0); +} + +/// Returns a random fully-connected subgraph which includes at least view change +/// quorum of active replicas. +fn random_core(prng: *stdx.PRNG, replica_count: u8, standby_count: u8) Core { + assert(replica_count > 0); + assert(replica_count <= constants.replicas_max); + assert(standby_count <= constants.standbys_max); + + const quorum_view_change = vsr.quorums(replica_count).view_change; + const replica_core_count = prng.range_inclusive(u8, quorum_view_change, replica_count); + const standby_core_count = prng.range_inclusive(u8, 0, standby_count); + + var core: Core = .{}; + + var combination = stdx.PRNG.Combination.init(.{ + .total = replica_count, + .sample = replica_core_count, + }); + for (0..replica_count) |replica| { + if (combination.take(prng)) core.set(replica); + } + assert(combination.done()); + + combination = stdx.PRNG.Combination.init(.{ + .total = standby_count, + .sample = standby_core_count, + }); + for (replica_count..replica_count + standby_count) |standby| { + if (combination.take(prng)) core.set(standby); + } + assert(combination.done()); + + assert(core.count() == replica_core_count + standby_core_count); + + return core; +} + +/// Returns a random fully-connected subgraph which includes all replicas and standbys. +fn full_core(replica_count: u8, standby_count: u8) Core { + assert(replica_count > 0); + assert(replica_count <= constants.replicas_max); + assert(standby_count <= constants.standbys_max); + + var core: Core = .{}; + + for (0..replica_count + standby_count) |replica| core.set(replica); + + assert(core.count() == replica_count + standby_count); + + return core; +} + +var log_buffer: std.io.BufferedWriter(4096, std.fs.File.Writer) = .{ + // This is initialized in main(), as std.io.getStdErr() is not comptime known on e.g. Windows. + .unbuffered_writer = undefined, +}; + +var log_performance_mode: bool = false; + +fn log_override( + comptime level: std.log.Level, + comptime scope: @TypeOf(.enum_literal), + comptime format: []const u8, + args: anytype, +) void { + if ((scope == .vsr and level == .err) or scope == .gpa) { + // Always print a message for vsr.fatal. + } else { + if (vsr_vopr_options.log == .short) { + if (log_performance_mode) { + if (scope != .simulator) return; + } else { + if (scope != .simulator and scope != .cluster) return; + } + } + } + + const prefix_default = "[" ++ @tagName(level) ++ "] " ++ "(" ++ @tagName(scope) ++ "): "; + const prefix = if (vsr_vopr_options.log == .short) "" else prefix_default; + + // Print the message to stderr using a buffer to avoid many small write() syscalls when + // providing many format arguments. Silently ignore failure. + log_buffer.writer().print(prefix ++ format ++ "\n", args) catch {}; + + // Flush the buffer before returning to ensure, for example, that a log message + // immediately before a failing assertion is fully printed. + log_buffer.flush() catch {}; +} diff --git a/ocam/src/vortex.zig b/ocam/src/vortex.zig new file mode 100644 index 00000000..50d5b5ac --- /dev/null +++ b/ocam/src/vortex.zig @@ -0,0 +1,141 @@ +/// Run a cluster of TigerBeetle replicas, a client driver, a workload, all with fault injection, +/// to test the whole system. +/// +/// On Linux, Vortex runs in a Linux namespace where it can control the network. +const std = @import("std"); +const stdx = @import("stdx"); +const builtin = @import("builtin"); + +const Supervisor = @import("testing/vortex/supervisor.zig").Supervisor; +const Command = @import("testing/vortex/workload.zig").Command; +const dependencies_count: u32 = @import("vortex_options").dependencies_count; + +const assert = std.debug.assert; +const log = std.log.scoped(.vortex); + +pub const std_options: std.Options = .{ + .log_level = .info, + .logFn = stdx.log_with_timestamp, +}; + +const CLIArgs = struct { + test_duration: stdx.Duration = .minutes(1), + driver_command: ?[]const u8 = null, + replica_count: u8 = 1, + disable_faults: bool = false, + log_debug: bool = false, + /// Log file path. + log: ?[]const u8 = null, + + @"--": void, + /// Vortex is non-deterministic, but providing a seed can still help constrain the scenario. + seed: ?u64 = null, +}; + +pub fn main() !void { + comptime assert(builtin.target.cpu.arch.endian() == .little); + + if (builtin.os.tag == .windows) { + // Vortex is not currently supported on Windows because of child process management. + // e.g. waitpid, pause/unpause. + log.err("vortex is not supported for Windows", .{}); + return error.NotSupported; + } + + if (builtin.os.tag == .macos) { + // Vortex is not currently supported on MacOS because io.write() is implemented with + // pwrite(), which doesn't work on non-seekable streams like child process input/output. + log.err("vortex is not supported for MacOS", .{}); + return error.NotSupported; + } + assert(builtin.os.tag == .linux); + + var gpa_allocator = std.heap.GeneralPurposeAllocator(.{}){}; + defer switch (gpa_allocator.deinit()) { + .ok => {}, + .leak => @panic("memory leak"), + }; + + const allocator = gpa_allocator.allocator(); + + var flags = stdx.Flags.init(allocator); + defer flags.deinit(allocator); + + const args = flags.parse(CLIArgs); + + if (args.log) |log_path| { + const log_file = try std.fs.cwd().createFile(log_path, .{}); + defer log_file.close(); + + // Redirect stderr to the file. + try std.posix.dup2(log_file.handle, std.posix.STDERR_FILENO); + } + + if (builtin.os.tag == .linux) { + // Relaunch in fresh pid / network namespaces. + try stdx.unshare.maybe_unshare_and_relaunch(allocator, .{ + .pid = true, + .network = true, + }); + } else { + log.warn("vortex may spawn runaway processes when run on a non-Linux OS", .{}); + log.warn("vortex may encounter port collisions non-Linux OS", .{}); + } + + if (dependencies_count == 1 or args.disable_faults or args.driver_command != null) { + log.warn("not testing upgrades", .{}); + } + + const seed = args.seed orelse std.crypto.random.int(u64); + var prng = stdx.PRNG.from_seed(seed); + + // Even if we have past versions available, only use them sometimes. + const release_min = prng.range_inclusive( + u32, + if (args.disable_faults or args.driver_command != null) dependencies_count - 1 else 0, + dependencies_count - 1, + ); + + const supervisor = try Supervisor.create(allocator, .{ + .seed = prng.int(u64), + .replica_count = args.replica_count, + .faulty = !args.disable_faults, + .log_debug = args.log_debug, + }); + defer supervisor.destroy(); + + log.info("seed={}", .{seed}); + log.info("output_directory={s}", .{supervisor.output_directory}); + log.info("duration={}", .{args.test_duration}); + log.info("releases={any}", .{supervisor.releases}); + + for (0..args.replica_count) |replica_index| { + try supervisor.replica_install(@intCast(replica_index), release_min); + try supervisor.replica_format(@intCast(replica_index)); + try supervisor.replica_start(@intCast(replica_index)); + } + try supervisor.workload_start( + if (args.driver_command) |driver_command| + .{ .command = driver_command } + else + .{ .release = supervisor.prng.range_inclusive(u32, 0, release_min) }, + .{ .transfer_count = std.math.maxInt(u32) }, + ); + + var timer = try std.time.Timer.start(); + while (timer.read() < args.test_duration.ns) { + try supervisor.tick(); + } + + log.info("workload: terminating due to max duration", .{}); + log.info("workload: created accounts={}", .{supervisor.workload.?.model.accounts.count()}); + log.info("workload: created transfers={}", .{supervisor.workload.?.model.transfers_created}); + for (std.enums.values(Command)) |command| { + log.info("workload: completed command={s} count={}", .{ + @tagName(command), + supervisor.workload.?.requests_finished_count.getAssertContains(command), + }); + } + supervisor.workload_terminate(); + log.info("done", .{}); +} diff --git a/ocam/src/vsr.zig b/ocam/src/vsr.zig new file mode 100644 index 00000000..26b2e95b --- /dev/null +++ b/ocam/src/vsr.zig @@ -0,0 +1,1786 @@ +const std = @import("std"); +const math = std.math; +const assert = std.debug.assert; +const maybe = stdx.maybe; +const log = std.log.scoped(.vsr); + +// vsr.zig is the root of a zig package, reexport all public APIs. +// +// Note that we don't promise any stability of these interfaces yet. +pub const cdc = @import("cdc/runner.zig"); +pub const constants = @import("constants.zig"); +pub const io = @import("io.zig"); +pub const queue = @import("queue.zig"); +pub const stack = @import("stack.zig"); +pub const message_buffer = @import("message_buffer.zig"); +pub const message_bus = @import("message_bus.zig"); +pub const message_pool = @import("message_pool.zig"); +pub const state_machine = @import("state_machine.zig"); +pub const storage = @import("storage.zig"); +pub const tb_client = @import("clients/c/tb_client.zig"); +pub const tigerbeetle = @import("tigerbeetle.zig"); +pub const time = @import("time.zig"); +pub const trace = @import("trace.zig"); +pub const stdx = @import("stdx"); +pub const grid = @import("vsr/grid.zig"); +pub const superblock = @import("vsr/superblock.zig"); +pub const aof = @import("aof.zig"); +pub const repl = @import("repl.zig"); +pub const lsm = .{ + .tree = @import("lsm/tree.zig"), + .groove = @import("lsm/groove.zig"), + .forest = @import("lsm/forest.zig"), + .schema = @import("lsm/schema.zig"), + .composite_key = @import("lsm/composite_key.zig"), + .TimestampRange = @import("lsm/timestamp_range.zig").TimestampRange, +}; +pub const testing = .{ + .cluster = @import("testing/cluster.zig"), + .random_int_exponential = @import("testing/fuzz.zig").random_int_exponential, + .IdPermutation = @import("testing/id.zig").IdPermutation, + .parse_seed = @import("testing/fuzz.zig").parse_seed, + .fixtures = @import("testing/fixtures.zig"), +}; +pub const ewah = @import("ewah.zig").ewah; +pub const checkpoint_trailer = @import("vsr/checkpoint_trailer.zig"); + +pub const multi_batch = @import("vsr/multi_batch.zig"); + +pub const ReplicaType = @import("vsr/replica.zig").ReplicaType; +pub const ReplicaEvent = @import("vsr/replica.zig").ReplicaEvent; +pub const ReplicaReformatType = @import("vsr/replica_reformat.zig").ReplicaReformatType; +pub const format = @import("vsr/replica_format.zig").format; +pub const Status = @import("vsr/replica.zig").Status; +pub const SyncStage = @import("vsr/sync.zig").Stage; +pub const SyncTarget = @import("vsr/sync.zig").Target; +pub const ClientType = @import("vsr/client.zig").ClientType; +pub const Clock = @import("vsr/clock.zig").Clock; +pub const GridType = @import("vsr/grid.zig").GridType; +pub const JournalType = @import("vsr/journal.zig").JournalType; +pub const ClientSessions = @import("vsr/client_sessions.zig").ClientSessions; +pub const ClientRepliesType = @import("vsr/client_replies.zig").ClientRepliesType; +pub const SlotRange = @import("vsr/journal.zig").SlotRange; +pub const SuperBlockType = superblock.SuperBlockType; +pub const SuperBlockManifestReferences = superblock.ManifestReferences; +pub const SuperBlockTrailerReference = superblock.TrailerReference; +pub const VSRState = superblock.SuperBlockHeader.VSRState; +pub const CheckpointState = superblock.SuperBlockHeader.CheckpointState; +pub const checksum = @import("vsr/checksum.zig").checksum; +pub const ChecksumStream = @import("vsr/checksum.zig").ChecksumStream; +pub const Header = @import("vsr/message_header.zig").Header; +pub const FreeSet = @import("vsr/free_set.zig").FreeSet; +pub const CheckpointTrailerType = @import("vsr/checkpoint_trailer.zig").CheckpointTrailerType; +pub const GridScrubberType = @import("vsr/grid_scrubber.zig").GridScrubberType; + +pub const FaultDetector = @import("vsr/fault_detector.zig"); +pub const CountingAllocator = @import("counting_allocator.zig"); + +/// The version of our Viewstamped Replication protocol in use, including customizations. +/// For backwards compatibility through breaking changes (e.g. upgrading checksums/ciphers). +pub const Version: u16 = 0; + +pub const multiversion = @import("multiversion.zig"); +pub const ReleaseList = multiversion.ReleaseList; +pub const Release = multiversion.Release; +pub const ReleaseTriple = multiversion.ReleaseTriple; + +pub const ProcessType = enum { replica, client }; +pub const Peer = union(enum) { + unknown, + replica: u8, + client: u128, + client_likely: u128, + + pub fn transition(old: Peer, new: Peer) enum { retain, update, reject } { + return switch (old) { + .unknown => .update, + .client_likely => switch (new) { + .client_likely => if (std.meta.eql(old, new)) + .retain + else + // Receiving requests from two different clients on the same connection implies + // that we are talking to a replica. However, as we don't know which one, we + // retain this as a connection to a client, for simplicity. + .retain, + .client => if (old.client_likely == new.client) .update else .reject, + .replica => .update, + .unknown => .retain, + }, + + .replica => switch (new) { + .replica => if (std.meta.eql(old, new)) .retain else .reject, + .client => .reject, + .client_likely, .unknown => .retain, + }, + .client => switch (new) { + .client => if (std.meta.eql(old, new)) .retain else .reject, + .client_likely => if (old.client == new.client_likely) .retain else .reject, + .replica => .reject, + .unknown => .retain, + }, + }; + } +}; + +pub const Zone = enum { + superblock, + wal_headers, + wal_prepares, + client_replies, + // Add padding between `client_replies` and `grid`, to make sure grid blocks are aligned to + // block size and not just to sector size. Aligning blocks this way makes it more likely that + // they are aligned to the underlying physical sector size. This padding is zeroed during + // format, but isn't used otherwise. + grid_padding, + grid, + + const size_superblock = superblock.superblock_zone_size; + const size_wal_headers = constants.journal_size_headers; + const size_wal_prepares = constants.journal_size_prepares; + const size_client_replies = constants.client_replies_size; + const size_grid_padding = size_grid_padding: { + const grid_start_unaligned = size_superblock + + size_wal_headers + + size_wal_prepares + + size_client_replies; + const grid_start_aligned = std.mem.alignForward( + usize, + grid_start_unaligned, + constants.block_size, + ); + break :size_grid_padding grid_start_aligned - grid_start_unaligned; + }; + + comptime { + for (.{ + size_superblock, + size_wal_headers, + size_wal_prepares, + size_client_replies, + size_grid_padding, + }) |zone_size| { + assert(zone_size % constants.sector_size == 0); + } + + for (std.enums.values(Zone)) |zone| { + assert(Zone.start(zone) % constants.sector_size == 0); + } + assert(Zone.start(.grid) % constants.block_size == 0); + } + + pub fn offset(zone: Zone, offset_logical: u64) u64 { + if (zone.size()) |zone_size| { + assert(offset_logical < zone_size); + } + + return zone.start() + offset_logical; + } + + pub fn start(zone: Zone) u64 { + comptime var start_offset = 0; + inline for (comptime std.enums.values(Zone)) |z| { + if (z == zone) return start_offset; + start_offset += comptime size(z) orelse 0; + } + unreachable; + } + + pub fn size(zone: Zone) ?u64 { + return switch (zone) { + .superblock => size_superblock, + .wal_headers => size_wal_headers, + .wal_prepares => size_wal_prepares, + .client_replies => size_client_replies, + .grid_padding => size_grid_padding, + .grid => null, + }; + } + + /// Ensures that the read or write is aligned correctly for Direct I/O. + /// If this is not the case, then the underlying syscall will return EINVAL. + /// We check this only at the start of a read or write because the physical sector size may be + /// less than our logical sector size so that partial IOs then leave us no longer aligned. + pub fn verify_iop(zone: Zone, buffer: []const u8, offset_in_zone: u64) void { + if (zone.size()) |zone_size| { + assert(offset_in_zone + buffer.len <= zone_size); + } + assert(@intFromPtr(buffer.ptr) % constants.sector_size == 0); + assert(buffer.len % constants.sector_size == 0); + assert(buffer.len > 0); + const offset_in_storage = zone.offset(offset_in_zone); + assert(offset_in_storage % constants.sector_size == 0); + if (zone == .grid) assert(offset_in_storage % constants.block_size == 0); + } +}; + +/// Reference to a single block in the grid. +/// +/// Blocks are always referred to by a pair of an address and a checksum to protect from misdirected +/// reads and writes: checksum inside the block itself doesn't help if the disk accidentally reads a +/// wrong block. +/// +/// Block addresses start from one, such that zeroed-out memory can not be confused with a valid +/// address. +pub const BlockReference = struct { + checksum: u128, + address: u64, +}; + +/// Viewstamped Replication protocol commands: +pub const Command = enum(u8) { + // Looking to make backwards incompatible changes here? Make sure to check release.zig for + // `release_triple_client_min`. + + reserved = 0, + + ping = 1, + pong = 2, + + ping_client = 3, + pong_client = 4, + + request = 5, + prepare = 6, + prepare_ok = 7, + reply = 8, + commit = 9, + + exit_view = 10, + join_view = 11, + get_view = 13, + + get_headers = 14, + get_prepare = 15, + get_reply = 16, + get_blocks = 19, + + headers = 17, + + eviction = 18, + + block = 20, + + view = 24, + + // If a command is removed from the protocol, its ordinal is added here and can't be re-used. + deprecated_12 = 12, // .view without checkpoint + deprecated_21 = 21, // .request_sync_checkpoint + deprecated_22 = 22, // .sync_checkpoint + deprecated_23 = 23, // .view with an older version of CheckpointState + + comptime { + for (std.enums.values(Command)) |command| { + assert(@intFromEnum(command) < std.enums.values(Command).len); + } + } +}; + +/// This type exists to avoid making the Header type dependent on the state +/// machine used, which would cause awkward circular type dependencies. +pub const Operation = enum(u8) { + // Looking to make backwards incompatible changes here? Make sure to check release.zig for + // `release_triple_client_min`. + + /// Operations reserved by VR protocol (for all state machines): + /// The value 0 is reserved to prevent a spurious zero from being interpreted as an operation. + reserved = 0, + /// The value 1 is reserved to initialize the cluster. + root = 1, + /// The value 2 is reserved to register a client session with the cluster. + register = 2, + /// The value 3 is reserved for reconfiguration request. + reconfigure = 3, + /// The value 4 is reserved for pulse request. + pulse = 4, + /// The value 5 is reserved for release-upgrade requests. + upgrade = 5, + /// The value 6 is reserved for noop requests. + noop = 6, + + /// Operations maybe(StateMachineOperation.from_vsr(vsr_operation) == null), + else => assert(StateMachineOperation.from_vsr(vsr_operation) == null), + } + } + } + } +}; + +pub const RegisterRequest = extern struct { + /// When command=request, batch_size_limit = 0. + /// When command=prepare, batch_size_limit > 0 and batch_size_limit ≤ message_body_size_max. + /// (Note that this does *not* include the `@sizeOf(Header)`.) + batch_size_limit: u32, + reserved: [252]u8 = @splat(0), + + comptime { + assert(@sizeOf(RegisterRequest) == 256); + assert(@sizeOf(RegisterRequest) <= constants.message_body_size_max); + assert(stdx.no_padding(RegisterRequest)); + } +}; + +pub const RegisterResult = extern struct { + batch_size_limit: u32, + reserved: [60]u8 = @splat(0), + + comptime { + assert(@sizeOf(RegisterResult) == 64); + assert(@sizeOf(RegisterResult) <= constants.message_body_size_max); + assert(stdx.no_padding(RegisterResult)); + } +}; + +pub const BlockRequest = extern struct { + block_checksum: u128, + block_address: u64, + reserved: [8]u8 = @splat(0), + + comptime { + assert(@sizeOf(BlockRequest) == 32); + assert(@sizeOf(BlockRequest) <= constants.message_body_size_max); + assert(stdx.no_padding(BlockRequest)); + } +}; + +/// Body of the builtin operation=.reconfigure request. +pub const ReconfigurationRequest = extern struct { + /// The new list of members. + /// + /// Request is rejected if it is not a permutation of an existing list of members. + /// This is done to separate different failure modes of physically adding a new machine to the + /// cluster as opposed to logically changing the set of machines participating in quorums. + members: Members, + /// The new epoch. + /// + /// Request is rejected if it isn't exactly current epoch + 1, to protect from operator errors. + /// Although there's already an `epoch` field in vsr.Header, we don't want to rely on that for + /// reconfiguration itself, as it is updated automatically by the clients, and here we need + /// a manual confirmation from the operator. + epoch: u32, + /// The new replica count. + /// + /// At the moment, we require this to be equal to the old count. + replica_count: u8, + /// The new standby count. + /// + /// At the moment, we require this to be equal to the old count. + standby_count: u8, + reserved: [54]u8 = @splat(0), + /// The result of this request. Set to zero by the client and filled-in by the primary when it + /// accepts a reconfiguration request. + result: ReconfigurationResult, + + comptime { + assert(@sizeOf(ReconfigurationRequest) == 256); + assert(stdx.no_padding(ReconfigurationRequest)); + } + + pub fn validate( + request: *const ReconfigurationRequest, + current: struct { + members: *const Members, + epoch: u32, + replica_count: u8, + standby_count: u8, + }, + ) ReconfigurationResult { + assert(member_count(current.members) == current.replica_count + current.standby_count); + + if (request.replica_count == 0) return .replica_count_zero; + if (request.replica_count > constants.replicas_max) return .replica_count_max_exceeded; + if (request.standby_count > constants.standbys_max) return .standby_count_max_exceeded; + + if (!valid_members(&request.members)) return .members_invalid; + if (member_count(&request.members) != request.replica_count + request.standby_count) { + return .members_count_invalid; + } + + if (!std.mem.allEqual(u8, &request.reserved, 0)) return .reserved_field; + if (request.result != .reserved) return .result_must_be_reserved; + + if (request.replica_count != current.replica_count) return .different_replica_count; + if (request.standby_count != current.standby_count) return .different_standby_count; + + if (request.epoch < current.epoch) return .epoch_in_the_past; + if (request.epoch == current.epoch) { + return if (std.meta.eql(request.members, current.members.*)) + .configuration_applied + else + .configuration_conflict; + } + if (request.epoch - current.epoch > 1) return .epoch_in_the_future; + + assert(request.epoch == current.epoch + 1); + + assert(valid_members(current.members)); + assert(valid_members(&request.members)); + assert(member_count(current.members) == member_count(&request.members)); + // We have just asserted that the sets have no duplicates and have equal lengths, + // so it's enough to check that current.members ⊂ request.members. + for (current.members) |member_current| { + if (member_current == 0) break; + for (request.members) |member| { + if (member == member_current) break; + } else return .different_member_set; + } + + if (std.meta.eql(request.members, current.members.*)) { + return .configuration_is_no_op; + } + + return .ok; + } +}; + +pub const ReconfigurationResult = enum(u32) { + reserved = 0, + /// Reconfiguration request is valid. + /// The cluster is guaranteed to transition to the new epoch with the specified configuration. + ok = 1, + + /// replica_count must be at least 1. + replica_count_zero = 2, + replica_count_max_exceeded = 3, + standby_count_max_exceeded = 4, + + /// The Members array is syntactically invalid --- duplicate entries or internal zero entries. + members_invalid = 5, + /// The number of non-zero entries in Members array does not match the sum of replica_count + /// and standby_count. + members_count_invalid = 6, + + /// A reserved field is non-zero. + reserved_field = 7, + /// result must be set to zero (.reserved). + result_must_be_reserved = 8, + + /// epoch is in the past (smaller than the current epoch). + epoch_in_the_past = 9, + /// epoch is too far in the future (larger than current epoch + 1). + epoch_in_the_future = 10, + + /// Reconfiguration changes the number of replicas, that is not currently supported. + different_replica_count = 11, + /// Reconfiguration changes the number of standbys, that is not currently supported. + different_standby_count = 12, + /// members must be a permutation of the current set of cluster members. + different_member_set = 13, + + /// epoch is equal to the current epoch and configuration is the same. + /// This is a duplicate request. + configuration_applied = 14, + /// epoch is equal to the current epoch but configuration is different. + /// A conflicting reconfiguration request was accepted. + configuration_conflict = 15, + /// The request is valid, but there's no need to advance the epoch, because / configuration + /// exactly matches the current one. + configuration_is_no_op = 16, + + comptime { + for (std.enums.values(ReconfigurationResult), 0..) |result, index| { + assert(@intFromEnum(result) == index); + } + } +}; + +test "ReconfigurationRequest" { + const ResultSet = std.EnumSet(ReconfigurationResult); + + const Test = struct { + members: Members = to_members(.{ 1, 2, 3, 4 }), + epoch: u32 = 1, + replica_count: u8 = 3, + standby_count: u8 = 1, + + tested: ResultSet = ResultSet{}, + + fn check( + t: *@This(), + request: ReconfigurationRequest, + expected: ReconfigurationResult, + ) !void { + const actual = request.validate(.{ + .members = &t.members, + .epoch = t.epoch, + .replica_count = t.replica_count, + .standby_count = t.standby_count, + }); + + try std.testing.expectEqual(expected, actual); + t.tested.insert(expected); + } + + fn to_members(m: anytype) Members { + var result: [constants.members_max]u128 = @splat(0); + inline for (m, 0..) |member, index| result[index] = member; + return result; + } + }; + + var t: Test = .{}; + + const r: ReconfigurationRequest = .{ + .members = Test.to_members(.{ 4, 1, 2, 3 }), + .epoch = 2, + .replica_count = 3, + .standby_count = 1, + .result = .reserved, + }; + + try t.check(r, .ok); + try t.check(stdx.update(r, .{ .replica_count = 0 }), .replica_count_zero); + try t.check(stdx.update(r, .{ .replica_count = 255 }), .replica_count_max_exceeded); + try t.check( + stdx.update(r, .{ .standby_count = constants.standbys_max + 1 }), + .standby_count_max_exceeded, + ); + try t.check( + stdx.update(r, .{ .members = Test.to_members(.{ 4, 1, 4, 3 }) }), + .members_invalid, + ); + try t.check( + stdx.update(r, .{ .members = Test.to_members(.{ 4, 1, 0, 2, 3 }) }), + .members_invalid, + ); + try t.check( + stdx.update(r, .{ .epoch = 0, .members = Test.to_members(.{ 4, 1, 0, 2, 3 }) }), + .members_invalid, + ); + try t.check( + stdx.update(r, .{ .epoch = 1, .members = Test.to_members(.{ 4, 1, 0, 2, 3 }) }), + .members_invalid, + ); + try t.check(stdx.update(r, .{ .replica_count = 4 }), .members_count_invalid); + try t.check(stdx.update(r, .{ .reserved = [_]u8{1} ** 54 }), .reserved_field); + try t.check(stdx.update(r, .{ .result = .ok }), .result_must_be_reserved); + try t.check(stdx.update(r, .{ .epoch = 0 }), .epoch_in_the_past); + try t.check(stdx.update(r, .{ .epoch = 3 }), .epoch_in_the_future); + try t.check( + stdx.update(r, .{ .members = Test.to_members(.{ 1, 2, 3 }), .replica_count = 2 }), + .different_replica_count, + ); + try t.check( + stdx.update(r, .{ .members = Test.to_members(.{ 1, 2, 3, 4, 5 }), .standby_count = 2 }), + .different_standby_count, + ); + try t.check( + stdx.update(r, .{ .members = Test.to_members(.{ 8, 1, 2, 3 }) }), + .different_member_set, + ); + try t.check( + stdx.update(r, .{ .epoch = 1, .members = Test.to_members(.{ 1, 2, 3, 4 }) }), + .configuration_applied, + ); + try t.check(stdx.update(r, .{ .epoch = 1 }), .configuration_conflict); + try t.check( + stdx.update(r, .{ .members = Test.to_members(.{ 1, 2, 3, 4 }) }), + .configuration_is_no_op, + ); + + assert(t.tested.count() < ResultSet.initFull().count()); + t.tested.insert(.reserved); + assert(t.tested.count() == ResultSet.initFull().count()); + + t.epoch = std.math.maxInt(u32); + try t.check(r, .epoch_in_the_past); + try t.check(stdx.update(r, .{ .epoch = std.math.maxInt(u32) }), .configuration_conflict); + try t.check( + stdx.update(r, .{ + .epoch = std.math.maxInt(u32), + .members = Test.to_members(.{ 1, 2, 3, 4 }), + }), + .configuration_applied, + ); +} + +pub const UpgradeRequest = extern struct { + release: Release, + reserved: [12]u8 = @splat(0), + + comptime { + assert(@sizeOf(UpgradeRequest) == 16); + assert(@sizeOf(UpgradeRequest) <= constants.message_body_size_max); + assert(stdx.no_padding(UpgradeRequest)); + } +}; + +/// To ease investigation of accidents, assign a separate exit status for each fatal condition. +/// This is a process-global set. +pub const FatalReason = enum(u8) { + cli = 1, + no_space_left = 2, + manifest_node_pool_exhausted = 3, + storage_size_exceeds_limit = 4, + storage_size_would_exceed_limit = 5, + forest_tables_count_would_exceed_limit = 6, + unknown_vsr_command = 7, + + pub fn exit_status(reason: FatalReason) u8 { + return @intFromEnum(reason); + } +}; + +/// Terminates the process with non-zero exit code. +/// +/// Use fatal when encountering an environmental error where stopping is the intended end response. +/// For example, when running out of disk space, use `fatal` instead of threading error.NoSpaceLeft +/// up the stack. Propagating fatal errors up the stack needlessly increases dimensionality (unusual +/// defers might run), but doesn't improve experience --- the leaf of the call stack has the most +/// context for printing error message. +/// +/// Don't use fatal for situations which are necessarily bugs in some replica process (not +/// necessary this process), use assert or panic instead. +pub fn fatal(reason: FatalReason, comptime fmt: []const u8, args: anytype) noreturn { + log.err(fmt, args); + const status = reason.exit_status(); + assert(status != 0); + std.process.exit(status); +} + +pub const Timeout = struct { + name: []const u8, + id: u128, + after: u64, + after_dynamic: ?u64 = null, // null iff !ticking + attempts: u8 = 0, + rtt: u64 = constants.rtt_ticks, + rtt_multiple: u8 = constants.rtt_multiple, + ticks: u64 = 0, + ticking: bool = false, + + /// Increments the attempts counter and resets the timeout with exponential backoff and jitter. + /// Allows the attempts counter to wrap from time to time. + /// The overflow period is kept short to surface any related bugs sooner rather than later. + /// We do not saturate the counter as this would cause round-robin retries to get stuck. + pub fn backoff(self: *Timeout, prng: *stdx.PRNG) void { + assert(self.ticking); + + self.ticks = 0; + self.attempts +%= 1; + + log.debug("{}: {s} backing off", .{ self.id, self.name }); + self.set_after_for_rtt_and_attempts(prng); + } + + /// It's important to check that when fired() is acted on that the timeout is stopped/started, + /// otherwise further ticks around the event loop may trigger a thundering herd of messages. + pub fn fired(self: *const Timeout) bool { + if (self.ticking and self.ticks >= self.after_dynamic.?) { + log.debug("{}: {s} fired", .{ self.id, self.name }); + if (self.ticks > self.after_dynamic.?) { + log.err("{}: {s} is firing every tick", .{ self.id, self.name }); + @panic("timeout was not reset correctly"); + } + return true; + } else { + return false; + } + } + + pub fn reset(self: *Timeout) void { + self.attempts = 0; + self.ticks = 0; + assert(self.ticking); + // TODO Use self.prng to adjust for rtt and attempts. + log.debug("{}: {s} reset", .{ self.id, self.name }); + } + + pub fn reset_with_jitter(self: *Timeout, prng: *stdx.PRNG) void { + self.attempts +%= 1; + self.ticks = 0; + assert(self.ticking); + + // Uniformly between [0.5 * timeout, 1.5 * timeout]. + assert(self.after > 1); + const half = @divFloor(self.after, 2); + self.after_dynamic = prng.range_inclusive(u64, half, 2 * self.after - half); + assert(self.after_dynamic.? > 0); + + log.debug("{}: {s} reset", .{ self.id, self.name }); + } + + /// Sets the value of `after` as a function of `rtt` and `attempts`. + /// Adds exponential backoff and jitter. + /// May be called only after a timeout has been stopped or reset, to prevent backward jumps. + fn set_after_for_rtt_and_attempts(self: *Timeout, prng: *stdx.PRNG) void { + // If `after` is reduced by this function to less than `ticks`, then `fired()` will panic: + assert(self.ticks == 0); + assert(self.rtt > 0); + + const after = (self.rtt * self.rtt_multiple) + exponential_backoff_with_jitter( + prng, + constants.backoff_min_ticks, + constants.backoff_max_ticks, + self.attempts, + ); + + // TODO Clamp `after` to min/max tick bounds for timeout. + + log.debug("{}: {s} after={}..{} (rtt={} min={} max={} attempts={})", .{ + self.id, + self.name, + self.after_dynamic.?, + after, + self.rtt, + constants.backoff_min_ticks, + constants.backoff_max_ticks, + self.attempts, + }); + + self.after_dynamic = after; + assert(self.after_dynamic.? > 0); + } + + pub fn set_rtt_ns(self: *Timeout, rtt_ns: u64) void { + assert(self.rtt > 0); + + const rtt_ms = @divFloor(rtt_ns, std.time.ns_per_ms); + const rtt_ticks = @max(1, @divFloor(rtt_ms, constants.tick_ms)); + const rtt_ticks_clamped = @min(rtt_ticks, constants.rtt_max_ticks); + + if (self.rtt != rtt_ticks_clamped) { + log.debug("{}: {s} rtt={}..{}", .{ + self.id, + self.name, + self.rtt, + rtt_ticks_clamped, + }); + + self.rtt = rtt_ticks_clamped; + } + } + + pub fn start(self: *Timeout) void { + self.attempts = 0; + self.after_dynamic = self.after; + self.ticks = 0; + self.ticking = true; + // TODO Use self.prng to adjust for rtt and attempts. + log.debug("{}: {s} started", .{ self.id, self.name }); + } + + pub fn stop(self: *Timeout) void { + self.attempts = 0; + self.after_dynamic = null; + self.ticks = 0; + self.ticking = false; + log.debug("{}: {s} stopped", .{ self.id, self.name }); + } + + pub fn tick(self: *Timeout) void { + if (self.ticking) self.ticks += 1; + } +}; + +/// Calculates exponential backoff with jitter to prevent cascading failure due to thundering herds. +pub fn exponential_backoff_with_jitter( + prng: *stdx.PRNG, + min: u64, + max: u64, + attempt: u64, +) u64 { + assert(max > min); + + // Do not use `@truncate(u6, attempt)` since that only discards the high bits: + // We want a saturating exponent here instead. + const exponent: u6 = @intCast(@min(std.math.maxInt(u6), attempt)); + + // A "1" shifted left gives any power of two: + // 1<<0 = 1, 1<<1 = 2, 1<<2 = 4, 1<<3 = 8 + const power = std.math.shlExact(u128, 1, exponent) catch unreachable; // Do not truncate. + + // Ensure that `backoff` is calculated correctly when min is 0, taking `@max(1, min)`. + // Otherwise, the final result will always be 0. This was an actual bug we encountered. + const min_non_zero = @max(1, min); + assert(min_non_zero > 0); + assert(power > 0); + + // Calculate the capped exponential backoff component, `min(range, min * 2 ^ attempt)`: + const backoff = @min(max - min, min_non_zero * power); + const jitter = prng.int_inclusive(u64, backoff); + + const result: u64 = @intCast(min + jitter); + assert(result >= min); + assert(result <= max); + + return result; +} + +test "exponential_backoff_with_jitter" { + var prng = stdx.PRNG.from_seed_testing(); + + const attempts = 1000; + const max: u64 = std.math.maxInt(u64); + const min = max - attempts; + + var attempt = max - attempts; + while (attempt < max) : (attempt += 1) { + const ebwj = exponential_backoff_with_jitter(&prng, min, max, attempt); + try std.testing.expect(ebwj >= min); + try std.testing.expect(ebwj <= max); + } +} + +pub const ClusterAddress = struct { + array: stdx.BoundedArrayType(stdx.SocketAddress, constants.members_max), + // true when the value of `--addresses` is exactly `0`. Used to enable "magic zero" mode for + // testing. We check the raw string rather than the parsed address to prevent triggering + // this logic by accident. + zero: bool, + + pub fn slice(address: *const ClusterAddress) []const stdx.SocketAddress { + return address.array.const_slice(); + } + + pub fn members_count(address: *const ClusterAddress) u8 { + return address.array.count_as(u8); + } + + pub fn parse_flag_value( + text: []const u8, + static_diagnostic: *?[]const u8, + ) error{InvalidFlagValue}!ClusterAddress { + var result: ClusterAddress = .{ + .array = .{}, + .zero = std.mem.eql(u8, text, "0"), + }; + const parsed = parse_addresses(text, result.array.unused_capacity_slice()) catch |err| { + static_diagnostic.* = switch (err) { + error.AddressHasTrailingComma => "invalid trailing comma:", + error.AddressLimitExceeded => std.fmt.comptimePrint( + "too many addresses, at most {d} are allowed:", + .{constants.members_max}, + ), + error.AddressHasMoreThanOneColon => "invalid address with more than one colon:", + error.PortInvalid => "invalid port:", + error.AddressInvalid => "invalid IPv4 or IPv6 address:", + }; + return error.InvalidFlagValue; + }; + result.array.resize(parsed.len) catch |err| switch (err) { + error.Overflow => unreachable, + }; + assert(result.array.slice().len == parsed.len); + assert(result.array.count() > 0); + assert(result.array.count() <= constants.members_max); + return result; + } +}; + +/// Returns An array containing the remote or local addresses of each of the 2f + 1 replicas: +/// Unlike the VRR paper, we do not sort the array but leave the order explicitly to the user. +/// There are several advantages to this: +/// * The operator may deploy a cluster with proximity in mind since replication follows order. +/// * A replica's IP address may be changed without reconfiguration. +/// This does require that the user specify the same order to all replicas. +/// The caller owns the memory of the returned slice of addresses. +pub fn parse_addresses( + raw: []const u8, + out_buffer: []stdx.SocketAddress, +) ![]stdx.SocketAddress { + const address_count = std.mem.count(u8, raw, ",") + 1; + if (address_count > out_buffer.len) return error.AddressLimitExceeded; + + var index: usize = 0; + var comma_iterator = std.mem.splitScalar(u8, raw, ','); + while (comma_iterator.next()) |raw_address| : (index += 1) { + assert(index < out_buffer.len); + if (raw_address.len == 0) return error.AddressHasTrailingComma; + out_buffer[index] = try parse_address_and_port(.{ + .string = raw_address, + .port_default = constants.port, + }); + } + assert(index == address_count); + + return out_buffer[0..address_count]; +} + +pub fn parse_address_and_port(options: struct { + string: []const u8, + port_default: u16, +}) !stdx.SocketAddress { + assert(options.string.len > 0); + assert(options.port_default > 0); + + if (std.mem.lastIndexOfAny(u8, options.string, ":.]")) |split| { + if (options.string[split] == ':') { + const port = stdx.parse_int(u16, options.string[split + 1 ..], .{}) catch + return error.PortInvalid; + const ip = try parse_address(options.string[0..split]); + return .{ .ip = ip, .port = port }; + } else { + const ip = try parse_address(options.string); + return .{ .ip = ip, .port = options.port_default }; + } + } else { + const ip = comptime stdx.IPAddress.parse(constants.address) catch unreachable; + const port = stdx.parse_int(u16, options.string, .{}) catch return error.PortInvalid; + return .{ .ip = ip, .port = port }; + } +} + +// A variation of stdx.IPAddress.parse that requires `[]` around IPv6 addresses. +fn parse_address(string: []const u8) !stdx.IPAddress { + if (string.len == 0) return error.AddressInvalid; + if (string[string.len - 1] == ':') return error.AddressHasMoreThanOneColon; + + const expect_v6 = string[0] == '[' and string[string.len - 1] == ']'; + if (expect_v6 != (std.mem.indexOfScalar(u8, string, ':') != null)) return error.AddressInvalid; + + const string_inner = if (expect_v6) string[1 .. string.len - 1] else string; + return stdx.IPAddress.parse(string_inner) catch error.AddressInvalid; +} + +test parse_addresses { + const vectors_positive = &[_]struct { + raw: []const u8, + addresses: []const std.net.Address, + }{ + .{ + // Test the minimum/maximum address/port. + .raw = "1.2.3.4:567,0.0.0.0:0,255.255.255.255:65535", + .addresses = &[3]std.net.Address{ + std.net.Address.initIp4([_]u8{ 1, 2, 3, 4 }, 567), + std.net.Address.initIp4([_]u8{ 0, 0, 0, 0 }, 0), + std.net.Address.initIp4([_]u8{ 255, 255, 255, 255 }, 65535), + }, + }, + .{ + // Addresses are not reordered. + .raw = "3.4.5.6:7777,200.3.4.5:6666,1.2.3.4:5555", + .addresses = &[3]std.net.Address{ + std.net.Address.initIp4([_]u8{ 3, 4, 5, 6 }, 7777), + std.net.Address.initIp4([_]u8{ 200, 3, 4, 5 }, 6666), + std.net.Address.initIp4([_]u8{ 1, 2, 3, 4 }, 5555), + }, + }, + .{ + // Test default address and port. + .raw = "1.2.3.4:5,4321,2.3.4.5", + .addresses = &[3]std.net.Address{ + std.net.Address.initIp4([_]u8{ 1, 2, 3, 4 }, 5), + try std.net.Address.parseIp4(constants.address, 4321), + std.net.Address.initIp4([_]u8{ 2, 3, 4, 5 }, constants.port), + }, + }, + .{ + // Test addresses less than address_limit. + .raw = "1.2.3.4:5,4321", + .addresses = &[2]std.net.Address{ + std.net.Address.initIp4([_]u8{ 1, 2, 3, 4 }, 5), + try std.net.Address.parseIp4(constants.address, 4321), + }, + }, + .{ + // Test IPv6 address with default port. + .raw = "[fe80::1ff:fe23:4567:890a]", + .addresses = &[_]std.net.Address{ + std.net.Address.initIp6( + [_]u8{ + 0xfe, 0x80, + 0, 0, + 0, 0, + 0, 0, + 0x01, 0xff, + 0xfe, 0x23, + 0x45, 0x67, + 0x89, 0x0a, + }, + constants.port, + 0, + 0, + ), + }, + }, + .{ + // Test IPv6 address with port. + .raw = "[fe80::1ff:fe23:4567:890a]:1234", + .addresses = &[_]std.net.Address{ + std.net.Address.initIp6( + [_]u8{ + 0xfe, 0x80, + 0, 0, + 0, 0, + 0, 0, + 0x01, 0xff, + 0xfe, 0x23, + 0x45, 0x67, + 0x89, 0x0a, + }, + 1234, + 0, + 0, + ), + }, + }, + .{ + // Test IPv6-mapped IPv4 address. + .raw = "[::ffff:7f00:1]:1234", + .addresses = &[_]std.net.Address{ + std.net.Address.initIp4([_]u8{ 127, 0, 0, 1 }, 1234), + }, + }, + }; + + const vectors_negative = &[_]struct { + raw: []const u8, + err: anyerror![]stdx.SocketAddress, + }{ + .{ .raw = "", .err = error.AddressHasTrailingComma }, + .{ .raw = ".", .err = error.AddressInvalid }, + .{ .raw = ":", .err = error.PortInvalid }, + .{ .raw = ":92", .err = error.AddressInvalid }, + .{ .raw = "[127.0.0.1]", .err = error.AddressInvalid }, + .{ .raw = "[127.0.0.1]:3001", .err = error.AddressInvalid }, + .{ .raw = "::ff:92", .err = error.AddressInvalid }, + .{ .raw = "1.2.3.4:5,2.3.4.5:6,4.5.6.7:8", .err = error.AddressLimitExceeded }, + .{ .raw = "1.2.3.4:7777,", .err = error.AddressHasTrailingComma }, + .{ .raw = "1.2.3.4:7777,2.3.4.5::8888", .err = error.AddressHasMoreThanOneColon }, + .{ .raw = "1.2.3.4:5,A", .err = error.PortInvalid }, // default port + .{ .raw = "1.2.3.4:5,2.a.4.5", .err = error.AddressInvalid }, // default port + .{ .raw = "1.2.3.4:5,2.a.4.5:6", .err = error.AddressInvalid }, // specified port + .{ .raw = "1.2.3.4:5,2.3.4.5:", .err = error.PortInvalid }, + .{ .raw = "1.2.3.4:5,2.3.4.5:A", .err = error.PortInvalid }, + .{ .raw = "1.2.3.4:5,65536", .err = error.PortInvalid }, // default address + .{ .raw = "1.2.3.4:5,2.3.4.5:65536", .err = error.PortInvalid }, + }; + + var buffer: [3]stdx.SocketAddress = undefined; + for (vectors_positive) |vector| { + const addresses_actual = try parse_addresses(vector.raw, &buffer); + + try std.testing.expectEqual(addresses_actual.len, vector.addresses.len); + for (vector.addresses, 0..) |address_expect_std, i| { + const address_actual = addresses_actual[i]; + const address_expect = try stdx.SocketAddress.from_std(address_expect_std); + try std.testing.expectEqual(address_expect, address_actual); + } + } + + for (vectors_negative) |vector| { + errdefer log.err("raw = '{s}', err = {any}", .{ vector.raw, vector.err }); + try std.testing.expectEqual( + vector.err, + parse_addresses(vector.raw, buffer[0..2]), + ); + } +} + +test "parse_addresses: fuzz" { + const test_count = 1024; + const input_size_max = 32; + const alphabet = " \t\n,:[]0123456789abcdefgABCDEFGXx"; + + var prng = stdx.PRNG.from_seed_testing(); + + var input_buffer: [input_size_max]u8 = @splat(0); + var buffer: [3]stdx.SocketAddress = undefined; + for (0..test_count) |_| { + const input_size = prng.int_inclusive(usize, input_size_max); + const input = input_buffer[0..input_size]; + for (input) |*c| { + c.* = alphabet[prng.index(alphabet)]; + } + if (parse_addresses(input, &buffer)) |addresses| { + assert(addresses.len > 0); + assert(addresses.len <= 3); + } else |_| {} + } +} + +pub fn sector_floor(offset: u64) u64 { + const sectors = math.divFloor(u64, offset, constants.sector_size) catch unreachable; + return sectors * constants.sector_size; +} + +pub fn sector_ceil(offset: u64) u64 { + const sectors = math.divCeil(u64, offset, constants.sector_size) catch unreachable; + return sectors * constants.sector_size; +} + +pub fn quorums(replica_count: u8) struct { + replication: u8, + view_change: u8, + nack_prepare: u8, + majority: u8, + upgrade: u8, +} { + assert(replica_count > 0); + + assert(constants.quorum_replication_max >= 2); + // For replica_count=2, set quorum_replication=2 even though =1 would intersect. + // This improves durability of small clusters. + const quorum_replication = if (replica_count == 2) 2 else @min( + constants.quorum_replication_max, + stdx.div_ceil(replica_count, 2), + ); + assert(quorum_replication <= replica_count); + assert(quorum_replication >= 2 or quorum_replication == replica_count); + + // For replica_count=2, set quorum_view_change=2 even though =1 would intersect. + // This avoids special cases for a single-replica view-change in Replica. + const quorum_view_change = + if (replica_count == 2) 2 else replica_count - quorum_replication + 1; + // The view change quorum may be more expensive to make the replication quorum cheaper. + // The insight is that the replication phase is by far more common than the view change. + // This trade-off allows us to optimize for the common case. + // See the comments in `constants.zig` for further explanation. + assert(quorum_view_change <= replica_count); + assert(quorum_view_change >= 2 or quorum_view_change == replica_count); + assert(quorum_view_change >= @divFloor(replica_count, 2) + 1); + assert(quorum_view_change + quorum_replication > replica_count); + + // We need to have enough nacks to guarantee that `quorum_replication` was not reached, + // because if the replication quorum was reached, then it may have been committed. + const quorum_nack_prepare = replica_count - quorum_replication + 1; + assert(quorum_nack_prepare + quorum_replication > replica_count); + + const quorum_majority = + stdx.div_ceil(replica_count, 2) + @intFromBool(@mod(replica_count, 2) == 0); + assert(quorum_majority <= replica_count); + assert(quorum_majority > @divFloor(replica_count, 2)); + + // A majority quorum (i.e. `max(quorum_replication, quorum_view_change)`) is required + // to ensure that the upgraded cluster can both commit and view-change. + // + // However, we farther require that all replicas can upgrade. In most cases, not upgrading all + // replicas together would be a mistake (leading to replicas lagging and needing to state sync). + // If an upgrade is needed while the cluster is compromised, then it should be a hotfix upgrade + // (i.e. to a build tagged with the same release). + const quorum_upgrade = replica_count; + assert(quorum_upgrade <= replica_count); + assert(quorum_upgrade >= quorum_replication); + assert(quorum_upgrade >= quorum_view_change); + + return .{ + .replication = quorum_replication, + .view_change = quorum_view_change, + .nack_prepare = quorum_nack_prepare, + .majority = quorum_majority, + .upgrade = quorum_upgrade, + }; +} + +test "quorums" { + if (constants.quorum_replication_max != 3) return error.SkipZigTest; + + const expect_replication = [_]u8{ 1, 2, 2, 2, 3, 3, 3, 3 }; + const expect_view_change = [_]u8{ 1, 2, 2, 3, 3, 4, 5, 6 }; + const expect_nack_prepare = [_]u8{ 1, 1, 2, 3, 3, 4, 5, 6 }; + const expect_majority = [_]u8{ 1, 2, 2, 3, 3, 4, 4, 5 }; + const expect_upgrade = [_]u8{ 1, 2, 3, 4, 5, 6, 7, 8 }; + + for (expect_replication[0..], 0..) |_, i| { + const replicas = @as(u8, @intCast(i)) + 1; + const actual = quorums(replicas); + try std.testing.expectEqual(expect_replication[i], actual.replication); + try std.testing.expectEqual(expect_view_change[i], actual.view_change); + try std.testing.expectEqual(expect_nack_prepare[i], actual.nack_prepare); + try std.testing.expectEqual(expect_majority[i], actual.majority); + try std.testing.expectEqual(expect_upgrade[i], actual.upgrade); + + // The nack quorum only differs from the view-change quorum when R=2. + if (replicas == 2) { + try std.testing.expectEqual(1, actual.nack_prepare); + } else { + try std.testing.expectEqual(actual.view_change, actual.nack_prepare); + } + } +} + +/// Set of replica_ids of cluster members, where order of ids determines replica indexes. +/// +/// First replica_count elements are active replicas, +/// then standby_count standbys, the rest are zeros. +/// Order determines ring topology for replication. +pub const Members = [constants.members_max]u128; + +/// Deterministically assigns replica_ids for the initial configuration. +/// +/// Eventually, we want to identify replicas using random u128 ids to prevent operator errors. +/// However, that requires unergonomic two-step process for spinning a new cluster up. To avoid +/// needlessly compromising the experience until reconfiguration is fully implemented, derive +/// replica ids for the initial cluster deterministically. +pub fn root_members(cluster: u128) Members { + const IdSeed = extern struct { + cluster_config_checksum: u128 align(1), + cluster: u128 align(1), + replica: u8 align(1), + }; + comptime assert(@sizeOf(IdSeed) == 33); + + var result: [constants.members_max]u128 = @splat(0); + var replica: u8 = 0; + while (replica < constants.members_max) : (replica += 1) { + const seed = IdSeed{ + .cluster_config_checksum = constants.config.cluster.checksum(), + .cluster = cluster, + .replica = replica, + }; + result[replica] = checksum(std.mem.asBytes(&seed)); + } + + assert(valid_members(&result)); + return result; +} + +/// Check that: +/// - all non-zero elements are different +/// - all zero elements are trailing +pub fn valid_members(members: *const Members) bool { + for (members, 0..) |replica_i, i| { + for (members[0..i]) |replica_j| { + if (replica_j == 0 and replica_i != 0) return false; + if (replica_j != 0 and replica_j == replica_i) return false; + } + } + return true; +} + +fn member_count(members: *const Members) u8 { + for (members, 0..) |member, index| { + if (member == 0) return @intCast(index); + } + return constants.members_max; +} + +pub fn member_index(members: *const Members, replica_id: u128) ?u8 { + assert(replica_id != 0); + assert(valid_members(members)); + for (members, 0..) |member, replica_index| { + if (member == replica_id) return @intCast(replica_index); + } else return null; +} + +pub const Headers = struct { + pub const Array = stdx.BoundedArrayType(Header.Prepare, constants.view_headers_max); + /// The SuperBlock's persisted VSR headers. + /// One of the following: + /// + /// - View headers (consecutive chain) + /// - JV headers (disjoint chain) + pub const ViewChangeSlice = ViewChangeHeadersSlice; + pub const ViewChangeArray = ViewChangeHeadersArray; + + fn jv_blank(op: u64) Header.Prepare { + return .{ + .command = .prepare, + .release = Release.zero, + .operation = .reserved, + .op = op, + .cluster = 0, + .view = 0, + .request_checksum = 0, + .checkpoint_id = 0, + .parent = 0, + .client = 0, + .commit = 0, + .timestamp = 0, + .request = 0, + }; + } + + pub fn jv_header_type(header: *const Header.Prepare) enum { blank, valid } { + if (std.meta.eql(header.*, Headers.jv_blank(header.op))) return .blank; + + assert(header.valid_checksum()); + assert(header.command == .prepare); + assert(header.operation != .reserved); + assert(header.invalid() == null); + return .valid; + } +}; + +pub const ViewChangeCommand = enum { join_view, view }; + +const ViewChangeHeadersSlice = struct { + command: ViewChangeCommand, + /// Headers are ordered from high-to-low op. + slice: []const Header.Prepare, + + pub fn init( + command: ViewChangeCommand, + slice: []const Header.Prepare, + ) ViewChangeHeadersSlice { + const headers = ViewChangeHeadersSlice{ + .command = command, + .slice = slice, + }; + headers.verify(); + return headers; + } + + pub fn verify(headers: ViewChangeHeadersSlice) void { + assert(headers.slice.len > 0); + assert(headers.slice.len <= constants.view_headers_max); + + const head = &headers.slice[0]; + // A JV's head op is never a gap or faulty. + // A View never includes gaps or faulty headers. + assert(Headers.jv_header_type(head) == .valid); + + var child = head; + for (headers.slice[1..], 0..) |*header, i| { + const index = i + 1; + assert(header.command == .prepare); + maybe(header.operation == .reserved); + assert(header.op < child.op); + + // JV: Ops are consecutive (with explicit blank headers). + // View: The first "pipeline + 1" ops of the View are consecutive. + if (headers.command == .join_view or + (headers.command == .view and + index < constants.pipeline_prepare_queue_max + 1)) + { + assert(header.op == head.op - index); + } + + switch (Headers.jv_header_type(header)) { + .blank => { + // We can't verify that View headers contain no gaps headers here: + // superblock.checkpoint could make .join_view headers durable instead of + // .view headers when view == log_view (see `commit_checkpoint_superblock` + // in `replica.zig`). When these headers are loaded from the superblock on + // startup, they are considered to be .view headers (see `view_headers` in + // `superblock.zig`). + maybe(headers.command == .join_view); + maybe(headers.command == .view); + continue; // Don't update "child". + }, + .valid => { + assert(header.view <= child.view); + assert(header.timestamp < child.timestamp); + if (header.op + 1 == child.op) { + assert(header.checksum == child.parent); + } + }, + } + child = header; + } + } + + const ViewRange = struct { + min: u32, // inclusive + max: u32, // inclusive + + pub fn contains(range: ViewRange, view: u32) bool { + return range.min <= view and view <= range.max; + } + }; + + /// Returns the range of possible views (of prepare, not commit) for a message that is part of + /// the same log_view as these headers. + /// + /// - When these are JV headers for a log_view=V, we must be in view_change status working to + /// transition to a view beyond V. So we will never prepare anything else as part of view V. + /// - When these are View headers for a log_view=V, we can continue to add to them (by preparing + /// more ops), but those ops will always be part of the log_view. If they were prepared during + /// a view prior to the log_view, they would already be part of the headers. + pub fn view_for_op(headers: ViewChangeHeadersSlice, op: u64, log_view: u32) ViewRange { + const header_newest = &headers.slice[0]; + const header_oldest = blk: { + var oldest: ?usize = null; + for (headers.slice, 0..) |*header, i| { + switch (Headers.jv_header_type(header)) { + .blank => assert(i > 0), + .valid => oldest = i, + } + } + break :blk &headers.slice[oldest.?]; + }; + assert(header_newest.view <= log_view); + assert(header_newest.view >= header_oldest.view); + assert(header_newest.op >= header_oldest.op); + + if (op < header_oldest.op) return .{ .min = 0, .max = header_oldest.view }; + if (op > header_newest.op) return .{ .min = log_view, .max = log_view }; + + for (headers.slice) |*header| { + if (Headers.jv_header_type(header) == .valid and header.op == op) { + return .{ .min = header.view, .max = header.view }; + } + } + + var header_next = &headers.slice[0]; + assert(Headers.jv_header_type(header_next) == .valid); + + for (headers.slice[1..]) |*header_prev| { + if (Headers.jv_header_type(header_prev) == .valid) { + if (header_prev.op < op and op < header_next.op) { + return .{ .min = header_prev.view, .max = header_next.view }; + } + header_next = header_prev; + } + } + unreachable; + } +}; + +test "Headers.ViewChangeSlice.view_for_op" { + var headers_array = [_]Header.Prepare{ + std.mem.zeroInit(Header.Prepare, .{ + .checksum = undefined, + .client = 6, + .request = 7, + .command = .prepare, + .release = Release.minimum, + .operation = @as(Operation, @enumFromInt(constants.vsr_operations_reserved + 8)), + .op = 9, + .view = 10, + .timestamp = 11, + }), + Headers.jv_blank(8), + Headers.jv_blank(7), + std.mem.zeroInit(Header.Prepare, .{ + .checksum = undefined, + .client = 3, + .request = 4, + .command = .prepare, + .release = Release.minimum, + .operation = @as(Operation, @enumFromInt(constants.vsr_operations_reserved + 5)), + .op = 6, + .view = 7, + .timestamp = 8, + }), + Headers.jv_blank(5), + }; + + headers_array[0].set_checksum(); + headers_array[3].set_checksum(); + + const headers = Headers.ViewChangeSlice.init(.join_view, &headers_array); + try std.testing.expect(std.meta.eql(headers.view_for_op(11, 12), .{ .min = 12, .max = 12 })); + try std.testing.expect(std.meta.eql(headers.view_for_op(10, 12), .{ .min = 12, .max = 12 })); + try std.testing.expect(std.meta.eql(headers.view_for_op(9, 12), .{ .min = 10, .max = 10 })); + try std.testing.expect(std.meta.eql(headers.view_for_op(8, 12), .{ .min = 7, .max = 10 })); + try std.testing.expect(std.meta.eql(headers.view_for_op(7, 12), .{ .min = 7, .max = 10 })); + try std.testing.expect(std.meta.eql(headers.view_for_op(6, 12), .{ .min = 7, .max = 7 })); + try std.testing.expect(std.meta.eql(headers.view_for_op(5, 12), .{ .min = 0, .max = 7 })); + try std.testing.expect(std.meta.eql(headers.view_for_op(0, 12), .{ .min = 0, .max = 7 })); +} + +/// The headers of a View or JV message. +const ViewChangeHeadersArray = struct { + command: ViewChangeCommand, + array: Headers.Array, + + pub fn root(cluster: u128) ViewChangeHeadersArray { + return ViewChangeHeadersArray.init(.view, &.{ + Header.Prepare.root(cluster), + }); + } + + pub fn init( + command: ViewChangeCommand, + slice: []const Header.Prepare, + ) ViewChangeHeadersArray { + const headers = ViewChangeHeadersArray{ + .command = command, + .array = Headers.Array.from_slice(slice) catch unreachable, + }; + headers.verify(); + return headers; + } + + pub fn verify(headers: *const ViewChangeHeadersArray) void { + (ViewChangeHeadersSlice{ + .command = headers.command, + .slice = headers.array.const_slice(), + }).verify(); + } + + pub fn replace( + headers: *ViewChangeHeadersArray, + command: ViewChangeCommand, + slice: []const Header.Prepare, + ) void { + headers.command = command; + headers.array.clear(); + for (slice) |*header| headers.array.push(header.*); + headers.verify(); + } + + pub fn append(headers: *ViewChangeHeadersArray, header: *const Header.Prepare) void { + // We don't do comprehensive validation here — assume that verify() will be called + // after any series of appends. + headers.array.push(header.*); + } + + pub fn append_blank(headers: *ViewChangeHeadersArray, op: u64) void { + assert(headers.command == .join_view); + assert(headers.array.count() > 0); + headers.array.push(Headers.jv_blank(op)); + } +}; + +/// For a replica with journal_slot_count=10, lsm_compaction_ops=2, pipeline_prepare_queue_max=2, +/// and checkpoint_interval=4, which can be computed as follows: +/// journal_slot_count - (lsm_compaction_ops + 2 * pipeline_prepare_queue_max) = 4 +/// +/// checkpoint() call 0 1 2 3 4 +/// op_checkpoint 0 3 7 11 15 +/// op_checkpoint_next 3 7 11 15 19 +/// op_checkpoint_next_trigger 5 9 13 17 21 +/// +/// commit log (ops) │ write-ahead log (slots) +/// 0 4 8 2 6 0 4 │ 0 - - - 4 - - - - 9 +/// 0 ───✓·% │[ 0 1 2 ✓] 4 % R R R R +/// 1 ───────✓·% │ 0 1 2 3[ 4 5 6 ✓] 8 % +/// 2 ───────────✓·% │ 10 ✓] 12 % 4 5 6 7[ 8 % +/// 3 ───────────────✓·% │ 10 11[12 13 14 ✓] 16 % 8 9 +/// 4 ───────────────────✓·% │ 20 % 12 13 14 15[16 17 18 19] +/// +/// Legend: +/// +/// ─/✓ op on disk at checkpoint +/// ·/% op in memory at checkpoint +/// ✓ op_checkpoint +/// % op_checkpoint's trigger +/// R slot reserved in WAL +/// [ ] range of ops from a checkpoint +pub const Checkpoint = struct { + comptime { + assert(constants.journal_slot_count > constants.lsm_compaction_ops); + assert(constants.journal_slot_count % constants.lsm_compaction_ops == 0); + } + + pub fn checkpoint_after(checkpoint: u64) u64 { + assert(valid(checkpoint)); + + const result = op: { + if (checkpoint == 0) { + // First wrap: op_checkpoint_next = 6-1 = 5 + // -1: vsr_checkpoint_ops is a count, result is an inclusive index. + break :op constants.vsr_checkpoint_ops - 1; + } else { + // Second wrap: op_checkpoint_next = 5+6 = 11 + // Third wrap: op_checkpoint_next = 11+6 = 17 + break :op checkpoint + constants.vsr_checkpoint_ops; + } + }; + + assert((result + 1) % constants.lsm_compaction_ops == 0); + assert(valid(result)); + + return result; + } + + pub fn trigger_for_checkpoint(checkpoint: u64) ?u64 { + assert(valid(checkpoint)); + + if (checkpoint == 0) { + return null; + } else { + return checkpoint + constants.lsm_compaction_ops; + } + } + + pub fn prepare_max_for_checkpoint(checkpoint: u64) ?u64 { + assert(valid(checkpoint)); + + if (trigger_for_checkpoint(checkpoint)) |trigger| { + return trigger + (2 * constants.pipeline_prepare_queue_max); + } else { + return null; + } + } + + pub fn durable(checkpoint: u64, commit: u64) bool { + assert(valid(checkpoint)); + + if (trigger_for_checkpoint(checkpoint)) |trigger| { + return commit > (trigger + constants.pipeline_prepare_queue_max); + } else { + return true; + } + } + + pub fn valid(op: u64) bool { + // Divide by `lsm_compaction_ops` instead of `vsr_checkpoint_ops`: + // although today in practice checkpoints are evenly spaced, the LSM layer doesn't assume + // that. LSM allows any bar boundary to become a checkpoint which happens, e.g., in the tree + // fuzzer. + return op == 0 or (op + 1) % constants.lsm_compaction_ops == 0; + } +}; + +test "Checkpoint ops diagram" { + const Snap = stdx.Snap; + const snap = Snap.snap_fn("src"); + + var string = std.ArrayList(u8).init(std.testing.allocator); + defer string.deinit(); + + var string2 = std.ArrayList(u8).init(std.testing.allocator); + defer string2.deinit(); + + try string.writer().print( + \\journal_slot_count={[journal_slot_count]} + \\lsm_compaction_ops={[lsm_compaction_ops]} + \\pipeline_prepare_queue_max={[pipeline_prepare_queue_max]} + \\vsr_checkpoint_ops={[vsr_checkpoint_ops]} + \\ + \\ + , .{ + .journal_slot_count = constants.journal_slot_count, + .lsm_compaction_ops = constants.lsm_compaction_ops, + .pipeline_prepare_queue_max = constants.pipeline_prepare_queue_max, + .vsr_checkpoint_ops = constants.vsr_checkpoint_ops, + }); + + var checkpoint_prev: u64 = 0; + var checkpoint_next: u64 = 0; + var checkpoint_count: u32 = 0; + for (0..constants.journal_slot_count * 10) |op| { + const last_beat = (op + 1) % constants.lsm_compaction_ops == 0; + const last_slot = (op + 1) % constants.journal_slot_count == 0; + + const op_type: enum { + normal, + checkpoint, + checkpoint_trigger, + checkpoint_prepare_max, + } = op_type: { + if (op == checkpoint_next) break :op_type .checkpoint; + if (checkpoint_prev != 0) { + if (op == Checkpoint.trigger_for_checkpoint(checkpoint_prev).?) { + break :op_type .checkpoint_trigger; + } + + if (op == Checkpoint.prepare_max_for_checkpoint(checkpoint_prev).?) { + break :op_type .checkpoint_prepare_max; + } + } + break :op_type .normal; + }; + + // Marker for tidy.zig to ignore the long lines. + if (op % constants.journal_slot_count == 0) try string.appendSlice("OPS: "); + + try string.writer().print("{s}{:_>3}{s}", .{ + switch (op_type) { + .normal => " ", + .checkpoint => if (checkpoint_count % 2 == 0) "[" else "{", + .checkpoint_trigger => "<", + .checkpoint_prepare_max => " ", + }, + op, + switch (op_type) { + .normal => if (last_slot) "" else " ", + .checkpoint => if (last_slot) "" else " ", + .checkpoint_trigger => ">", + .checkpoint_prepare_max => if (checkpoint_count % 2 == 0) "]" else "}", + }, + }); + + if (last_slot) try string.append('\n'); + if (!last_slot and last_beat) try string.append(' '); + + if (op_type == .checkpoint) { + checkpoint_prev = checkpoint_next; + checkpoint_next = Checkpoint.checkpoint_after(checkpoint_prev); + } + checkpoint_count += @intFromBool(op == checkpoint_prev); + } + + try snap(@src(), + \\journal_slot_count=32 + \\lsm_compaction_ops=4 + \\pipeline_prepare_queue_max=4 + \\vsr_checkpoint_ops=20 + \\ + \\OPS: [__0 __1 __2 __3 __4 __5 __6 __7 __8 __9 _10 _11 _12 _13 _14 _15 _16 _17 _18 {_19 _20 _21 _22 <_23> _24 _25 _26 _27 _28 _29 _30 _31] + \\OPS: _32 _33 _34 _35 _36 _37 _38 [_39 _40 _41 _42 <_43> _44 _45 _46 _47 _48 _49 _50 _51} _52 _53 _54 _55 _56 _57 _58 {_59 _60 _61 _62 <_63> + \\OPS: _64 _65 _66 _67 _68 _69 _70 _71] _72 _73 _74 _75 _76 _77 _78 [_79 _80 _81 _82 <_83> _84 _85 _86 _87 _88 _89 _90 _91} _92 _93 _94 _95 + \\OPS: _96 _97 _98 {_99 100 101 102 <103> 104 105 106 107 108 109 110 111] 112 113 114 115 116 117 118 [119 120 121 122 <123> 124 125 126 127 + \\OPS: 128 129 130 131} 132 133 134 135 136 137 138 {139 140 141 142 <143> 144 145 146 147 148 149 150 151] 152 153 154 155 156 157 158 [159 + \\OPS: 160 161 162 <163> 164 165 166 167 168 169 170 171} 172 173 174 175 176 177 178 {179 180 181 182 <183> 184 185 186 187 188 189 190 191] + \\OPS: 192 193 194 195 196 197 198 [199 200 201 202 <203> 204 205 206 207 208 209 210 211} 212 213 214 215 216 217 218 {219 220 221 222 <223> + \\OPS: 224 225 226 227 228 229 230 231] 232 233 234 235 236 237 238 [239 240 241 242 <243> 244 245 246 247 248 249 250 251} 252 253 254 255 + \\OPS: 256 257 258 {259 260 261 262 <263> 264 265 266 267 268 269 270 271] 272 273 274 275 276 277 278 [279 280 281 282 <283> 284 285 286 287 + \\OPS: 288 289 290 291} 292 293 294 295 296 297 298 {299 300 301 302 <303> 304 305 306 307 308 309 310 311] 312 313 314 315 316 317 318 [319 + \\ + ).diff(string.items); +} + +pub const Snapshot = struct { + /// A table with TableInfo.snapshot_min=S was written during some commit with op 0); + assert(options.block_count == stdx.div_ceil(options.trailer_size, chunk_size_max)); + assert(options.block_index < options.block_count); + + const last_block = options.block_index == options.block_count - 1; + const chunk_size: u32 = if (last_block) + @intCast(options.trailer_size - (options.block_count - 1) * chunk_size_max) + else + chunk_size_max; + + return chunk_size; + } +}; + +/// CheckpointTrailer is the persistent representation of the free set and client sessions. +/// It defines the layout of the free set and client sessions as stored in the grid between +/// checkpoints. +/// +/// - Free set is stored as a linked list of blocks containing EWAH-encoding of a bitset of acquired +/// blocks. The length of the linked list is proportional to the degree of fragmentation, rather +/// that to the size of the data file. The common case is a single block. +/// +/// The blocks holding free set itself are marked as free in the on-disk encoding, because the +/// number of blocks required to store the compressed bitset becomes known only after encoding. +/// This might or might not be related to Russell's paradox. +/// +/// - Client sessions is stored as a linked list of blocks containing reply headers and session +/// numbers. +/// +/// Linked list is a FIFO. While the blocks are written in the direct order, they have to be read in +/// the reverse order. +pub fn CheckpointTrailerType(comptime Storage: type) type { + const Grid = GridType(Storage); + + return struct { + const CheckpointTrailer = @This(); + + // Reference to the grid is late-initialized in `open`, because the free set is part of + // the grid, which doesn't have access to a stable grid pointer. It is set to null by + // `reset`, to verify that the free set is not used before it is opened during sync. + grid: ?*Grid = null, + trailer_type: TrailerType, + + next_tick: Grid.NextTick = undefined, + read: Grid.Read = undefined, + write: Grid.Write = undefined, + + // TODO(Grid pool): Acquire blocks as-needed from the grid pool. The common-case number of + // blocks needed is much less than the worst-case number of blocks. + blocks: []BlockPtr, + /// `encode_chunks()`/`decode_chunks()` return slices into this memory. + block_bodies: [][]align(@sizeOf(u256)) u8, + + // SoA representation of block references holding the trailer itself. + // + // After the set is read from disk and decoded, these blocks are manually marked as + // acquired. + block_addresses: []u64, + block_checksums: []u128, + // The current block that is being read or written. It counts from 0 to block_count() + // during checkpoint, and from block_count() to zero during open. + block_index: u32 = 0, + + // Size of the encoded set in bytes. + // (Does not include block headers.) + size: u64 = 0, + // The number of trailer bytes read or written during disk IO. Used to cross-check that we + // haven't lost any bytes along the way. + size_transferred: u64 = 0, + + // Checksum covering the entire encoded trailer. + // (Does not include block headers.) + checksum: u128 = 0, + + callback: union(enum) { + none, + open: *const fn (trailer: *CheckpointTrailer) void, + checkpoint: *const fn (trailer: *CheckpointTrailer) void, + } = .none, + + pub fn init( + allocator: mem.Allocator, + trailer_type: TrailerType, + buffer_size: usize, + ) !CheckpointTrailer { + const block_count_max_ = block_count_for_trailer_size(buffer_size); + const blocks = try allocator.alloc(BlockPtr, block_count_max_); + errdefer allocator.free(blocks); + @memset(blocks, undefined); + + const block_bodies = try allocator.alloc([]align(@sizeOf(u256)) u8, block_count_max_); + errdefer allocator.free(block_bodies); + @memset(block_bodies, undefined); + + const block_addresses = try allocator.alloc(u64, block_count_max_); + errdefer allocator.free(block_addresses); + + const block_checksums = try allocator.alloc(u128, block_count_max_); + errdefer allocator.free(block_checksums); + + return .{ + .trailer_type = trailer_type, + .blocks = blocks, + .block_bodies = block_bodies, + .block_addresses = block_addresses, + .block_checksums = block_checksums, + }; + } + + pub fn deinit(trailer: *CheckpointTrailer, allocator: mem.Allocator) void { + if (trailer.grid) |grid| { + for (trailer.blocks) |block| grid.block_unref(block); + } else { + assert(trailer.size == 0); + } + + allocator.free(trailer.block_checksums); + allocator.free(trailer.block_addresses); + allocator.free(trailer.block_bodies); + allocator.free(trailer.blocks); + } + + pub fn reset(trailer: *CheckpointTrailer) void { + if (trailer.grid) |grid| { + for (trailer.blocks) |*block| { + grid.block_unref(block.*); + } + } else { + assert(trailer.size == 0); + } + + switch (trailer.callback) { + .none, .open => {}, + // Checkpointing doesn't need to read blocks, so it's not cancellable. + .checkpoint => unreachable, + } + trailer.* = .{ + .trailer_type = trailer.trailer_type, + .blocks = trailer.blocks, + .block_bodies = trailer.block_bodies, + .block_addresses = trailer.block_addresses, + .block_checksums = trailer.block_checksums, + }; + } + + pub fn block_count(trailer: *const CheckpointTrailer) u32 { + assert(trailer.grid != null); + return block_count_for_trailer_size(trailer.size); + } + + /// Each returned chunk has `chunk.len == chunk_size_max`. + pub fn encode_chunks(trailer: *CheckpointTrailer) []const []align(@sizeOf(u256)) u8 { + assert(trailer.grid != null); + + // Get a fresh set of blocks, since the caller is going to write to them. + for (trailer.blocks) |*block| { + trailer.grid.?.block_unref(block.*); + block.* = trailer.grid.?.get_block(); + } + + for (trailer.block_bodies, trailer.blocks) |*block_body, block| { + block_body.* = block[@sizeOf(vsr.Header)..]; + + assert(block_body.*.len == chunk_size_max); + } + return trailer.block_bodies; + } + + pub fn decode_chunks( + trailer: *const CheckpointTrailer, + ) []const []align(@sizeOf(u256)) const u8 { + assert(trailer.grid != null); + + const chunk_count: u32 = @intCast(stdx.div_ceil(trailer.size, chunk_size_max)); + for ( + trailer.block_bodies[0..chunk_count], + trailer.blocks[0..chunk_count], + 0.., + ) |*block_body, block, block_index| { + const chunk_size = Chunk.size(.{ + .block_index = @intCast(block_index), + .block_count = chunk_count, + .trailer_size = trailer.size, + }); + + block_body.* = block[@sizeOf(vsr.Header)..][0..chunk_size]; + } + return trailer.block_bodies[0..chunk_count]; + } + + // These data are stored in the superblock header. + pub fn checkpoint_reference( + trailer: *const CheckpointTrailer, + ) vsr.SuperBlockTrailerReference { + assert(trailer.size == trailer.size_transferred); + assert(trailer.callback == .none); + + const reference: vsr.SuperBlockTrailerReference = if (trailer.size == 0) .{ + .checksum = vsr.checksum(&.{}), + .last_block_address = 0, + .last_block_checksum = 0, + .trailer_size = 0, + } else .{ + .checksum = trailer.checksum, + .last_block_address = trailer.block_addresses[trailer.block_count() - 1], + .last_block_checksum = trailer.block_checksums[trailer.block_count() - 1], + .trailer_size = trailer.size, + }; + assert(reference.empty() == (trailer.size == 0)); + + return reference; + } + + pub fn open( + trailer: *CheckpointTrailer, + grid: *Grid, + reference: vsr.SuperBlockTrailerReference, + callback: *const fn (trailer: *CheckpointTrailer) void, + ) void { + assert(trailer.grid == null); + trailer.grid = grid; + + assert(trailer.callback == .none); + defer assert(trailer.callback == .open); + + assert(reference.trailer_size % trailer.trailer_type.item_size() == 0); + assert(block_count_for_trailer_size(reference.trailer_size) <= trailer.blocks.len); + assert(trailer.size == 0); + assert(trailer.size_transferred == 0); + assert(trailer.block_index == 0); + + for (trailer.blocks) |*block| block.* = grid.get_block(); + + trailer.size = reference.trailer_size; + trailer.checksum = reference.checksum; + trailer.callback = .{ .open = callback }; + + // Start from the last block, as the linked list arranges data in the reverse order. + trailer.block_index = trailer.block_count(); + + if (trailer.size == 0) { + assert(reference.last_block_address == 0); + trailer.grid.?.on_next_tick(open_next_tick, &trailer.next_tick); + } else { + assert(reference.last_block_address != 0); + trailer.open_read_next(reference.last_block_address, reference.last_block_checksum); + } + } + + fn open_next_tick(next_tick: *Grid.NextTick) void { + const trailer: *CheckpointTrailer = @alignCast(@fieldParentPtr("next_tick", next_tick)); + assert(trailer.grid != null); + assert(trailer.callback == .open); + assert(trailer.size == 0); + trailer.open_done(); + } + + fn open_read_next(trailer: *CheckpointTrailer, address: u64, checksum: u128) void { + assert(trailer.callback == .open); + assert(trailer.size > 0); + assert((trailer.size_transferred == 0) == + (trailer.block_index == trailer.block_count())); + assert(address != 0); + + assert(trailer.block_index <= trailer.block_count()); + assert(trailer.block_index > 0); + trailer.block_index -= 1; + + trailer.block_addresses[trailer.block_index] = address; + trailer.block_checksums[trailer.block_index] = checksum; + for (trailer.block_index + 1..trailer.block_count()) |index| { + assert(trailer.block_addresses[index] != address); + assert(trailer.block_checksums[index] != checksum); + } + + trailer.grid.?.read_block( + .{ .from_local_or_global_storage = open_read_next_callback }, + &trailer.read, + address, + checksum, + .{ .cache_read = true, .cache_write = false }, + ); + } + + fn open_read_next_callback(read: *Grid.Read, block: BlockPtrConst) void { + const trailer: *CheckpointTrailer = @fieldParentPtr("read", read); + assert(trailer.callback == .open); + assert(trailer.size > 0); + assert(trailer.block_index < trailer.block_count()); + + const block_header = schema.header_from_block(block); + assert(block_header.block_type == trailer.trailer_type.block_type()); + + const chunk_size = Chunk.size(.{ + .block_index = trailer.block_index, + .block_count = trailer.block_count(), + .trailer_size = trailer.size, + }); + assert(chunk_size > 0); + + trailer.grid.?.block_unref(trailer.blocks[trailer.block_index]); + trailer.blocks[trailer.block_index] = @constCast(trailer.grid.?.block_ref(block)); + trailer.size_transferred += chunk_size; + + if (schema.TrailerNode.previous(block)) |previous| { + assert(trailer.block_index > 0); + trailer.open_read_next(previous.address, previous.checksum); + } else { + assert(trailer.block_index == 0); + trailer.open_done(); + } + } + + fn open_done(trailer: *CheckpointTrailer) void { + assert(trailer.grid != null); + assert(trailer.callback == .open); + defer assert(trailer.callback == .none); + + assert(trailer.block_index == 0); + assert(trailer.size_transferred == trailer.size); + + var checksum_stream = vsr.ChecksumStream.init(); + for (trailer.decode_chunks()) |chunk| checksum_stream.add(chunk); + assert(trailer.checksum == checksum_stream.checksum()); + + const callback = trailer.callback.open; + trailer.callback = .none; + callback(trailer); + } + + pub fn checkpoint( + trailer: *CheckpointTrailer, + callback: *const fn (trailer: *CheckpointTrailer) void, + ) void { + assert(trailer.callback == .none); + defer assert(trailer.callback == .checkpoint); + + var checksum_stream = vsr.ChecksumStream.init(); + for (trailer.decode_chunks()) |chunk| checksum_stream.add(chunk); + + trailer.size_transferred = 0; + trailer.checksum = checksum_stream.checksum(); + + if (trailer.size > 0) { + assert(trailer.grid.?.free_set.count_reservations() == 0); + const reservation = trailer.grid.?.free_set.reserve(trailer.block_count()).?; + defer trailer.grid.?.free_set.forfeit(reservation); + + for ( + trailer.block_addresses[0..trailer.block_count()], + trailer.block_checksums[0..trailer.block_count()], + ) |*address, *checksum| { + address.* = trailer.grid.?.free_set.acquire(reservation).?; + checksum.* = undefined; + } + // Reservation should be fully used up. + assert(trailer.grid.?.free_set.acquire(reservation) == null); + } + + trailer.block_index = 0; + trailer.callback = .{ .checkpoint = callback }; + if (trailer.size == 0) { + trailer.grid.?.on_next_tick(checkpoint_next_tick, &trailer.next_tick); + } else { + trailer.checkpoint_write_next(); + } + } + + fn checkpoint_next_tick(next_tick: *Grid.NextTick) void { + const trailer: *CheckpointTrailer = @alignCast(@fieldParentPtr("next_tick", next_tick)); + assert(trailer.callback == .checkpoint); + assert(trailer.size == 0); + assert(trailer.block_index == 0); + trailer.checkpoint_done(); + } + + fn checkpoint_write_next(trailer: *CheckpointTrailer) void { + assert(trailer.callback == .checkpoint); + assert(trailer.size > 0); + assert(trailer.block_index < trailer.block_count()); + assert((trailer.size_transferred == 0) == (trailer.block_index == 0)); + + const chunk_size = Chunk.size(.{ + .block_index = trailer.block_index, + .block_count = trailer.block_count(), + .trailer_size = trailer.size, + }); + + const block_index = trailer.block_index; + const block = &trailer.blocks[block_index]; + const metadata: schema.TrailerNode.Metadata = if (block_index == 0) .{ + .previous_trailer_block_checksum = 0, + .previous_trailer_block_address = 0, + } else .{ + .previous_trailer_block_checksum = trailer.block_checksums[block_index - 1], + .previous_trailer_block_address = trailer.block_addresses[block_index - 1], + }; + + const header = mem.bytesAsValue(vsr.Header.Block, block.*[0..@sizeOf(vsr.Header)]); + header.* = .{ + .cluster = trailer.grid.?.superblock.working.cluster, + .metadata_bytes = @bitCast(metadata), + .address = trailer.block_addresses[trailer.block_index], + .snapshot = 0, // TODO(snapshots): Set this properly; it is useful for debugging. + .size = @sizeOf(vsr.Header) + chunk_size, + .command = .block, + .release = trailer.grid.?.superblock.working.vsr_state.checkpoint.release, + .block_type = trailer.trailer_type.block_type(), + }; + trailer.size_transferred += chunk_size; + header.set_checksum_body(block.*[@sizeOf(vsr.Header)..][0..chunk_size]); + header.set_checksum(); + schema.TrailerNode.assert_valid_header(block.*); + + trailer.block_checksums[block_index] = header.checksum; + // create_block swaps out the `blocks` BlockPtr, so our reference to it will be invalid. + trailer.block_bodies[block_index] = undefined; + trailer.grid.?.create_block(checkpoint_write_next_callback, &trailer.write, block); + } + + fn checkpoint_write_next_callback(write: *Grid.Write) void { + const trailer: *CheckpointTrailer = @fieldParentPtr("write", write); + assert(trailer.callback == .checkpoint); + + trailer.block_index += 1; + if (trailer.block_index == trailer.block_count()) { + trailer.checkpoint_done(); + } else { + trailer.checkpoint_write_next(); + } + } + + fn checkpoint_done(trailer: *CheckpointTrailer) void { + assert(trailer.callback == .checkpoint); + defer assert(trailer.callback == .none); + + assert(trailer.block_index == trailer.block_count()); + assert(trailer.size_transferred == trailer.size); + + const callback = trailer.callback.checkpoint; + trailer.callback = .none; + callback(trailer); + } + }; +} + +pub fn block_count_for_trailer_size(trailer_size: u64) u32 { + return @intCast(stdx.div_ceil(trailer_size, chunk_size_max)); +} + +pub const TrailerType = enum { + free_set, + client_sessions, + + fn block_type(trailer_type: TrailerType) schema.BlockType { + return switch (trailer_type) { + .free_set => .free_set, + .client_sessions => .client_sessions, + }; + } + + fn item_size(trailer_type: TrailerType) usize { + return switch (trailer_type) { + .free_set => @sizeOf(FreeSet.Word), + .client_sessions => @sizeOf(vsr.Header) + @sizeOf(u64), + }; + } +}; diff --git a/ocam/src/vsr/checksum.zig b/ocam/src/vsr/checksum.zig new file mode 100644 index 00000000..6a87679d --- /dev/null +++ b/ocam/src/vsr/checksum.zig @@ -0,0 +1,256 @@ +//! This file implements vsr.checksum. TigerBeetle uses this checksum to: +//! +//! - detect bitrot in data on disk, +//! - validate network messages before casting raw bytes to an `extern struct` type, +//! - hash-chain prepares and client requests to have strong consistency and ordering guarantees. +//! +//! As this checksum is stored on disk, it is set in stone and impossible to change. +//! +//! We need this checksum to be fast (it's in all our hotpaths) and strong (it's our ultimate line +//! of defense against storage failures and some classes of software bugs). +//! +//! Our checksum of choice is based on Aegis: +//! +//! +//! +//! We use the implementation from the Zig standard library, but here's the overall overview of the +//! thing works: +//! +//! - AES-block is a widely supported in hardware symmetric encryption primitive (`vaesenc`, +//! `vaesdec` instructions). Hardware acceleration is what provides speed. +//! - Aegis is an modern Authenticated Encryption with Associated Data (AEAD) scheme based on +//! AES-block. +//! - In AEAD, the user provides, a key, a nonce, a secret message, and associated data, and gets +//! a ciphertext and an authentication tag back. Associated data is expected to be sent as plain +//! text (eg, it could be routing information). The tag authenticates _both_ the secret message +//! and associated data. +//! - AEAD can be specialized to be a MAC by using an empty secret message and zero nonce. NB: +//! in mac mode, message to sign is treated as AD, not as a secret message. +//! - A MAC can further be specialized to be a checksum by setting the secret key to zero. +//! And that's what we do here! + +const std = @import("std"); +const builtin = @import("builtin"); +const mem = std.mem; +const testing = std.testing; +const assert = std.debug.assert; + +const stdx = @import("stdx"); +const MiB = stdx.MiB; + +const Aegis128LMac_128 = stdx.aegis.Aegis128LMac_128; + +var seed_once = std.once(seed_init); +var seed_state: Aegis128LMac_128 = undefined; + +comptime { + // As described above, TigerBeetle uses Aegis (and thus AES Blocks), for its checksumming. + // While there is a software implementation, it's much slower and we don't expect to ever be + // using it considering we target platforms with AES hardware acceleration. + // + // If you're trying to compile TigerBeetle for an older CPU without AES hardware acceleration, + // you'll need to disable the following assert. + assert(std.crypto.core.aes.has_hardware_support); +} + +fn seed_init() void { + const key: [16]u8 = @splat(0); + seed_state = Aegis128LMac_128.init(&key); +} + +// Lazily initialize the Aegis State instead of recomputing it on each call to checksum(). +// Then, make a copy of the state and use that to hash the source input bytes. +pub fn checksum(source: []const u8) u128 { + if (@inComptime()) { + // Aegis128 uses hardware accelerated AES via inline asm which isn't available at comptime. + // Use a hard-coded value instead and verify via a test. + if (source.len == 0) return 0x49F174618255402DE6E7E3C40D60CC83; + } + var stream = ChecksumStream.init(); + stream.add(source); + return stream.checksum(); +} + +test "checksum empty" { + var stream = ChecksumStream.init(); + stream.add(&.{}); + try std.testing.expectEqual(stream.checksum(), comptime checksum(&.{})); +} + +pub const ChecksumStream = struct { + state: Aegis128LMac_128, + + pub fn init() ChecksumStream { + seed_once.call(); + return ChecksumStream{ .state = seed_state }; + } + + pub fn add(stream: *ChecksumStream, bytes: []const u8) void { + stream.state.update(bytes); + } + + pub fn checksum(stream: *ChecksumStream) u128 { + var result: u128 = undefined; + stream.state.final(mem.asBytes(&result)); + stream.* = undefined; + return result; + } +}; + +// Note: these test vectors are not independent --- there are test vectors in AEAD papers, but they +// don't zero all of (nonce, key, secret message). However, the as underlying AEAD implementation +// matches those test vectors, the entries here are correct. +// +// They can be used to smoke-test independent implementations of TigerBeetle checksum. +// +// "checksum stability" test further nails down the exact behavior. +test "checksum test vectors" { + const TestVector = struct { + source: []const u8, + hash: u128, + }; + + for (&[_]TestVector{ + .{ + .source = &[_]u8{0x00} ** 16, + .hash = @byteSwap(@as(u128, 0xf72ad48dd05dd1656133101cd4be3a26)), + }, + .{ + .source = &[_]u8{}, + .hash = @byteSwap(@as(u128, 0x83cc600dc4e3e7e62d4055826174f149)), + }, + }) |test_vector| { + try testing.expectEqual(test_vector.hash, checksum(test_vector.source)); + } +} + +test "checksum simple fuzzing" { + var prng = stdx.PRNG.from_seed(42); + + const msg_min = 1; + const msg_max = 1 * MiB; + + var msg_buf = try testing.allocator.alloc(u8, msg_max); + defer testing.allocator.free(msg_buf); + + const cipher_buf = try testing.allocator.alloc(u8, msg_max); + defer testing.allocator.free(cipher_buf); + + var i: usize = 0; + while (i < 1_000) : (i += 1) { + const msg_len = prng.range_inclusive(usize, msg_min, msg_max); + const msg = msg_buf[0..msg_len]; + prng.fill(msg); + + const msg_checksum = checksum(msg); + + // Sanity check that it's a pure function. + const msg_checksum_again = checksum(msg); + try testing.expectEqual(msg_checksum, msg_checksum_again); + + // Change the message and make sure the checksum changes. + msg[prng.index(msg)] +%= 1; + const changed_checksum = checksum(msg); + try testing.expect(changed_checksum != msg_checksum); + } +} + +// Change detector test to ensure we don't inadvertency modify our checksum function. +test "checksum stability" { + var buf: [1024]u8 = undefined; + var cases: [896]u128 = undefined; + var case_index: usize = 0; + + // Zeros of various lengths. + var subcase: usize = 0; + while (subcase < 128) : (subcase += 1) { + const message = buf[0..subcase]; + @memset(message, 0); + + cases[case_index] = checksum(message); + case_index += 1; + } + + // 64 bytes with exactly one bit set. + subcase = 0; + while (subcase < 64 * 8) : (subcase += 1) { + const message = buf[0..64]; + @memset(message, 0); + message[@divFloor(subcase, 8)] = @shlExact(@as(u8, 1), @as(u3, @intCast(subcase % 8))); + + cases[case_index] = checksum(message); + case_index += 1; + } + + // Pseudo-random data from a specific PRNG of various lengths. + var prng = stdx.PRNG.from_seed(92); + subcase = 0; + while (subcase < 256) : (subcase += 1) { + const message = buf[0 .. subcase + 13]; + prng.fill(message); + + cases[case_index] = checksum(message); + case_index += 1; + } + + // Sanity check that we are not getting trivial answers. + for (cases, 0..) |case_a, i| { + assert(case_a != 0); + assert(case_a != std.math.maxInt(u128)); + for (cases[0..i]) |case_b| assert(case_a != case_b); + } + + // Hash me, baby, one more time! If this final hash changes, we broke compatibility in a major + // way. + comptime assert(builtin.target.cpu.arch.endian() == .little); + const hash = checksum(mem.sliceAsBytes(&cases)); + try testing.expectEqual(0x82dcaacf4875b279446825b6830d1263, hash); +} + +test "checksum alignment and sizing" { + var gpa = std.testing.allocator; + + var input: []align(1) u8 = try gpa.alignedAlloc(u8, 1, 8 * stdx.KiB); + defer gpa.free(input); + + var prng = stdx.PRNG.from_seed(92); + prng.fill(input); + + var cases: [4112]u128 = @splat(0); + var case_index: usize = 0; + + for (0..16) |start_idx| { + cases[case_index] = checksum(input[start_idx..]); + case_index += 1; + for (0..256) |size| { + cases[case_index] = checksum(input[start_idx..][0..size]); + case_index += 1; + } + } + + for (cases) |case| { + try std.testing.expect(case != 0); + try std.testing.expect(case != std.math.maxInt(u128)); + } + + for (0..input.len) |idx| { + input[idx] = @intCast(idx % 2); + } + + const window_size = 256; + + const even = checksum(input[0..window_size]); + const odd = checksum(input[1..][0..window_size]); + + for (0..16) |start_idx| { + if (start_idx % 2 == 0) { + try std.testing.expectEqual(even, checksum(input[start_idx..][0..window_size])); + } else { + try std.testing.expectEqual(odd, checksum(input[start_idx..][0..window_size])); + } + } + + comptime assert(builtin.target.cpu.arch.endian() == .little); + const hash = checksum(mem.sliceAsBytes(&cases)); + try testing.expectEqual(0xC8E7102D72CE96458639F6027DA0FBA0, hash); +} diff --git a/ocam/src/vsr/checksum_benchmark.zig b/ocam/src/vsr/checksum_benchmark.zig new file mode 100644 index 00000000..046c5ee7 --- /dev/null +++ b/ocam/src/vsr/checksum_benchmark.zig @@ -0,0 +1,43 @@ +const std = @import("std"); + +const cache_line_size = @import("../constants.zig").cache_line_size; +const checksum = @import("checksum.zig").checksum; + +const stdx = @import("stdx"); + +const KiB = stdx.KiB; +const MiB = stdx.MiB; + +const Bench = stdx.Bench; + +const repetitions = 35; + +test "benchmark: checksum" { + var bench: Bench = .init(); + defer bench.deinit(); + + const blob_size = bench.parameter("blob_size", KiB, MiB); + + var arena_instance = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_instance.deinit(); + + const arena = arena_instance.allocator(); + var prng = stdx.PRNG.from_seed(bench.seed); + const blob = try arena.alignedAlloc(u8, cache_line_size, blob_size); + prng.fill(blob); + + var duration_samples: [repetitions]stdx.Duration = undefined; + var checksum_counter: u128 = 0; + + for (&duration_samples) |*duration| { + bench.start(); + checksum_counter +%= checksum(blob); + duration.* = bench.stop(); + } + + const result = bench.estimate(&duration_samples); + + // See "benchmark: API tutorial" to understand why we print out the "hash" of this run. + bench.report("checksum {x:0>32}", .{checksum_counter}); + bench.report("{} for whole blob", .{result}); +} diff --git a/ocam/src/vsr/client.zig b/ocam/src/vsr/client.zig new file mode 100644 index 00000000..90e0acae --- /dev/null +++ b/ocam/src/vsr/client.zig @@ -0,0 +1,784 @@ +const std = @import("std"); +const stdx = @import("stdx"); +const mem = std.mem; +const assert = std.debug.assert; +const maybe = stdx.maybe; + +const constants = @import("../constants.zig"); +const vsr = @import("../vsr.zig"); +const Header = vsr.Header; +const Time = vsr.time.Time; + +const MessagePool = @import("../message_pool.zig").MessagePool; +const Message = @import("../message_pool.zig").MessagePool.Message; +const MessageBuffer = @import("../message_buffer.zig").MessageBuffer; + +const log = stdx.log.scoped(.client); + +pub fn ClientType( + comptime StateMachineOperation: type, + comptime MessageBus: type, +) type { + return struct { + const Client = @This(); + + pub const Operation = StateMachineOperation; + pub const Request = struct { + pub const Callback = *const fn ( + user_data: u128, + operation: vsr.Operation, + timestamp: u64, + result: []align(constants.cache_line_size) const u8, + ) void; + + pub const RegisterCallback = *const fn ( + user_data: u128, + result: *const vsr.RegisterResult, + ) void; + + message: *Message.Request, + user_data: u128, + callback: union(enum) { + /// When message.header.operation ≠ .register + request: Callback, + /// When message.header.operation = .register + register: RegisterCallback, + }, + }; + + message_bus: MessageBus, + + time: Time, + + /// A universally unique identifier for the client (must not be zero). + /// Used for routing replies back to the client via any network path (multi-path routing). + /// The client ID must be ephemeral and random per process, and never persisted, so that + /// lingering or zombie deployment processes cannot break correctness and/or liveness. + /// A cryptographic random number generator must be used to ensure these properties. + id: u128, + + /// The identifier for the cluster that this client intends to communicate with. + cluster: u128, + + /// The number of replicas in the cluster. + replica_count: u8, + + aof_recovery: bool, + + /// Only tests should ever override the release. + release: vsr.Release = constants.config.process.release, + + /// The total number of ticks elapsed since the client was initialized. + ticks: u64 = 0, + + /// We hash-chain request/reply checksums to verify linearizability within a client session: + /// * so that the parent of the next request is the checksum of the latest reply, and + /// * so that the parent of the next reply is the checksum of the latest request. + parent: u128 = 0, + + /// The session number for the client, zero when registering a session, non-zero thereafter. + session: u64 = 0, + + /// The request number of the next request. + request_number: u32 = 0, + + /// Measures the time elapsed between sending a request (in `raw_request`) and receiving the + /// corresponding reply (in `on_reply`). + request_completion_timer: vsr.time.Timer, + + /// The maximum body size for `command=request` messages. + /// Set by the `register`'s reply. + batch_size_limit: ?u32 = null, + + /// The highest view number seen by the client in messages exchanged with the cluster. Used + /// to locate the current primary, and provide more information to a partitioned primary. + view: u32 = 0, + + /// Tracks a currently processing (non-register) request message submitted by `register()` + /// or `raw_request()`. + request_inflight: ?Request = null, + + /// The number of ticks without a reply before the client resends the inflight request. + /// Dynamically adjusted as a function of recent request round-trip time. + request_timeout: vsr.Timeout, + + /// The number of ticks before the client broadcasts a ping to the cluster. + /// Used for end-to-end keepalive, and to discover a new primary between requests. + ping_timeout: vsr.Timeout, + + /// The round-trip time (estimated by the latest ping/pong pair) from each replica. + replica_round_trip_times_ns: [constants.replicas_max]?u64 = @splat(null), + + /// Used to calculate exponential backoff with random jitter. + /// Seeded with the client's ID. + prng: stdx.PRNG, + + on_reply_context: ?*anyopaque = null, + /// Used for testing. Called for replies to all operations (including `register`). + on_reply_callback: ?*const fn ( + client: *Client, + request: *Message.Request, + reply: *Message.Reply, + ) void = null, + + evicted: bool = false, + on_eviction_callback: ?*const fn ( + client: *Client, + eviction: *const Message.Eviction, + ) void = null, + + pub fn init( + allocator: mem.Allocator, + time: Time, + message_pool: *MessagePool, + options: struct { + id: u128, + cluster: u128, + replica_count: u8, + aof_recovery: bool, + message_bus_options: MessageBus.Options, + /// When eviction_callback is null, the client will panic on eviction. + /// + /// When eviction_callback is non-null, it must `deinit()` the Client. + /// After eviction, the client must not send or process any additional messages. + eviction_callback: ?*const fn ( + client: *Client, + eviction: *const Message.Eviction, + ) void = null, + }, + ) !Client { + assert(options.id > 0); + assert(options.replica_count > 0); + + var message_bus = try MessageBus.init( + allocator, + .{ .client = options.id }, + message_pool, + Client.on_messages, + options.message_bus_options, + ); + errdefer message_bus.deinit(allocator); + + var self = Client{ + .message_bus = message_bus, + .time = time, + .id = options.id, + .cluster = options.cluster, + .replica_count = options.replica_count, + .aof_recovery = options.aof_recovery, + .request_completion_timer = .init(time), + .request_timeout = .{ + .name = "request_timeout", + .id = options.id, + .after = constants.rtt_ticks * constants.rtt_multiple, + }, + .ping_timeout = .{ + .name = "ping_timeout", + .id = options.id, + .after = 30000 / constants.tick_ms, + }, + .prng = stdx.PRNG.from_seed(@as(u64, @truncate(options.id))), + .on_eviction_callback = options.eviction_callback, + }; + + self.ping_timeout.start(); + + return self; + } + + pub fn deinit(self: *Client, allocator: std.mem.Allocator) void { + if (self.request_inflight) |inflight| self.release_message(inflight.message.base()); + self.message_bus.deinit(allocator); + } + + /// Begin a graceful shutdown of the underlying message bus connections. The caller + /// must continue to drive `io.run_for_ns()` until `shutdown_complete()` returns true + /// before calling `deinit()`. + pub fn shutdown(self: *Client) void { + self.message_bus.shutdown(); + } + + pub fn shutdown_complete(self: *const Client) bool { + return self.message_bus.shutdown_complete(); + } + + pub fn on_messages(message_bus: *MessageBus, buffer: *MessageBuffer) void { + const self: *Client = @fieldParentPtr("message_bus", message_bus); + while (buffer.next_header()) |header| { + const message = buffer.consume_message(self.message_bus.pool, &header); + defer self.message_bus.unref(message); + + if (message.header.cluster != self.cluster) { + buffer.invalidate(.header_cluster); + return; + } + if (!self.evicted) { + self.on_message(message); + } + } + } + + pub fn on_message(self: *Client, message: *Message) void { + assert(!self.evicted); + + // Switch on the header type so that we don't log opaque bytes for the per-command data. + switch (message.header.into_any()) { + inline else => |header| { + log.debug("{}: on_message: {}", .{ self.id, header }); + }, + } + + if (message.header.invalid()) |reason| { + log.debug("{}: on_message: invalid ({s})", .{ self.id, reason }); + return; + } + if (message.header.cluster != self.cluster) { + log.warn("{}: on_message: wrong cluster (cluster should be {}, not {})", .{ + self.id, + self.cluster, + message.header.cluster, + }); + return; + } + switch (message.into_any()) { + .pong_client => |m| self.on_pong_client(m), + .reply => |m| self.on_reply(m), + .eviction => |m| self.on_eviction(m), + else => { + log.warn("{}: on_message: ignoring misdirected {s} message", .{ + self.id, + @tagName(message.header.command), + }); + return; + }, + } + } + + pub fn tick(self: *Client) void { + assert(!self.evicted); + + self.ticks += 1; + + self.message_bus.tick_client(); + self.time.tick(); + + self.ping_timeout.tick(); + self.request_timeout.tick(); + + if (self.ping_timeout.fired()) self.on_ping_timeout(); + if (self.request_timeout.fired()) self.on_request_timeout(); + } + + /// Registers a session with the cluster for the client, if this has not yet been done. + pub fn register(self: *Client, callback: Request.RegisterCallback, user_data: u128) void { + assert(!self.evicted); + assert(self.request_inflight == null); + assert(self.request_number == 0); + + const message = self.get_message().build(.request); + errdefer self.release_message(message.base()); + + // We will set parent, session, view and checksums only when sending for the first time: + message.header.* = .{ + .size = @sizeOf(Header) + @sizeOf(vsr.RegisterRequest), + .client = self.id, + .request = self.request_number, + .cluster = self.cluster, + .command = .request, + .operation = .register, + .release = self.release, + .previous_request_latency = 0, + // During AOF recovery, if we were to pass timestamp=0, the primary would assign the + // timestamp. Instead, we send a fixed bogus timestamp (1), to ensure that AOF + // recovery is deterministic. + .timestamp = @intFromBool(self.aof_recovery), + }; + + std.mem.bytesAsValue( + vsr.RegisterRequest, + message.body_used()[0..@sizeOf(vsr.RegisterRequest)], + ).* = .{ + .batch_size_limit = 0, + }; + + assert(self.request_number == 0); + self.request_number += 1; + + log.debug( + "{}: register: registering a session with the cluster user_data={}", + .{ self.id, user_data }, + ); + + self.request_inflight = .{ + .message = message, + .user_data = user_data, + .callback = .{ .register = callback }, + }; + self.send_request_for_the_first_time(message); + + // Proactively send ping to replicas so they can identify this peer + // (see `recv_update_peer` in MessageBus). + self.on_ping_timeout(); + } + + /// Sends a request message with the operation and events payload to the replica. + /// There must be no other request message currently inflight. + pub fn request( + self: *Client, + callback: Request.Callback, + user_data: u128, + operation: Operation, + events: []const u8, + ) void { + assert(!self.evicted); + assert(self.request_inflight == null); + assert(self.request_number > 0); + + const event_size = operation.event_size(); + assert(events.len <= constants.message_body_size_max); + assert(events.len <= self.batch_size_limit.?); + assert(events.len % event_size == 0); + + const message = self.get_message().build(.request); + errdefer self.release_message(message.base()); + + message.header.* = .{ + .client = self.id, + .request = 0, // Set inside `raw_request` down below. + .cluster = self.cluster, + .command = .request, + .release = self.release, + .operation = operation.to_vsr(), + .size = @intCast(@sizeOf(Header) + events.len), + .previous_request_latency = 0, + }; + + stdx.copy_disjoint(.exact, u8, message.body_used(), events); + self.raw_request(callback, user_data, message); + } + + /// Sends a request, only setting request_number in the header. + /// There must be no other request message currently inflight. + pub fn raw_request( + self: *Client, + callback: Request.Callback, + user_data: u128, + message: *Message.Request, + ) void { + assert(self.request_inflight == null); + assert(self.request_number > 0); + assert(message.header.client == self.id); + assert(message.header.release.value == self.release.value); + assert(message.header.cluster == self.cluster); + assert(message.header.command == .request); + assert(message.header.size >= @sizeOf(Header)); + assert(message.header.size <= constants.message_size_max); + assert(message.header.size <= @sizeOf(Header) + self.batch_size_limit.?); + assert(message.header.operation.valid(Operation)); + assert(message.header.view == 0); + assert(message.header.parent == 0); + assert(message.header.session == 0); + assert(message.header.request == 0); + assert((message.header.timestamp == 0) != self.aof_recovery); + + if (!self.aof_recovery) { + assert(message.header.operation == .noop or + !message.header.operation.vsr_reserved()); + } + + message.header.request = self.request_number; + self.request_number += 1; + self.request_completion_timer.reset(); + + log.debug("{}: request: user_data={} request={} size={} {s}", .{ + self.id, + user_data, + message.header.request, + message.header.size, + message.header.operation.tag_name(Operation), + }); + + self.request_inflight = .{ + .message = message, + .user_data = user_data, + .callback = .{ .request = callback }, + }; + self.send_request_for_the_first_time(message); + } + + /// Acquires a message from the message bus. + /// The caller must ensure that a message is available. + /// + /// Either use it in `client.raw_request()` or discard via `client.release_message()`, + /// the reference is not guaranteed to be valid after both actions. + /// Do NOT use the reference counter function `message.ref()` for storing the message. + pub fn get_message(self: *Client) *Message { + return self.message_bus.get_message(null); + } + + /// Releases a message back to the message bus. + pub fn release_message(self: *Client, message: *Message) void { + self.message_bus.unref(message); + } + + fn on_eviction(self: *Client, eviction: *const Message.Eviction) void { + assert(!self.evicted); + assert(eviction.header.command == .eviction); + assert(eviction.header.cluster == self.cluster); + + if (eviction.header.client != self.id) { + log.warn("{}: on_eviction: ignoring (wrong client={})", .{ + self.id, + eviction.header.client, + }); + return; + } + + if (eviction.header.view < self.view) { + log.debug("{}: on_eviction: ignoring (older view={})", .{ + self.id, + eviction.header.view, + }); + return; + } + + assert(eviction.header.client == self.id); + assert(eviction.header.view >= self.view); + + if (self.on_eviction_callback) |callback| { + const eviction_specific_log = switch (eviction.header.reason) { + .client_release_too_low => " - your client is too old; upgrade to a version " ++ + "compatible with your cluster", + .client_release_too_high => " - your client is too new; downgrade to the " ++ + "same version as your cluster", + else => "", + }; + log.err( + "{}: session evicted: reason={?s} (cluster_release={}, client_release={}){s}", + .{ + self.id, + std.enums.tagName(vsr.Header.Eviction.Reason, eviction.header.reason), + eviction.header.release, + self.release, + eviction_specific_log, + }, + ); + + self.evicted = true; + self.on_eviction_callback = null; + callback(self, eviction); + } else { + std.debug.panic("session evicted: {?s} (cluster_release={})", .{ + std.enums.tagName(vsr.Header.Eviction.Reason, eviction.header.reason), + eviction.header.release, + }); + } + } + + fn on_pong_client(self: *Client, pong: *const Message.PongClient) void { + assert(pong.header.command == .pong_client); + assert(pong.header.cluster == self.cluster); + + if (pong.header.view > self.view) { + log.debug("{}: on_pong: newer view={}..{}", .{ + self.id, + self.view, + pong.header.view, + }); + self.view = pong.header.view; + // Even if there is a request in flight, don't try to retransmit it immediately + // after a view change. Instead, ride the on_request_timeout normally to reduce the + // size of thundering herd. + maybe(self.request_inflight != null); + } + + const ping_timestamp_monotonic = pong.header.ping_timestamp_monotonic; + const pong_timestamp_monotonic = self.time.monotonic().ns; + if (ping_timestamp_monotonic <= pong_timestamp_monotonic) { + self.replica_round_trip_times_ns[pong.header.replica] = + pong_timestamp_monotonic - ping_timestamp_monotonic; + + var round_trip_times_ns = stdx.BoundedArrayType(u64, constants.replicas_max){}; + for (self.replica_round_trip_times_ns) |round_trip_time_ns| { + if (round_trip_time_ns) |rtt_ns| { + round_trip_times_ns.push(rtt_ns); + } + } + std.mem.sort(u64, round_trip_times_ns.slice(), {}, std.sort.asc(u64)); + assert(round_trip_times_ns.count() > 0); + + const rtt_median_ns = + round_trip_times_ns.get(@divFloor(round_trip_times_ns.count(), 2)); + self.request_timeout.set_rtt_ns(rtt_median_ns); + } else { + log.debug("{}: on_pong: monotonic timestamp regressed {}..{} replica={}", .{ + self.id, + ping_timestamp_monotonic, + pong_timestamp_monotonic, + pong.header.replica, + }); + } + } + + fn on_reply(self: *Client, reply: *Message.Reply) void { + // We check these checksums again here because this is the last time we get to downgrade + // a correctness bug into a liveness bug, before we return data back to the application. + assert(reply.header.valid_checksum()); + assert(reply.header.valid_checksum_body(reply.body_used())); + assert(reply.header.command == .reply); + assert(reply.header.release.value == self.release.value); + + if (reply.header.client != self.id) { + log.debug("{}: on_reply: ignoring (wrong client={})", .{ + self.id, + reply.header.client, + }); + return; + } + + var inflight = self.request_inflight orelse { + assert(reply.header.request < self.request_number); + log.debug("{}: on_reply: ignoring (no inflight request)", .{self.id}); + return; + }; + + if (reply.header.request < inflight.message.header.request) { + assert(inflight.message.header.request > 0); + assert(inflight.message.header.operation != .register); + + log.debug("{}: on_reply: ignoring (request {} < {})", .{ + self.id, + reply.header.request, + inflight.message.header.request, + }); + return; + } + + assert(reply.header.request == inflight.message.header.request); + assert(reply.header.request_checksum == inflight.message.header.checksum); + const inflight_vsr_operation = inflight.message.header.operation; + const inflight_request = inflight.message.header.request; + + if (inflight_vsr_operation == .register) { + assert(inflight_request == 0); + } else { + assert(inflight_request > 0); + } + // Consume the inflight request here before invoking callbacks down below in case they + // wish to queue a new `request_inflight`. + assert(inflight.message == self.request_inflight.?.message); + self.request_inflight = null; + + if (self.on_reply_callback) |on_reply_callback| { + on_reply_callback(self, inflight.message, reply); + } + + log.debug("{}: on_reply: user_data={} request={} size={} {s}", .{ + self.id, + inflight.user_data, + reply.header.request, + reply.header.size, + reply.header.operation.tag_name(Operation), + }); + + assert(reply.header.request_checksum == self.parent); + assert(reply.header.client == self.id); + assert(reply.header.request == inflight_request); + assert(reply.header.cluster == self.cluster); + assert(reply.header.op == reply.header.commit); + assert(reply.header.operation == inflight_vsr_operation); + + // The context of this reply becomes the parent of our next request: + self.parent = reply.header.context; + + if (reply.header.view > self.view) { + log.debug("{}: on_reply: newer view={}..{}", .{ + self.id, + self.view, + reply.header.view, + }); + self.view = reply.header.view; + } + + self.request_timeout.stop(); + + // Release request message to ensure that inflight's callback can submit a new one. + self.release_message(inflight.message.base()); + inflight.message = undefined; + + if (inflight_vsr_operation == .register) { + assert(inflight_request == 0); + assert(self.batch_size_limit == null); + assert(self.session == 0); + assert(reply.header.commit > 0); + assert(reply.header.size == @sizeOf(Header) + @sizeOf(vsr.RegisterResult)); + + const result = std.mem.bytesAsValue( + vsr.RegisterResult, + reply.body_used()[0..@sizeOf(vsr.RegisterResult)], + ); + assert(result.batch_size_limit > 0); + assert(result.batch_size_limit <= constants.message_body_size_max); + + self.session = reply.header.commit; // The commit number becomes the session number. + self.batch_size_limit = result.batch_size_limit; + inflight.callback.register(inflight.user_data, result); + } else { + // The message is the result of raw_request(), so invoke the user callback. + // NOTE: the callback is allowed to mutate `reply.body_used()` here. + inflight.callback.request( + inflight.user_data, + inflight_vsr_operation, + reply.header.timestamp, + reply.body_used(), + ); + } + } + + fn on_ping_timeout(self: *Client) void { + self.ping_timeout.reset(); + + const ping = Header.PingClient{ + .command = .ping_client, + .cluster = self.cluster, + .release = self.release, + .client = self.id, + .ping_timestamp_monotonic = self.time.monotonic().ns, + .session = self.session, + }; + + self.send_header_to_replicas(ping.frame_const()); + } + + // Possible reasons for a timeout: + // - the cluster is overloaded and takes too long to respond + // - the request message got dropped by the network + // - there was a view change, and we are not speaking to the primary + fn on_request_timeout(self: *Client) void { + self.request_timeout.backoff(&self.prng); // Reduce the load. + + const message = self.request_inflight.?.message; + assert(message.header.command == .request); + assert(message.header.request < self.request_number); + assert(message.header.checksum == self.parent); + assert(message.header.session == self.session); + + log.debug("{}: on_request_timeout: resending request={} checksum={x:0>32}", .{ + self.id, + message.header.request, + message.header.checksum, + }); + + self.send_request_with_hedging(message); + } + + /// The caller owns the returned message, if any, which has exactly 1 reference. + fn create_message_from_header(self: *Client, header: *const Header) *Message { + assert(header.cluster == self.cluster); + assert(header.size == @sizeOf(Header)); + + const message = self.message_bus.get_message(null); + defer self.message_bus.unref(message); + + message.header.* = header.*; + message.header.set_checksum_body(message.body_used()); + message.header.set_checksum(); + + return message.ref(); + } + + fn send_header_to_replicas(self: *Client, header: *const Header) void { + const message = self.create_message_from_header(header); + defer self.message_bus.unref(message); + + self.send_message_to_replicas(message); + } + + fn send_message_to_replicas(self: *Client, message: *Message) void { + for (0..self.replica_count) |replica| { + self.send_message_to_replica(@intCast(replica), message); + } + } + + fn send_message_to_replica(self: *Client, replica: u8, message: *Message) void { + // Switch on the header type so that we don't log opaque bytes for the per-command data. + switch (message.header.into_any()) { + inline else => |header| { + log.debug("{}: sending {s} to replica {}: {}", .{ + self.id, + @tagName(message.header.command), + replica, + header, + }); + }, + } + + assert(replica < self.replica_count); + assert(message.header.valid_checksum()); + assert(message.header.cluster == self.cluster); + + switch (message.into_any()) { + inline .request, + .ping_client, + => |m| assert(m.header.client == self.id), + else => unreachable, + } + + self.message_bus.send_message_to_replica(replica, message); + } + + // In addition to the primary, each request is also sent to a randomly chosen backup, to + // handle the case where the client → primary link is down. This ensures logical + // availability of the cluster, i.e., as long the client is connected to a backup that in + // turn is connected to the primary, the request will be processed by the cluster. + fn send_request_with_hedging(self: *Client, message: *Message.Request) void { + const primary: u8 = @intCast(self.view % self.replica_count); + self.send_message_to_replica(primary, message.base()); + + if (self.replica_count > 1) { + const offset_random = self.prng.range_inclusive(u8, 1, self.replica_count - 1); + const backup_random = (primary + offset_random) % self.replica_count; + assert(backup_random != primary); + self.send_message_to_replica(backup_random, message.base()); + } + } + + fn send_request_for_the_first_time(self: *Client, message: *Message.Request) void { + assert(self.request_inflight.?.message == message); + assert(self.request_number > 0); + + assert(message.header.command == .request); + assert(message.header.parent == 0); + assert(message.header.session == 0); + assert(message.header.request < self.request_number); + assert(message.header.view == 0); + assert(message.header.size <= constants.message_size_max); + + // We set the message checksums only when sending the request for the first time, + // which is when we have the checksum of the latest reply available to set as `parent`, + // and similarly also the session number if requests were queued while registering: + message.header.parent = self.parent; + message.header.session = self.session; + // We also try to include our highest view number, so we wait until the request is ready + // to be sent for the first time. However, beyond that, it is not necessary to update + // the view number again, for example if it should change between now and resending. + message.header.view = self.view; + message.header.set_checksum_body(message.body_used()); + message.header.set_checksum(); + + // The checksum of this request becomes the parent of our next reply: + self.parent = message.header.checksum; + + log.debug("{}: send_request_for_the_first_time: request={} checksum={x:0>32}", .{ + self.id, + message.header.request, + message.header.checksum, + }); + + assert(!self.request_timeout.ticking); + self.request_timeout.start(); + + self.send_request_with_hedging(message); + } + }; +} diff --git a/ocam/src/vsr/client_replies.zig b/ocam/src/vsr/client_replies.zig new file mode 100644 index 00000000..47a7061d --- /dev/null +++ b/ocam/src/vsr/client_replies.zig @@ -0,0 +1,532 @@ +//! Store the latest reply to every active client session. +//! +//! This allows them to be resent to the corresponding client if the client missed the original +//! reply message (e.g. dropped packet). +//! +//! - Client replies' headers are stored in the `client_sessions` trailer. +//! - Client replies (header and body) are only stored by ClientReplies in the `client_replies` zone +//! when `reply.header.size ≠ sizeOf(Header)` – that is, when the body is non-empty. +//! - Corrupt client replies can be repaired from other replicas. +//! +//! Replies are written asynchronously. Subsequent writes for the same client may be coalesced – +//! we only care about the last reply to each client session. +//! +//! ClientReplies guarantees that the latest replies are durable at checkpoint. +//! +//! If the same reply is corrupted by all replicas, the cluster is still available. +//! If the respective client also never received the reply (due to a network fault), the client may +//! be "locked out" of the cluster – continually retrying a request which has been executed, but +//! whose reply has been permanently lost. This can be resolved by the operator restarting the +//! client to create a new session. +const std = @import("std"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const log = std.log.scoped(.client_replies); + +const stdx = @import("stdx"); +const constants = @import("../constants.zig"); +const RingBufferType = stdx.RingBufferType; +const IOPSType = stdx.IOPSType; +const vsr = @import("../vsr.zig"); +const Message = @import("../message_pool.zig").MessagePool.Message; +const MessagePool = @import("../message_pool.zig").MessagePool; +const Slot = @import("client_sessions.zig").ReplySlot; +const ClientSessions = @import("client_sessions.zig").ClientSessions; + +fn slot_offset(slot: Slot) usize { + return slot.index * constants.message_size_max; +} + +// TODO Optimization: +// Don't always immediately start writing a reply. Instead, hold onto it in the hopes that +// the same client will queue another request. If they do (within the same checkpoint), +// then we no longer need to persist the original reply. +pub fn ClientRepliesType(comptime Storage: type) type { + return struct { + const ClientReplies = @This(); + + const Read = struct { + client_replies: *ClientReplies, + completion: Storage.Read, + callback: ?*const fn ( + client_replies: *ClientReplies, + reply_header: *const vsr.Header.Reply, + reply: ?*Message.Reply, + destination_replica: ?u8, + ) void, + slot: Slot, + message: *Message.Reply, + /// The header of the expected reply. + header: vsr.Header.Reply, + destination_replica: ?u8, + }; + + const Write = struct { + client_replies: *ClientReplies, + completion: Storage.Write, + slot: Slot, + message: *Message.Reply, + trigger: WriteTrigger, + }; + + const WriteTrigger = enum { commit, repair }; + + const WriteQueue = RingBufferType(*Write, .{ + .array = constants.client_replies_iops_write_max, + }); + + storage: *Storage, + message_pool: *MessagePool, + replica: u8, + + reads: IOPSType(Read, constants.client_replies_iops_read_max) = .{}, + writes: IOPSType(Write, constants.client_replies_iops_write_max) = .{}, + + /// Track which slots have a write currently in progress. + writing: stdx.BitSetType(constants.clients_max) = .{}, + /// Track which slots hold a corrupt reply, or are otherwise missing the reply + /// that ClientSessions believes they should hold. + /// + /// Invariants: + /// - Set bits must correspond to occupied slots in ClientSessions. + /// - Set bits must correspond to entries in ClientSessions with + /// `header.size > @sizeOf(vsr.Header)`. + faulty: stdx.BitSetType(constants.clients_max) = .{}, + + /// Guard against multiple concurrent writes to the same slot. + /// Pointers are into `writes`. + write_queue: WriteQueue = WriteQueue.init(), + + ready_callback: ?*const fn (*ClientReplies) void = null, + + checkpoint_next_tick: Storage.NextTick = undefined, + checkpoint_callback: ?*const fn (*ClientReplies) void = null, + + pub fn init(options: struct { + storage: *Storage, + message_pool: *MessagePool, + replica_index: u8, + }) ClientReplies { + return .{ + .storage = options.storage, + .message_pool = options.message_pool, + .replica = options.replica_index, + }; + } + + pub fn deinit(client_replies: *ClientReplies) void { + { + var it = client_replies.reads.iterate(); + while (it.next()) |read| client_replies.message_pool.unref(read.message); + } + { + var it = client_replies.writes.iterate(); + while (it.next()) |write| client_replies.message_pool.unref(write.message); + } + // Don't unref `write_queue`'s Writes — they are a subset of `writes`. + } + + /// Returns true if the reply at the given slot is durably persisted to disk. The + /// difference with `faulty` bit set is that `faulty` is cleared at the start of a write + /// when the reply is still in RAM. In contrast, `reply_durable` checks that the + /// corresponding reply hit the disk. + pub fn reply_durable( + client_replies: *const ClientReplies, + slot: Slot, + ) bool { + return !client_replies.faulty.is_set(slot.index) and + !client_replies.writing.is_set(slot.index); + } + + pub fn read_reply_sync( + client_replies: *ClientReplies, + slot: Slot, + session: *const ClientSessions.Entry, + ) ?*Message.Reply { + const client = session.header.client; + + if (!client_replies.writing.is_set(slot.index)) return null; + + var writes = client_replies.writes.iterate(); + var write_latest: ?*const Write = null; + while (writes.next()) |write| { + if (write.message.header.client == client) { + if (write_latest == null or + write_latest.?.message.header.request < write.message.header.request) + { + write_latest = write; + } + } + } + + // The reply being written to the target slot may not be for the client that we're + // looking for. For example, it may be an old reply for a different client. + maybe(write_latest == null); + + if (write_latest == null or + write_latest.?.message.header.checksum != session.header.checksum) + { + // We are writing a reply, but that's a wrong reply according to `client_sessions`. + // This happens after state sync, where we update `client_sessions` without + // waiting for the in-flight write requests to complete. + assert(client_replies.faulty.is_set(slot.index)); + return null; + } + + assert(!client_replies.faulty.is_set(slot.index)); + return write_latest.?.message; + } + + /// Caller must check read_reply_sync() first. + /// (They are split up to avoid complicated NextTick bounds.) + pub fn read_reply( + client_replies: *ClientReplies, + slot: Slot, + session: *const ClientSessions.Entry, + callback: *const fn ( + *ClientReplies, + *const vsr.Header.Reply, + ?*Message.Reply, + ?u8, + ) void, + destination_replica: ?u8, + ) error{Busy}!void { + assert(client_replies.read_reply_sync(slot, session) == null); + + const read = client_replies.reads.acquire() orelse { + log.debug("{}: read_reply: busy (client={} reply={x:0>32})", .{ + client_replies.replica, + session.header.client, + session.header.checksum, + }); + + return error.Busy; + }; + + log.debug("{}: read_reply: start (client={} reply={x:0>32})", .{ + client_replies.replica, + session.header.client, + session.header.checksum, + }); + + const message = client_replies.message_pool.get_message(.reply); + defer client_replies.message_pool.unref(message); + + read.* = .{ + .client_replies = client_replies, + .completion = undefined, + .slot = slot, + .message = message.ref(), + .callback = callback, + .header = session.header, + .destination_replica = destination_replica, + }; + + client_replies.storage.read_sectors( + read_reply_callback, + &read.completion, + message.buffer[0..vsr.sector_ceil(session.header.size)], + .client_replies, + slot_offset(slot), + ); + } + + fn read_reply_callback(completion: *Storage.Read) void { + const read: *ClientReplies.Read = @alignCast(@fieldParentPtr("completion", completion)); + const client_replies = read.client_replies; + const header = read.header; + const message = read.message; + const callback_or_null = read.callback; + const destination_replica = read.destination_replica; + + client_replies.reads.release(read); + defer client_replies.message_pool.unref(message); + + const callback = callback_or_null orelse { + log.debug("{}: read_reply: already resolved (client={} reply={x:0>32})", .{ + client_replies.replica, + header.client, + header.checksum, + }); + return; + }; + + if (!message.header.valid_checksum() or + !message.header.valid_checksum_body(message.body_used())) + { + log.warn("{}: read_reply: corrupt reply (client={} reply={x:0>32})", .{ + client_replies.replica, + header.client, + header.checksum, + }); + + callback(client_replies, &header, null, destination_replica); + return; + } + + // Possible causes: + // - The read targets an older reply. + // - The read targets a newer reply (that we haven't seen/written yet). + // - The read targets a reply that we wrote, but was misdirected. + if (message.header.checksum != header.checksum) { + log.warn("{}: read_reply: unexpected header " ++ + "(client={} reply={x:0>32} found={x:0>32})", .{ + client_replies.replica, + header.client, + header.checksum, + message.header.checksum, + }); + + callback(client_replies, &header, null, destination_replica); + return; + } + + assert(message.header.command == .reply); + assert(message.header.cluster == header.cluster); + + log.debug("{}: read_reply: done (client={} reply={x:0>32})", .{ + client_replies.replica, + header.client, + header.checksum, + }); + + callback(client_replies, &header, message, destination_replica); + } + + pub fn ready_sync(client_replies: *ClientReplies) bool { + maybe(client_replies.ready_callback == null); + assert(client_replies.writing.count() + client_replies.write_queue.count == + client_replies.writes.executing()); + + return client_replies.writes.available() > 0; + } + + /// Caller must check ready_sync() first. + /// Call `callback` when ClientReplies is able to start another write_reply(). + pub fn ready( + client_replies: *ClientReplies, + callback: *const fn (client_replies: *ClientReplies) void, + ) void { + assert(client_replies.ready_callback == null); + assert(!client_replies.ready_sync()); + assert(client_replies.writes.available() == 0); + assert(client_replies.writing.count() + client_replies.write_queue.count == + client_replies.writes.executing()); + + // ready_callback will be called the next time a write completes. + client_replies.ready_callback = callback; + } + + pub fn remove_reply(client_replies: *ClientReplies, slot: Slot) void { + maybe(client_replies.faulty.is_set(slot.index)); + + client_replies.faulty.unset(slot.index); + } + + /// The caller is responsible for ensuring that the ClientReplies is able to write + /// by calling `write_reply()` after `ready()` finishes. + pub fn write_reply( + client_replies: *ClientReplies, + slot: Slot, + message: *Message.Reply, + trigger: WriteTrigger, + ) void { + assert(client_replies.ready_sync()); + assert(client_replies.ready_callback == null); + assert(client_replies.writes.available() > 0); + maybe(client_replies.writing.is_set(slot.index)); + assert(client_replies.writing.count() + client_replies.write_queue.count == + client_replies.writes.executing()); + assert(message.header.command == .reply); + // There is never any need to write a body-less message, since the header is + // stored safely in the `client_sessions` trailer. + assert(message.header.size != @sizeOf(vsr.Header)); + + switch (trigger) { + .commit => { + assert(client_replies.checkpoint_callback == null); + maybe(client_replies.faulty.is_set(slot.index)); + }, + .repair => { + maybe(client_replies.checkpoint_callback == null); + assert(client_replies.faulty.is_set(slot.index)); + }, + } + + // Resolve any pending reads for this reply. + // If we don't do this, an earlier started read can complete with an error, and + // erroneously clobber the faulty bit. + // For simplicity, resolve the reads synchronously, instead of going through next tick + // machinery. + var reads = client_replies.reads.iterate(); + while (reads.next()) |read| { + if (read.callback == null) continue; // Already resolved. + if (read.header.checksum == message.header.checksum) { + defer read.callback = null; + + read.callback.?( + client_replies, + &read.header, + message, + read.destination_replica, + ); + } + } + + // Clear the fault *before* the write completes, not after. + // Otherwise, a replica exiting state sync might mark a reply as faulty, then the + // ClientReplies clears that bit due to an unrelated write that was already queued. + client_replies.faulty.unset(slot.index); + + const write = client_replies.writes.acquire().?; + write.* = .{ + .client_replies = client_replies, + .completion = undefined, + .message = message.ref(), + .slot = slot, + .trigger = trigger, + }; + + // If there is already a write to the same slot queued (but not started), replace it. + var write_queue = client_replies.write_queue.iterator_mutable(); + while (write_queue.next_ptr()) |queued| { + if (queued.*.slot.index == slot.index) { + client_replies.message_pool.unref(queued.*.message); + client_replies.writes.release(queued.*); + + queued.* = write; + break; + } + } else { + client_replies.write_queue.push_assume_capacity(write); + client_replies.write_reply_next(); + } + + assert(client_replies.writing.is_set(write.slot.index)); + } + + fn write_reply_next(client_replies: *ClientReplies) void { + while (client_replies.write_queue.head()) |write| { + if (client_replies.writing.is_set(write.slot.index)) return; + + const message = write.message; + _ = client_replies.write_queue.pop(); + + // Padding must be zero to ensure deterministic storage. + const size = message.header.size; + const size_ceil = vsr.sector_ceil(size); + assert(stdx.zeroed(message.buffer[size..size_ceil])); + + client_replies.writing.set(write.slot.index); + client_replies.storage.write_sectors( + write_reply_callback, + &write.completion, + message.buffer[0..size_ceil], + .client_replies, + slot_offset(write.slot), + ); + } + } + + fn write_reply_callback(completion: *Storage.Write) void { + const write: *ClientReplies.Write = @fieldParentPtr("completion", completion); + const client_replies = write.client_replies; + const message = write.message; + assert(client_replies.writing.is_set(write.slot.index)); + maybe(client_replies.faulty.is_set(write.slot.index)); + + var reads = client_replies.reads.iterate(); + while (reads.next()) |read| { + if (read.slot.index == write.slot.index) { + if (read.header.checksum == message.header.checksum) { + assert(read.callback == null); + } else { + // A read and a write can race on the slot if: + // - the write is from before the latest state sync (outdated write) + // - the read is from before the write (outdated read) + } + } + } + + log.debug("{}: write_reply: wrote (client={} request={})", .{ + client_replies.replica, + message.header.client, + message.header.request, + }); + + // Release the write *before* invoking the callback, so that if the callback checks + // .writes.available() we don't erroneously appear busy. + client_replies.writing.unset(write.slot.index); + client_replies.writes.release(write); + + client_replies.message_pool.unref(message); + client_replies.write_reply_next(); + + if (client_replies.ready_callback) |ready_callback| { + client_replies.ready_callback = null; + ready_callback(client_replies); + } + + if (client_replies.checkpoint_callback != null and + client_replies.writes_executing_by_trigger(.commit) == 0) + { + client_replies.checkpoint_done(); + } + } + + // Wait until all writes with trigger=commit are done, and then invoke the callback. + // (Writes with trigger=repair may still be in progress.) + pub fn checkpoint( + client_replies: *ClientReplies, + callback: *const fn (*ClientReplies) void, + ) void { + assert(client_replies.checkpoint_callback == null); + client_replies.checkpoint_callback = callback; + + if (client_replies.writes_executing_by_trigger(.commit) == 0) { + client_replies.storage.on_next_tick( + .vsr, + checkpoint_next_tick_callback, + &client_replies.checkpoint_next_tick, + ); + } + } + + fn checkpoint_next_tick_callback(next_tick: *Storage.NextTick) void { + const client_replies: *ClientReplies = + @alignCast(@fieldParentPtr("checkpoint_next_tick", next_tick)); + + if (client_replies.checkpoint_callback != null) { + client_replies.checkpoint_done(); + } + } + + fn checkpoint_done(client_replies: *ClientReplies) void { + assert(client_replies.writes_executing_by_trigger(.commit) == 0); + + const repairing = client_replies.writes_executing_by_trigger(.repair); + assert(client_replies.writes.executing() == repairing); + assert(client_replies.writing.count() + client_replies.write_queue.count == repairing); + + const callback = client_replies.checkpoint_callback.?; + client_replies.checkpoint_callback = null; + + callback(client_replies); + } + + fn writes_executing_by_trigger( + client_replies: *const ClientReplies, + trigger: WriteTrigger, + ) u32 { + assert(client_replies.writing.count() + client_replies.write_queue.count == + client_replies.writes.executing()); + + var writes = client_replies.writes.iterate_const(); + var writes_by_trigger: u32 = 0; + while (writes.next()) |write| { + writes_by_trigger += @intFromBool(write.trigger == trigger); + } + return writes_by_trigger; + } + }; +} diff --git a/ocam/src/vsr/client_sessions.zig b/ocam/src/vsr/client_sessions.zig new file mode 100644 index 00000000..8f1af652 --- /dev/null +++ b/ocam/src/vsr/client_sessions.zig @@ -0,0 +1,338 @@ +const std = @import("std"); +const assert = std.debug.assert; +const mem = std.mem; + +const constants = @import("../constants.zig"); +const vsr = @import("../vsr.zig"); +const stdx = @import("stdx"); + +/// There is a slot corresponding to every active client (i.e. a total of clients_max slots). +pub const ReplySlot = struct { index: usize }; + +/// Track the headers of the latest reply for each active client. +/// Serialized/deserialized to/from the trailer on-disk. +/// For the reply bodies, see ClientReplies. +pub const ClientSessions = struct { + /// We found two bugs in the VRR paper relating to the client table: + /// + /// 1. a correctness bug, where successive client crashes may cause request numbers to collide + /// for different request payloads, resulting in requests receiving the wrong reply, and + /// + /// 2. a liveness bug, where if the client table is updated for request and prepare messages + /// with the client's latest request number, then the client may be locked out from the cluster + /// if the request is ever reordered through a view change. + /// + /// We therefore take a different approach with the implementation of our client table, to: + /// + /// 1. register client sessions explicitly through the state machine to ensure that + /// session numbers always increase, and + /// + /// 2. make a more careful distinction between uncommitted and committed request numbers, + /// considering that uncommitted requests may not survive a view change. + pub const Entry = struct { + /// The client's session number as committed to the cluster by a register request. + session: u64, + + /// The header of the reply corresponding to the client's latest committed request. + header: vsr.Header.Reply, + }; + + /// Values are indexes into `entries`. + const EntriesByClient = std.AutoHashMapUnmanaged(u128, usize); + + const EntriesPresent = stdx.BitSetType(constants.clients_max); + + /// Free entries are zeroed, both in `entries` and on-disk. + entries: []Entry, + entries_by_client: EntriesByClient, + entries_present: EntriesPresent = .{}, + + pub fn init(allocator: mem.Allocator) !ClientSessions { + var entries_by_client: EntriesByClient = .{}; + errdefer entries_by_client.deinit(allocator); + + try entries_by_client.ensureTotalCapacity(allocator, @intCast(constants.clients_max)); + assert(entries_by_client.capacity() >= constants.clients_max); + + const entries = try allocator.alloc(Entry, constants.clients_max); + errdefer allocator.free(entries); + @memset(entries, std.mem.zeroes(Entry)); + + return ClientSessions{ + .entries_by_client = entries_by_client, + .entries = entries, + }; + } + + pub fn deinit(client_sessions: *ClientSessions, allocator: mem.Allocator) void { + client_sessions.entries_by_client.deinit(allocator); + allocator.free(client_sessions.entries); + } + + pub fn reset(client_sessions: *ClientSessions) void { + @memset(client_sessions.entries, std.mem.zeroes(Entry)); + client_sessions.entries_by_client.clearRetainingCapacity(); + client_sessions.entries_present = .{}; + } + + /// Size of the buffer needed to encode the client sessions on disk. + /// (Not rounded up to a sector boundary). + pub const encode_size = blk: { + var size_max: usize = 0; + + // First goes the vsr headers for the entries. + // This takes advantage of the buffer alignment to avoid adding padding for the headers. + assert(@alignOf(vsr.Header) == 16); + size_max = std.mem.alignForward(usize, size_max, 16); + size_max += @sizeOf(vsr.Header) * constants.clients_max; + + // Then follows the session values for the entries. + assert(@alignOf(u64) == 8); + size_max = std.mem.alignForward(usize, size_max, 8); + size_max += @sizeOf(u64) * constants.clients_max; + + // For encoding/decoding simplicity, the ClientSessions always fits in a single block. + assert(size_max <= constants.block_size - @sizeOf(vsr.Header)); + + break :blk size_max; + }; + + pub fn encode( + client_sessions: *const ClientSessions, + target: []align(@alignOf(vsr.Header)) u8, + ) u64 { + assert(target.len >= encode_size); + + var size: u64 = 0; + + // Write all headers: + comptime assert(@alignOf(vsr.Header) == 16); + var new_size = std.mem.alignForward(usize, size, @alignOf(vsr.Header)); + @memset(target[size..new_size], 0); + size = new_size; + + for (client_sessions.entries) |*entry| { + stdx.copy_disjoint(.inexact, u8, target[size..], mem.asBytes(&entry.header)); + size += @sizeOf(vsr.Header); + } + + // Write all sessions: + comptime assert(@alignOf(u64) == 8); + new_size = std.mem.alignForward(usize, size, @alignOf(u64)); + @memset(target[size..new_size], 0); + size = new_size; + + for (client_sessions.entries) |*entry| { + stdx.copy_disjoint(.inexact, u8, target[size..], mem.asBytes(&entry.session)); + size += @sizeOf(u64); + } + + assert(size == encode_size); + return size; + } + + pub fn decode( + client_sessions: *ClientSessions, + source: []align(@alignOf(vsr.Header)) const u8, + ) void { + assert(client_sessions.count() == 0); + assert(client_sessions.entries_present.empty()); + for (client_sessions.entries) |*entry| { + assert(entry.session == 0); + assert(stdx.zeroed(std.mem.asBytes(&entry.header))); + } + + var size: u64 = 0; + assert(source.len > 0); + assert(source.len <= encode_size); + + comptime assert(@alignOf(vsr.Header) == 16); + size = std.mem.alignForward(usize, size, @alignOf(vsr.Header)); + const headers: []const vsr.Header.Reply = @alignCast(mem.bytesAsSlice( + vsr.Header.Reply, + source[size..][0 .. constants.clients_max * @sizeOf(vsr.Header)], + )); + size += mem.sliceAsBytes(headers).len; + + comptime assert(@alignOf(u64) == 8); + size = std.mem.alignForward(usize, size, @alignOf(u64)); + const sessions = mem.bytesAsSlice( + u64, + source[size..][0 .. constants.clients_max * @sizeOf(u64)], + ); + size += mem.sliceAsBytes(sessions).len; + + assert(size == encode_size); + + for (headers, 0..) |*header, i| { + const session = sessions[i]; + if (session == 0) { + assert(stdx.zeroed(std.mem.asBytes(header))); + } else { + assert(header.valid_checksum()); + assert(header.command == .reply); + assert(header.commit >= session); + + client_sessions.entries_by_client.putAssumeCapacityNoClobber(header.client, i); + client_sessions.entries_present.set(i); + client_sessions.entries[i] = .{ + .session = session, + .header = header.*, + }; + } + } + + assert( + client_sessions.entries_present.count() == client_sessions.entries_by_client.count(), + ); + } + + pub fn count(client_sessions: *const ClientSessions) usize { + return client_sessions.entries_by_client.count(); + } + + pub fn capacity(client_sessions: *const ClientSessions) usize { + _ = client_sessions; + return constants.clients_max; + } + + pub fn get(client_sessions: *ClientSessions, client: u128) ?*Entry { + const entry_index = client_sessions.entries_by_client.get(client) orelse return null; + const entry = &client_sessions.entries[entry_index]; + assert(entry.session != 0); + assert(entry.header.command == .reply); + assert(entry.header.client == client); + return entry; + } + + pub fn get_slot_for_client(client_sessions: *const ClientSessions, client: u128) ?ReplySlot { + const index = client_sessions.entries_by_client.get(client) orelse return null; + return ReplySlot{ .index = index }; + } + + pub fn get_slot_for_header( + client_sessions: *const ClientSessions, + header: *const vsr.Header.Reply, + ) ?ReplySlot { + if (client_sessions.entries_by_client.get(header.client)) |entry_index| { + const entry = &client_sessions.entries[entry_index]; + if (entry.header.checksum == header.checksum) { + return ReplySlot{ .index = entry_index }; + } + } + return null; + } + + /// If the entry is from a newly-registered client, the caller is responsible for ensuring + /// the ClientSessions has available capacity. + pub fn put( + client_sessions: *ClientSessions, + session: u64, + header: *const vsr.Header.Reply, + ) ReplySlot { + assert(session != 0); + assert(header.command == .reply); + const client = header.client; + + defer assert(client_sessions.entries_by_client.contains(client)); + + const entry_gop = client_sessions.entries_by_client.getOrPutAssumeCapacity(client); + if (entry_gop.found_existing) { + const entry_index = entry_gop.value_ptr.*; + assert(client_sessions.entries_present.is_set(entry_index)); + + const existing = &client_sessions.entries[entry_index]; + assert(existing.session == session); + assert(existing.header.cluster == header.cluster); + assert(existing.header.client == header.client); + assert(existing.header.commit < header.commit); + + existing.header = header.*; + return ReplySlot{ .index = entry_index }; + } else { + const entry_index = client_sessions.entries_present.first_unset().?; + client_sessions.entries_present.set(entry_index); + + const e = &client_sessions.entries[entry_index]; + assert(e.session == 0); + + entry_gop.value_ptr.* = entry_index; + e.session = session; + e.header = header.*; + return ReplySlot{ .index = entry_index }; + } + } + + /// For correctness, it's critical that all replicas evict deterministically: + /// We cannot depend on `HashMap.capacity()` since `HashMap.ensureTotalCapacity()` may + /// change across versions of the Zig std lib. We therefore rely on + /// `constants.clients_max`, which must be the same across all replicas, and must not + /// change after initializing a cluster. + /// We also do not depend on `HashMap.valueIterator()` being deterministic here. However, + /// we do require that all entries have different commit numbers and are iterated. + /// This ensures that we will always pick the entry with the oldest commit number. + /// We also check that a client has only one entry in the hash map (or it's buggy). + pub fn evictee(client_sessions: *const ClientSessions) u128 { + assert(client_sessions.entries_present.full()); + assert(client_sessions.count() == constants.clients_max); + + var evictee_: ?*const vsr.Header.Reply = null; + var iterated: usize = 0; + var entries = client_sessions.iterator(); + while (entries.next()) |entry| : (iterated += 1) { + assert(entry.header.command == .reply); + assert(entry.header.op == entry.header.commit); + assert(entry.header.commit >= entry.session); + + if (evictee_) |evictee_reply| { + assert(entry.header.client != evictee_reply.client); + assert(entry.header.commit != evictee_reply.commit); + + if (entry.header.commit < evictee_reply.commit) { + evictee_ = &entry.header; + } + } else { + evictee_ = &entry.header; + } + } + assert(iterated == constants.clients_max); + + return evictee_.?.client; + } + + pub fn remove(client_sessions: *ClientSessions, client: u128) void { + const entry_index = client_sessions.entries_by_client.fetchRemove(client).?.value; + + assert(client_sessions.entries_present.is_set(entry_index)); + client_sessions.entries_present.unset(entry_index); + + assert(client_sessions.entries[entry_index].header.client == client); + client_sessions.entries[entry_index] = std.mem.zeroes(Entry); + + assert(!client_sessions.entries_by_client.contains(client)); + } + + pub const Iterator = struct { + client_sessions: *const ClientSessions, + index: usize = 0, + + pub fn next(it: *Iterator) ?*const Entry { + while (it.index < it.client_sessions.entries.len) { + defer it.index += 1; + + const entry = &it.client_sessions.entries[it.index]; + if (entry.session == 0) { + assert(!it.client_sessions.entries_present.is_set(it.index)); + } else { + assert(it.client_sessions.entries_present.is_set(it.index)); + return entry; + } + } + return null; + } + }; + + pub fn iterator(client_sessions: *const ClientSessions) Iterator { + return .{ .client_sessions = client_sessions }; + } +}; diff --git a/ocam/src/vsr/clock.zig b/ocam/src/vsr/clock.zig new file mode 100644 index 00000000..f302b2a2 --- /dev/null +++ b/ocam/src/vsr/clock.zig @@ -0,0 +1,1026 @@ +//! Cluster-wide synchronized clock, aggregating timing information from all replicas. +//! +//! Time plays a central role in TigerBeetle data model. Because it is so important, TigerBeetle +//! defines its own time. In other words, we don't use time to drive consensus, we use consensus to +//! drive time! +//! +//! Time is important for the domain of accounting (e.g., pending transfers can expire with time), +//! but it can't be supplied by the client, as its clock can be unreliable. For this reason, +//! TigerBeetle needs to expose a "time service" to the state machine logic. +//! +//! Additionally, TigerBeetle needs to assign some kind of a sequence number to every event in the +//! system, to make it easy to say whether A happened before B or vice versa. +//! +//! Finally, to maintain indices, the LSM tree could benefit from a compact synthetic primary key. +//! +//! Time solves _all_ of these problems at once: each object in TigerBeetle gets tagged with a u64 +//! nanosecond-precision creation timestamp. These timestamps are unique across all objects (an +//! Account and a Transfer can never have the same timestamp), consistent with linearization order +//! of the events (earlier events get smaller timestamps), and closely match the real wall-clock +//! time. Timestamps are used as internal synthetic primary keys instead of user-supplied random +//! u128 ids because they are smaller and also expose temporal locality. +//! +//! Implementation: +//! +//! The ultimate source of timestamps is each replica's operating system. This time is backed by a +//! replica-local drifty hardware clock which is periodically synchronized through NTP with high +//! quality clocks elsewhere. Using system time directly as a source of TigerBeetle timestamps +//! doesn't work: +//! +//! First, system time differs across replicas. To solve this problem, only the primary assigns +//! timestamps. Specifically, when the primary converts a request to a prepare, it assigns its +//! current time to the prepare. The state machine then assigns `prepare_timestamp + object_index` +//! as the creation timestamp for each object in a batch. +//! +//! Second, system time is not monotonic: due to NTP it can easily go backwards. To solve this +//! problem, the primary just takes the max between the current time and the previous timestamp +//! used. Notably, this ends up preserving monotonicity across restarts --- it is when replaying +//! past prepares from the WAL that a replica learns about the latest timestamp before restart. +//! +//! Third, replica's system time lacks high availability: if a primary is isolated from NTP servers +//! its local clock can drift significantly. Another problematic scenario is an operator error +//! which incorrectly adjusts primary's local clock to be far in the future, which, due to +//! monotonicity requirement, could render the cluster completely unusable. +//! +//! To solve the last problem, the primary aggregates clock information from the entire cluster and +//! calculates a timestamp value which is consistent with clocks on at least half of the replicas. +//! +//! Sketch of the algorithm: +//! +//! Assume you have six different clocks. Each clock shows a different time. Most are close, but +//! there could be outliers. How do you estimate the "true" time? +//! +//! The key insight is to think in intervals, rather than points. If a clock shows time t and +//! claims error margin Δ, it means the true time is in the [t-Δ;t+Δ] interval. If you have two +//! clocks, you can intersect their intervals to narrow down the true time interval. If the +//! intervals are disjoint, that means that at least one of the clocks is malfunctioning. This gives +//! an algorithm for identifying cluster time --- collect clock measurements from all replicas +//! together with the respective error margins and find an interval which is consistent with at +//! least half of the clocks. +//! +//! The first problem with the above plan is that clocks' error margins are not known. To solve +//! this, flip the problem around and find the smallest error margin that still allows for half of +//! the clocks' intervals to intersect. If this minimal error margin still ends up too large, +//! declare that the clocks are unsynchronized and wait for NTP to fix things up. +//! +//! The second problem with the plan is that a replica can only read its own clock. To learn other +//! replica's clock, the following algorithm is used: +//! +//! - A sends a ping message to B, including A's current time. +//! - B replies with a pong message, which includes a copy of the original ping timestamp, as well +//! as B's current time. +//! - When A receives a pong, it uses the attached ping time to estimate the network delay and infer +//! the clock offset from that. +//! +//! Further reading: +//! +//! [Three Clocks are Better than One](https://tigerbeetle.com/blog/2021-08-30-three-clocks-are-better-than-one) +//! +//! And watching: +//! +//! [Detecting Clock Sync Failure in Highly Available Systems](https://youtu.be/7R-Iz6sJG6Q?si=9sD2TpfD29AxUjOY) +const std = @import("std"); +const assert = std.debug.assert; +const fmt = std.fmt; + +const stdx = @import("stdx"); +const log = stdx.log.scoped(.clock); +const constants = @import("../constants.zig"); +const ratio = stdx.PRNG.ratio; +const Instant = stdx.Instant; +const Time = @import("../time.zig").Time; +const TimeSim = @import("../testing/time.zig").TimeSim; +const Tracer = @import("../trace.zig").Tracer; + +const clock_offset_tolerance_max: u64 = constants.clock_offset_tolerance_max.ns; +const epoch_max: u64 = constants.clock_epoch_max.ns; +const window_min: u64 = constants.clock_synchronization_window_min.ns; +const window_max: u64 = constants.clock_synchronization_window_max.ns; + +const Marzullo = @import("marzullo.zig").Marzullo; + +pub const Clock = @This(); + +const Sample = struct { + /// The relative difference between our wall clock reading and that of the remote clock source. + clock_offset: i64, + one_way_delay: u64, +}; + +const Epoch = struct { + /// The best clock offset sample per remote clock source (with minimum one way delay) collected + /// over the course of a window period of several seconds. + sources: []?Sample, + + /// The total number of samples learned while synchronizing this epoch. + samples: usize, + + /// The monotonic clock timestamp when this epoch began. We use this to measure elapsed time. + monotonic: u64, + + /// The wall clock timestamp when this epoch began. We add the elapsed monotonic time to this + /// plus the synchronized clock offset to arrive at a synchronized realtime timestamp. We + /// capture this realtime when starting the epoch, before we take any samples, to guard against + /// any jumps in the system's realtime clock from impacting our measurements. + realtime: i64, + + /// Once we have enough source clock offset samples in agreement, the epoch is / synchronized. + /// We then have lower and upper bounds on the true cluster time, and can / install this epoch + /// for subsequent clock readings. This epoch is then valid for / several seconds, while clock + /// drift has not had enough time to accumulate into any / significant clock skew, and while we + /// collect samples for the next epoch to refresh / and replace this one. + synchronized: ?Marzullo.Interval, + + /// A guard to prevent synchronizing too often without having learned any new samples. + learned: bool = false, + + fn elapsed(epoch: *Epoch, clock: *Clock) u64 { + return clock.monotonic().ns - epoch.monotonic; + } + + fn reset(epoch: *Epoch, clock: *Clock) void { + @memset(epoch.sources, null); + // A replica always has zero clock offset and network delay to its own system time + // reading: + epoch.sources[clock.replica] = Sample{ + .clock_offset = 0, + .one_way_delay = 0, + }; + epoch.samples = 1; + epoch.monotonic = clock.monotonic().ns; + epoch.realtime = clock.realtime(); + epoch.synchronized = null; + epoch.learned = false; + } + + fn sources_sampled(epoch: *Epoch) usize { + var count: usize = 0; + for (epoch.sources) |sampled| { + if (sampled != null) count += 1; + } + return count; + } +}; + +/// The index of the replica using this clock to provide synchronized time. +replica: u8, +/// Minimal number of distinct clock sources required for synchronization. +quorum: u8, + +/// The underlying time source for this clock (system time or deterministic time). +time: Time, + +/// An epoch from which the clock can read synchronized clock timestamps within safe bounds. +/// At least `constants.clock_synchronization_window_min` is needed for this to be ready to use. +epoch: Epoch, + +/// The next epoch (collecting samples and being synchronized) to replace the current epoch. +window: Epoch, + +/// A static allocation to convert window samples into tuple bounds for Marzullo's +/// algorithm. +marzullo_tuples: []Marzullo.Tuple, + +/// A kill switch to revert to unsynchronized realtime. +synchronization_disabled: bool, + +trace: ?*Tracer, + +pub fn init( + allocator: std.mem.Allocator, + time: Time, + tracer: ?*Tracer, + options: struct { + /// The size of the cluster, i.e. the number of clock sources (including this + /// replica). + replica_count: u8, + replica: u8, + quorum: u8, + }, +) !Clock { + assert(options.replica_count > 0); + assert(options.replica < options.replica_count); + assert(options.quorum > 0); + assert(options.quorum <= options.replica_count); + if (options.replica_count > 1) assert(options.quorum > 1); + + var epoch: Epoch = undefined; + epoch.sources = try allocator.alloc(?Sample, options.replica_count); + errdefer allocator.free(epoch.sources); + + var window: Epoch = undefined; + window.sources = try allocator.alloc(?Sample, options.replica_count); + errdefer allocator.free(window.sources); + + // There are two Marzullo tuple bounds (lower and upper) per source clock offset sample: + const marzullo_tuples = try allocator.alloc(Marzullo.Tuple, options.replica_count * 2); + errdefer allocator.free(marzullo_tuples); + + var self = Clock{ + .replica = options.replica, + .quorum = options.quorum, + .time = time, + .epoch = epoch, + .window = window, + .marzullo_tuples = marzullo_tuples, + // A cluster of one cannot synchronize. + .synchronization_disabled = options.replica_count == 1, + + .trace = tracer, + }; + + // Reset the current epoch to be unsynchronized, + self.epoch.reset(&self); + // and open a new epoch window to start collecting samples... + self.window.reset(&self); + + return self; +} + +pub fn deinit(self: *Clock, allocator: std.mem.Allocator) void { + allocator.free(self.epoch.sources); + allocator.free(self.window.sources); + allocator.free(self.marzullo_tuples); +} + +/// Called by `Replica.on_pong()` with: +/// * the index of the `replica` that has replied to our ping with a pong, +/// * our monotonic timestamp `m0` embedded in the ping we sent, carried over into this pong, +/// * the remote replica's `realtime()` timestamp `t1`, and +/// * our monotonic timestamp `m2` as captured by our `Replica.on_pong()` handler. +pub fn learn(self: *Clock, replica: u8, m0: u64, t1: i64, m2: u64) void { + assert(replica != self.replica); + + if (self.synchronization_disabled) return; + + // Our m0 and m2 readings should always be monotonically increasing if not equal. + // Crucially, it is possible for a very fast network to have m0 == m2, especially where + // `constants.tick_ms` is at a more course granularity. We must therefore tolerate RTT=0 or + // otherwise we would have a liveness bug simply because we would be throwing away perfectly + // good clock samples. + // This condition should never be true. Reject this as a bad sample: + if (m0 > m2) { + log.warn("{}: learn: m0={} > m2={}", .{ self.replica, m0, m2 }); + return; + } + + // The window was reset between a ping and the corresponding pong. + if (m0 < self.window.monotonic) { + log.debug("{}: learn: m0={} < window.monotonic={}", .{ + self.replica, + m0, + self.window.monotonic, + }); + return; + } + assert(m2 >= self.window.monotonic); // Guaranteed by monotonicity of our local Time. + + const elapsed: u64 = m2 - self.window.monotonic; + if (elapsed > window_max) { + log.warn("{}: learn: elapsed={} > window_max={}", .{ + self.replica, + elapsed, + window_max, + }); + return; + } + + const round_trip_time: u64 = m2 - m0; + const one_way_delay: u64 = round_trip_time / 2; + const t2: i64 = self.window.realtime + @as(i64, @intCast(elapsed)); + const clock_offset: i64 = t1 + @as(i64, @intCast(one_way_delay)) - t2; + const asymmetric_delay = self.estimate_asymmetric_delay( + replica, + one_way_delay, + clock_offset, + ); + const clock_offset_corrected = clock_offset + asymmetric_delay; + + log.debug("{}: learn: replica={} m0={} t1={} m2={} t2={} one_way_delay={} " ++ + "asymmetric_delay={} clock_offset={}", .{ + self.replica, + replica, + m0, + t1, + m2, + t2, + one_way_delay, + asymmetric_delay, + clock_offset_corrected, + }); + + // The less network delay, the more likely we have an accurate clock offset measurement: + self.window.sources[replica] = minimum_one_way_delay( + self.window.sources[replica], + Sample{ + .clock_offset = clock_offset_corrected, + .one_way_delay = one_way_delay, + }, + ); + + self.window.samples += 1; + + // We decouple calls to `synchronize()` so that it's not triggered by these network events. + // Otherwise, excessive duplicate network packets would burn the CPU. + self.window.learned = true; +} + +/// Called by `Replica.on_ping_timeout()` to provide `m0` when we decide to send a ping. +/// Called by `Replica.on_pong()` to provide `m2` when we receive a pong. +/// Called by `Replica.on_commit_message_timeout()` to allow backups to discard +/// duplicate/misdirected heartbeats. +pub fn monotonic(self: *Clock) Instant { + return self.time.monotonic(); +} + +/// Called by `Replica.on_ping()` when responding to a ping with a pong. +/// This should never be used by the state machine, only for measuring clock offsets. +pub fn realtime(self: *Clock) i64 { + return self.time.realtime(); +} + +/// Called by `Replica.on_request()` when the primary wants to timestamp a batch. If the primary's +/// clock is not synchronized with the cluster, it must wait until it is. +/// Returns the system time clamped to be within our synchronized lower and upper bounds. +/// This is complementary to NTP and allows clusters with very accurate time to make use of it, +/// while providing guard rails for when NTP is partitioned or unable to correct quickly enough. +pub fn realtime_synchronized(self: *Clock) ?i64 { + if (self.synchronization_disabled) { + return self.realtime(); + } else if (self.epoch.synchronized) |interval| { + const elapsed = @as(i64, @intCast(self.epoch.elapsed(self))); + return std.math.clamp( + self.realtime(), + self.epoch.realtime + elapsed + interval.lower_bound, + self.epoch.realtime + elapsed + interval.upper_bound, + ); + } else { + return null; + } +} + +pub fn round_trip_time_median_ns(self: *const Clock) ?u64 { + // +1 to allow for the standby. + var one_way_delays = stdx.BoundedArrayType(u64, constants.replicas_max + 1){}; + for (self.window.sources, 0..) |source, replica_index| { + if (self.replica != replica_index) { + if (source) |sampled| { + one_way_delays.push(sampled.one_way_delay); + } + } + } + + if (one_way_delays.count() < self.quorum) { + return null; + } else { + std.mem.sort(u64, one_way_delays.slice(), {}, std.sort.asc(u64)); + const one_way_delay_median = + one_way_delays.get(@divFloor(one_way_delays.count(), 2)); + return one_way_delay_median * 2; + } +} + +pub fn tick(self: *Clock) void { + self.time.tick(); + + if (self.synchronization_disabled) return; + self.synchronize(); + // Expire the current epoch if successive windows failed to synchronize: + // Gradual clock drift prevents us from using an epoch for more than a few seconds. + if (self.epoch.elapsed(self) >= epoch_max) { + log.err( + "{}: no agreement on cluster time (partitioned or too many clock faults)", + .{self.replica}, + ); + self.epoch.reset(self); + } +} + +/// Estimates the asymmetric delay for a sample compared to the previous window, according to +/// Algorithm 1 from Section 4.2, +/// "A System for Clock Synchronization in an Internet of Things". +/// +/// Note that it is impossible to estimate persistent asymmetric delay, as these two situations are +/// indistinguishable: +/// - A and B have synchronized clocks and a 50ms symmetrical delay. +/// - B's clock is 50ms ahead, A → B delay is 0ms, B → A delay is 100ms. +/// +/// In both of these cases, A and B observe that a ping-pong round trip takes 100ms and that +/// a pong's timestamp is 50ms ahead of ping's timestamp. +/// +/// Instead, the model here is of a one-time delay --- a particular ping or pong message got delayed +/// because it had a large prepare message in front of it in the send queue, a network packet got +/// lost, or a pigeon got eaten by a cat. +/// +/// The delay happened either for the ping (forward path) or for the pong (reverse path) message. +/// Assuming that the minimum RTT seen before is a no-delay situation, the magnitude of a delay for +/// the current sample can be estimated as RTT - min(RTT), and the direction (forward/reverse) +/// distinguished by comparing unadjusted clock offsets. +/// +/// Previous window is used to determine min(RTT). +fn estimate_asymmetric_delay( + self: *Clock, + replica: u8, + one_way_delay: u64, + clock_offset: i64, +) i64 { + // Note that `one_way_delay` may be 0 for very fast networks. + + const error_margin = 10 * std.time.ns_per_ms; + + if (self.epoch.sources[replica]) |epoch| { + if (one_way_delay <= epoch.one_way_delay) { + return 0; + } else if (clock_offset > epoch.clock_offset + error_margin) { + // The asymmetric error is on the forward network path. + return 0 - @as(i64, @intCast(one_way_delay - epoch.one_way_delay)); + } else if (clock_offset < epoch.clock_offset - error_margin) { + // The asymmetric error is on the reverse network path. + return 0 + @as(i64, @intCast(one_way_delay - epoch.one_way_delay)); + } else { + return 0; + } + } else { + return 0; + } +} + +fn synchronize(self: *Clock) void { + assert(self.window.synchronized == null); + + // Wait until the window has enough accurate samples: + const elapsed = self.window.elapsed(self); + if (elapsed < window_min) return; + if (elapsed >= window_max) { + // We took too long to synchronize the window, expire stale samples... + const sources_sampled = self.window.sources_sampled(); + if (sources_sampled <= @divTrunc(self.window.sources.len, 2)) { + log.warn("{}: synchronization failed, partitioned (sources={} samples={})", .{ + self.replica, + sources_sampled, + self.window.samples, + }); + } else { + log.warn("{}: synchronization failed, no agreement (sources={} samples={})", .{ + self.replica, + sources_sampled, + self.window.samples, + }); + } + self.window.reset(self); + return; + } + + if (!self.window.learned) return; + // Do not reset `learned` any earlier than this (before we have attempted to synchronize). + self.window.learned = false; + + // Starting with the most clock offset tolerance, while we have a quorum, find the best smallest + // interval with the least clock offset tolerance, reducing tolerance at each step: + var tolerance: u64 = clock_offset_tolerance_max; + var terminate = false; + var rounds: usize = 0; + // Do at least one round if tolerance=0 and cap the number of rounds to avoid runaway loops. + while (!terminate and rounds < 64) : (tolerance /= 2) { + if (tolerance == 0) terminate = true; + rounds += 1; + + const interval = Marzullo.smallest_interval(self.window_tuples(tolerance)); + if (interval.sources_true < self.quorum) break; + + // The new interval may reduce the number of `sources_true` while also decreasing error. In + // other words, provided we maintain a quorum, we prefer tighter tolerance bounds. + self.window.synchronized = interval; + } + + // Wait for more accurate samples or until we timeout the window for lack of quorum: + if (self.window.synchronized == null) return; + + // Transitioning from not being synchronized to being synchronized - log out a message for the + // operator, as the counterpoint to `no agreement on cluster time`. + if (self.epoch.synchronized == null and self.window.synchronized != null) { + const new_interval = self.window.synchronized.?; + log.info("{}: synchronized: accuracy={}", .{ + self.replica, + fmt.fmtDurationSigned(new_interval.upper_bound - new_interval.lower_bound), + }); + } + + var new_window = self.epoch; + new_window.reset(self); + self.epoch = self.window; + self.window = new_window; + + self.after_synchronization(); +} + +fn after_synchronization(self: *Clock) void { + const new_interval = self.epoch.synchronized.?; + + log.debug("{}: synchronized: truechimers={}/{} clock_offset={}..{} accuracy={}", .{ + self.replica, + new_interval.sources_true, + self.epoch.sources.len, + fmt.fmtDurationSigned(new_interval.lower_bound), + fmt.fmtDurationSigned(new_interval.upper_bound), + fmt.fmtDurationSigned(new_interval.upper_bound - new_interval.lower_bound), + }); + + const elapsed: i64 = @intCast(self.epoch.elapsed(self)); + const system = self.realtime(); + const lower = self.epoch.realtime + elapsed + new_interval.lower_bound; + const upper = self.epoch.realtime + elapsed + new_interval.upper_bound; + const cluster = std.math.clamp(system, lower, upper); + + // The only current hard limit on what the clock skew can actually be is from + // `clock_offset_tolerance_max`. + // + // Warn at 50ms, since that's a reasonable amount of NTP clock skew, and ensure that 50ms is a + // reasonable (sub 1%) portion of `clock_offset_tolerance_max`. + const delta_warning = 50 * std.time.ns_per_ms; + comptime assert(delta_warning < @divFloor(clock_offset_tolerance_max, 100)); + + if (system == cluster) {} else if (system < lower) { + const delta = lower - system; + if (self.trace) |trace| trace.gauge(.clock_delta_ns, delta); + + if (delta < delta_warning) { + log.debug("{}: system time is {} behind", .{ + self.replica, + fmt.fmtDurationSigned(delta), + }); + } else { + log.warn( + "{}: system time is {} behind, clamping system time to cluster time", + .{ + self.replica, + fmt.fmtDurationSigned(delta), + }, + ); + } + } else { + const delta = system - upper; + if (self.trace) |trace| trace.gauge(.clock_delta_ns, delta); + + if (delta < delta_warning) { + log.debug("{}: system time is {} ahead", .{ + self.replica, + fmt.fmtDurationSigned(delta), + }); + } else { + log.warn("{}: system time is {} ahead, clamping system time to cluster time", .{ + self.replica, + fmt.fmtDurationSigned(delta), + }); + } + } +} + +fn window_tuples(self: *Clock, tolerance: u64) []Marzullo.Tuple { + assert(self.window.sources[self.replica].?.clock_offset == 0); + assert(self.window.sources[self.replica].?.one_way_delay == 0); + var count: usize = 0; + for (self.window.sources, 0..) |sampled, source| { + if (sampled) |sample| { + self.marzullo_tuples[count] = Marzullo.Tuple{ + .source = @intCast(source), + .offset = sample.clock_offset - + @as(i64, @intCast(sample.one_way_delay + tolerance)), + .bound = .lower, + }; + count += 1; + self.marzullo_tuples[count] = Marzullo.Tuple{ + .source = @intCast(source), + .offset = sample.clock_offset + + @as(i64, @intCast(sample.one_way_delay + tolerance)), + .bound = .upper, + }; + count += 1; + } + } + return self.marzullo_tuples[0..count]; +} + +fn minimum_one_way_delay(a: ?Sample, b: ?Sample) ?Sample { + if (a == null) return b; + if (b == null) return a; + if (a.?.one_way_delay < b.?.one_way_delay) return a; + // Choose B if B's one way delay is less or the same (we assume B is the newer sample): + return b; +} + +const testing = std.testing; +const OffsetType = @import("../testing/time.zig").OffsetType; + +const ClockUnitTestContainer = struct { + time: TimeSim, + clock: Clock, + rtt: u64 = 300 * std.time.ns_per_ms, + owd: u64 = 150 * std.time.ns_per_ms, + learn_interval: u64 = 5, + + pub fn init( + self: *ClockUnitTestContainer, + allocator: std.mem.Allocator, + offset_type: OffsetType, + offset_coefficient_A: i64, + offset_coefficient_B: i64, + ) !void { + self.* = .{ + .time = .{ + .resolution = std.time.ns_per_s / 2, + .offset_type = offset_type, + .offset_coefficient_A = offset_coefficient_A, + .offset_coefficient_B = offset_coefficient_B, + }, + .clock = try Clock.init(allocator, self.time.time(), null, .{ + .replica_count = 3, + .replica = 0, + .quorum = 2, + }), + }; + } + + pub fn run_till_tick(self: *ClockUnitTestContainer, tick_stop: u64) void { + while (self.time.ticks < tick_stop) { + self.clock.time.tick(); + + if (@mod(self.time.ticks, self.learn_interval) == 0) { + const on_pong_time = self.clock.monotonic().ns; + const m0 = on_pong_time - self.rtt; + const t1: i64 = @intCast(on_pong_time - self.owd); + + self.clock.learn(1, m0, t1, on_pong_time); + self.clock.learn(2, m0, t1, on_pong_time); + } + + self.clock.synchronize(); + } + } + + const AssertionPoint = struct { + tick: u64, + expected_offset: i64, + }; + pub fn ticks_to_perform_assertions(self: *ClockUnitTestContainer) [3]AssertionPoint { + var ret: [3]AssertionPoint = undefined; + switch (self.time.offset_type) { + .linear => { + // For the first (OWD/drift per tick) ticks, the offset < OWD. This means that the + // Marzullo interval is [0,0] (the offset and OWD are 0 for a replica w.r.t. + // itself). Therefore the offset of `clock.realtime_synchronised` will be the + // analytically prescribed offset at the start of the window. + // Beyond this, the offset > OWD and the Marzullo interval will be from replica 1 + // and replica 2. The `clock.realtime_synchronized` will be clamped to the lower + // bound. Therefore the `clock.realtime_synchronized` will be offset by the OWD. + const threshold = self.owd / + @as(u64, @intCast(self.time.offset_coefficient_A)); + ret[0] = .{ + .tick = threshold, + .expected_offset = self.time.offset(threshold - self.learn_interval), + }; + ret[1] = .{ + .tick = threshold + 100, + .expected_offset = @intCast(self.owd), + }; + ret[2] = .{ + .tick = threshold + 200, + .expected_offset = @intCast(self.owd), + }; + }, + .periodic => { + ret[0] = .{ + .tick = @intCast(@divTrunc(self.time.offset_coefficient_B, 4)), + .expected_offset = @intCast(self.owd), + }; + ret[1] = .{ + .tick = @intCast(@divTrunc(self.time.offset_coefficient_B, 2)), + .expected_offset = 0, + }; + ret[2] = .{ + .tick = @intCast(@divTrunc(self.time.offset_coefficient_B * 3, 4)), + .expected_offset = -@as(i64, @intCast(self.owd)), + }; + }, + .step => { + ret[0] = .{ + .tick = @intCast(self.time.offset_coefficient_B - 10), + .expected_offset = 0, + }; + ret[1] = .{ + .tick = @intCast(self.time.offset_coefficient_B + 10), + .expected_offset = -@as(i64, @intCast(self.owd)), + }; + ret[2] = .{ + .tick = @intCast(self.time.offset_coefficient_B + 10), + .expected_offset = -@as(i64, @intCast(self.owd)), + }; + }, + .non_ideal => unreachable, // use ideal clocks for the unit tests + } + + return ret; + } +}; + +test "ideal clocks get clamped to cluster time" { + // Silence all clock logs. + const level = std.testing.log_level; + std.testing.log_level = std.log.Level.err; + defer std.testing.log_level = level; + + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + + const allocator = arena.allocator(); + + var ideal_constant_drift_clock: ClockUnitTestContainer = undefined; + try ideal_constant_drift_clock.init( + allocator, + OffsetType.linear, + std.time.ns_per_ms, // loses 1ms per tick + 0, + ); + const linear_clock_assertion_points = ideal_constant_drift_clock.ticks_to_perform_assertions(); + for (linear_clock_assertion_points) |point| { + ideal_constant_drift_clock.run_till_tick(point.tick); + try testing.expectEqual( + point.expected_offset, + @as(i64, @intCast(ideal_constant_drift_clock.clock.monotonic().ns)) - + ideal_constant_drift_clock.clock.realtime_synchronized().?, + ); + } + + var ideal_periodic_drift_clock: ClockUnitTestContainer = undefined; + try ideal_periodic_drift_clock.init( + allocator, + OffsetType.periodic, + std.time.ns_per_s, // loses up to 1s + 200, // period of 200 ticks + ); + const ideal_periodic_drift_clock_assertion_points = + ideal_periodic_drift_clock.ticks_to_perform_assertions(); + for (ideal_periodic_drift_clock_assertion_points) |point| { + ideal_periodic_drift_clock.run_till_tick(point.tick); + try testing.expectEqual( + point.expected_offset, + @as(i64, @intCast(ideal_periodic_drift_clock.clock.monotonic().ns)) - + ideal_periodic_drift_clock.clock.realtime_synchronized().?, + ); + } + + var ideal_jumping_clock: ClockUnitTestContainer = undefined; + try ideal_jumping_clock.init( + allocator, + OffsetType.step, + -5 * std.time.ns_per_day, // jumps 5 days ahead. + 49, // after 49 ticks + ); + const ideal_jumping_clock_assertion_points = ideal_jumping_clock.ticks_to_perform_assertions(); + for (ideal_jumping_clock_assertion_points) |point| { + ideal_jumping_clock.run_till_tick(point.tick); + try testing.expectEqual( + point.expected_offset, + @as(i64, @intCast(ideal_jumping_clock.clock.monotonic().ns)) - + ideal_jumping_clock.clock.realtime_synchronized().?, + ); + } +} + +const PacketSimulatorOptions = @import("../testing/packet_simulator.zig").PacketSimulatorOptions; +const PacketSimulatorType = @import("../testing/packet_simulator.zig").PacketSimulatorType; +const Path = @import("../testing/packet_simulator.zig").Path; +const Command = @import("../vsr.zig").Command; +const ClockSimulator = struct { + const Packet = struct { + m0: u64, + t1: ?i64, + }; + + const PacketSimulator = PacketSimulatorType(Packet); + + const Options = struct { + ping_timeout: u32, + clock_count: u8, + network_options: PacketSimulatorOptions, + }; + + allocator: std.mem.Allocator, + options: Options, + ticks: u64 = 0, + network: PacketSimulatorType(Packet), + times: []TimeSim, + clocks: []Clock, + prng: stdx.PRNG, + + pub fn init(allocator: std.mem.Allocator, options: Options) !ClockSimulator { + var network = try PacketSimulator.init(allocator, options.network_options, .{ + .packet_command = &packet_command, + .packet_clone = &packet_clone, + .packet_deinit = &packet_deinit, + .packet_deliver = &packet_deliver, + }); + errdefer network.deinit(allocator); + + var times = try allocator.alloc(TimeSim, options.clock_count); + errdefer allocator.free(times); + + var clocks = try allocator.alloc(Clock, options.clock_count); + errdefer allocator.free(clocks); + + var prng = stdx.PRNG.from_seed(options.network_options.seed); + + for (clocks, 0..) |*clock, replica| { + errdefer for (clocks[0..replica]) |*c| c.deinit(allocator); + + const amplitude = (@as(i64, @intCast(prng.int_inclusive(u64, 10))) - 10) * + std.time.ns_per_s; + const phase = @as(i64, @intCast(prng.range_inclusive(u64, 100, 1000))) + + @as(i64, @intFromFloat(std.Random.init(&prng, stdx.PRNG.fill).floatNorm(f64) * 50)); + times[replica] = .{ + .resolution = std.time.ns_per_s / 2, // delta_t = 0.5s + .offset_type = OffsetType.non_ideal, + .offset_coefficient_A = amplitude, + .offset_coefficient_B = phase, + .offset_coefficient_C = 10, + }; + + clock.* = try Clock.init(allocator, times[replica].time(), null, .{ + .replica_count = options.clock_count, + .replica = @intCast(replica), + .quorum = @divFloor(options.clock_count, 2) + 1, + }); + errdefer clock.deinit(allocator); + } + errdefer for (clocks) |*clock| clock.deinit(allocator); + + return ClockSimulator{ + .allocator = allocator, + .options = options, + .network = network, + .times = times, + .clocks = clocks, + .prng = prng, + }; + } + + pub fn deinit(self: *ClockSimulator) void { + for (self.clocks) |*clock| clock.deinit(self.allocator); + self.allocator.free(self.clocks); + self.allocator.free(self.times); + self.network.deinit(self.allocator); + } + + pub fn tick(self: *ClockSimulator) void { + self.ticks += 1; + self.network.tick(); + for (self.clocks) |*clock| { + clock.tick(); + } + + for (self.clocks, self.times) |*clock, *time| { + if (time.ticks % self.options.ping_timeout == 0) { + const m0 = clock.monotonic().ns; + for (self.clocks, 0..) |_, target| { + if (target != clock.replica) { + self.network.submit_packet( + .{ + .m0 = m0, + .t1 = null, + }, + .{ + .source = clock.replica, + .target = @intCast(target), + }, + ); + } + } + } + } + } + + fn packet_command(_: *PacketSimulator, _: Packet) Command { + return .ping; // Value doesn't matter. + } + + fn packet_clone(_: *PacketSimulator, packet: Packet) Packet { + return packet; + } + + fn packet_deinit(_: *PacketSimulator, _: Packet) void {} + + fn packet_deliver(packet_simulator: *PacketSimulator, packet: Packet, path: Path) void { + const self: *ClockSimulator = @fieldParentPtr("network", packet_simulator); + const target = &self.clocks[path.target]; + + if (packet.t1) |t1| { + target.learn( + path.source, + packet.m0, + t1, + target.monotonic().ns, + ); + } else { + self.network.submit_packet( + .{ + .m0 = packet.m0, + .t1 = target.realtime(), + }, + .{ + // send the packet back to where it came from. + .source = path.target, + .target = path.source, + }, + ); + } + } +}; + +test "clock: fuzz test" { + // Silence all clock logs. + const level = std.testing.log_level; + std.testing.log_level = std.log.Level.err; + defer std.testing.log_level = level; + + const ticks_max: u64 = 1_000_000; + const clock_count: u8 = 3; + const SystemTime = @import("../testing/time.zig").TimeSim; + var system_time = SystemTime{ + .resolution = constants.tick_ms * std.time.ns_per_ms, + .offset_type = .linear, + .offset_coefficient_A = 0, + .offset_coefficient_B = 0, + }; + const seed: u64 = @intCast(system_time.time().realtime()); + var min_sync_error: u64 = 1_000_000_000; + var max_sync_error: u64 = 0; + var max_clock_offset: u64 = 0; + var min_clock_offset: u64 = 1_000_000_000; + var simulator = try ClockSimulator.init(std.testing.allocator, .{ + .network_options = .{ + .node_count = clock_count, + .client_count = 0, + .seed = seed, + + .one_way_delay_mean = .ms(250), + .one_way_delay_min = .ms(100), + .packet_loss_probability = ratio(10, 100), + .path_maximum_capacity = 20, + .path_clog_duration_mean = .ms(200), + .path_clog_probability = ratio(2, 100), + .packet_replay_probability = ratio(2, 100), + + .partition_mode = .isolate_single, + .partition_probability = ratio(25, 100), + .unpartition_probability = ratio(5, 100), + .partition_stability = 100, + .unpartition_stability = 10, + }, + .clock_count = clock_count, + .ping_timeout = 20, + }); + defer simulator.deinit(); + + var clock_ticks_without_synchronization: [clock_count]u32 = @splat(0); + while (simulator.ticks < ticks_max) { + simulator.tick(); + + for (simulator.clocks, 0..) |*clock, index| { + const offset = simulator.times[index].offset(simulator.ticks); + const abs_offset: u64 = if (offset >= 0) @intCast(offset) else @intCast(-offset); + max_clock_offset = if (abs_offset > max_clock_offset) abs_offset else max_clock_offset; + min_clock_offset = if (abs_offset < min_clock_offset) abs_offset else min_clock_offset; + + const synced_time = clock.realtime_synchronized() orelse { + clock_ticks_without_synchronization[index] += 1; + continue; + }; + + for (simulator.clocks, 0..) |*other_clock, other_clock_index| { + if (index == other_clock_index) continue; + const other_clock_sync_time = other_clock.realtime_synchronized() orelse { + continue; + }; + const err: i64 = synced_time - other_clock_sync_time; + const abs_err: u64 = if (err >= 0) @intCast(err) else @intCast(-err); + max_sync_error = if (abs_err > max_sync_error) abs_err else max_sync_error; + min_sync_error = if (abs_err < min_sync_error) abs_err else min_sync_error; + } + } + } + + log.info("seed={}, max ticks={}, clock count={}\n", .{ + seed, + ticks_max, + clock_count, + }); + log.info("absolute clock offsets with respect to test time:\n", .{}); + log.info("maximum={}\n", .{fmt.fmtDurationSigned(@as(i64, @intCast(max_clock_offset)))}); + log.info("minimum={}\n", .{fmt.fmtDurationSigned(@as(i64, @intCast(min_clock_offset)))}); + log.info("\nabsolute synchronization errors between clocks:\n", .{}); + log.info("maximum={}\n", .{fmt.fmtDurationSigned(@as(i64, @intCast(max_sync_error)))}); + log.info("minimum={}\n", .{fmt.fmtDurationSigned(@as(i64, @intCast(min_sync_error)))}); + log.info("clock ticks without synchronization={d}\n", .{ + clock_ticks_without_synchronization, + }); +} diff --git a/ocam/src/vsr/fault_detector.zig b/ocam/src/vsr/fault_detector.zig new file mode 100644 index 00000000..87975eb2 --- /dev/null +++ b/ocam/src/vsr/fault_detector.zig @@ -0,0 +1,279 @@ +//! FaultDetector estimates the probability that the primary crashed. +//! It is implemented as a pure algorithm in a "sans-io" style. +//! +//! Intuition: imagine looking from a window at the street below and trying to guess whether the +//! nearest traffic light (not directly visible) is red or green. If you see the cars going, it +//! definitely was green recently. If there are no cars, it could be that the light is red, or that +//! you are out of peak hour and there are no cars at all. So, a reasonable algorithm is to note +//! the average interarrival time over a sliding window, and signal "red" when there's a suspicious +//! absence of cars, relative to recent history. Note latency-throughput interaction: if the cars +//! are frequent, but there's a large distance between your window and the traffic light, you notice +//! red very quickly, but only after "wave edge" reaches you. +//! +//! Applying to TigerBeetle, let's consider the case where the cluster is under stable load and +//! prepares are flowing regularly. In this case, a backup uses a sliding window to compute current +//! rate of prepares, and sounds an alarm (ExitView) if the flow stops. +//! +//! To solve the case of an idle cluster, primary broadcasts Commit messages periodically, which are +//! interchangeable with Prepare messages as a signal that the primary is alive. +//! +//! Another complication is that prepare load can be bursty. If the load ramps up quickly, the +//! primary is seen as extremely alive, which is OK. However, if the load is cut off exogenously +//! (because the client finished a batch job, not because the primary crashed), this will look like +//! a dead primary. To solve this, primary uses central-bank style algorithm, where it injects extra +//! Commit messages to keep prepare rate relatively stable in the short run. + +const std = @import("std"); +const stdx = @import("stdx"); +const assert = std.debug.assert; +const Instant = stdx.Instant; +const Duration = stdx.Duration; + +interval_min: Duration, +interval_max: Duration, + +signal_last: Instant, +interval_ewma: Duration, + +const FaultDetector = @This(); + +pub fn init(options: struct { + now: Instant, + interval_min: Duration, + interval_max: Duration, +}) FaultDetector { + assert(options.interval_min.ns < options.interval_max.ns); + // Sanity check and overflow protection for ewma. + assert(options.interval_max.ns <= 10 * std.time.ns_per_hour); + return .{ + .interval_min = options.interval_min, + .interval_max = options.interval_max, + + .signal_last = options.now, + .interval_ewma = options.interval_max, + }; +} + +pub fn signal(detector: *FaultDetector, now: Instant) void { + const past = detector.signal_last; + assert(past.ns <= now.ns); + const elapsed = past.elapsed(now) + // Clamp first, then ewma_add, to avoid overflows. + .clamp(detector.interval_min, detector.interval_max); + + detector.interval_ewma = ewma_add_duration(detector.interval_ewma, elapsed); + detector.signal_last = now; +} + +/// Is the signal overdue? +/// * green --- signal is on time +/// * yellow --- signal seems delayed/lost +/// * red --- signaler is likely dead +/// +/// On yellow, the primary injects a Commit. +/// On red, a backup sends ExitView. +/// +/// Rough model: +/// - Random delays, but 2X delay is suspicious. +/// - An individual signal can get lost. +/// Either of the above suggests 2X+some as a cutoff point for a fault. +/// We round up to 3X cutoff for red, and 1.5X cutoff for yellow. +/// +/// An alternative approach would be to build a probabilistic model of the prepare/commit arrival +/// process, and then compute the actual probability of primary being dead using Bayes' rule. We +/// don't do that, because we don't know the actual underlying model. A simple rule like the above +/// will not give us the optimal answer, but it should work in variety of different contexts! +pub fn tardy(detector: *FaultDetector, now: Instant) enum { green, yellow, red } { + const past = detector.signal_last; + assert(past.ns <= now.ns); + const elapsed = past.elapsed(now); + + if (elapsed.ns *| 2 <= detector.interval_ewma.ns * 3) { // interval <= 1.5 * interval_ewma + return .green; + } + assert(elapsed.ns >= detector.interval_ewma.ns); + if (elapsed.ns <= detector.interval_ewma.ns * 3) { + return .yellow; + } + assert(elapsed.ns > detector.interval_ewma.ns); + return .red; +} + +pub fn reset(detector: *FaultDetector, now: Instant) void { + const past = detector.signal_last; + assert(past.ns <= now.ns); + detector.* = FaultDetector.init(.{ + .now = now, + .interval_min = detector.interval_min, + .interval_max = detector.interval_max, + }); +} + +fn ewma_add_duration(old: Duration, new: Duration) Duration { + return .{ + .ns = @divFloor((old.ns * 4) + new.ns, 5), + }; +} + +test "FaultDetector: smoke" { + // Test that computed ewma interval tracks actual interval, + // clamped to limits. + var now: Instant = .{ .ns = 1_000 }; + var detector = FaultDetector.init(.{ + .now = now, + .interval_min = .ms(100), + .interval_max = .ms(2_000), + }); + assert(detector.tardy(now) == .green); + assert(detector.interval_ewma.to_ms() == 2_000); + + for (0..100) |_| { + now = now.add(.ms(200)); + detector.signal(now); + } + now = now.add(.ms(200)); + assert(detector.tardy(now) == .green); + assert(detector.interval_ewma.to_ms() == 200); + + now = now.add(.ms(200)); + assert(detector.tardy(now) == .yellow); + + now = now.add(.ms(250)); + assert(detector.tardy(now) == .red); + + for (0..100) |_| { + now = now.add(.ms(1_000)); + detector.signal(now); + } + now = now.add(.ms(1_000)); + assert(detector.tardy(now) == .green); + assert(detector.interval_ewma.to_ms() == 999); + + for (0..100) |_| { + now = now.add(.ms(10)); + detector.signal(now); + } + now = now.add(.ms(10)); + assert(detector.tardy(now) == .green); + assert(detector.interval_ewma.to_ms() == 100); + + for (0..100) |_| { + now = now.add(.ms(10_000)); + detector.signal(now); + } + now = now.add(.ms(10_000)); + assert(detector.tardy(now) == .red); + assert(detector.interval_ewma.to_ms() == 1_999); +} + +test "FaultDetector: smoothing" { + // Check that, after a burst of prepares is abruptly ended, + // the primary can gradually reduce the arrival interval, + // without triggering a view change. + var now: Instant = .{ .ns = 1_000 }; + var primary = FaultDetector.init(.{ + .now = now, + .interval_min = .ms(50), + .interval_max = .ms(2_000), + }); + + const backup_delay: Duration = .ms(100); + var backup = FaultDetector.init(.{ + .now = now, + .interval_min = .ms(50), + .interval_max = .ms(2_000), + }); + + const commit_interval: Duration = .ms(500); + const request_interval: Duration = .ms(100); + + var commit_timer = now; + var request_timer = now; + + for (0..1_000) |_| { + now = now.add(.ms(10)); // Advance by one tick. + + // Primary broadcasts commit message every 500ms. + if (commit_timer.elapsed(now).ns > commit_interval.ns) { + commit_timer = now; + primary.signal(now); + backup.signal(now.add(backup_delay)); + } + assert(primary.tardy(now) == .green); + + // Primary converts a request into prepare every 100ms. + if (request_timer.elapsed(now).ns > request_interval.ns) { + request_timer = now; + primary.signal(now); + backup.signal(now.add(backup_delay)); + } + assert(backup.tardy(now.add(backup_delay)) == .green); + } + assert(primary.interval_ewma.to_ms() == 95); + assert(backup.interval_ewma.to_ms() == 95); + + for (0..1_000) |_| { + now = now.add(.ms(10)); // Advance by one tick. + + if (commit_timer.elapsed(now).ns > commit_interval.ns) { + commit_timer = now; + primary.signal(now); + backup.signal(now.add(backup_delay)); + } + switch (primary.tardy(now)) { + .green => {}, + .yellow => { + // Stay awhile and listen, wanderer, the story of the next line. + // + // The original implementation in replica.zig didn't have the equivalent. In other + // words, the primary was always sending a commit message every 500 ms, and then + // additionally injecting a "bonus" commit whenever fault detector flashed yellow. + // That is, when the delay since last commit/prepare exceeded 1.5X of the current + // interval. Because 1.5 > 1, it should be the case that the ewma of the interval + // gradually converges to 500 ms, right? + // + // Wrong! Implementing this test to double-check "obviously correct" logic showed + // that the interval expands from 100ms to 250ms, but then gets stuck! + // Here's the picture. Originally we start with an idle cluster where only commits + // are pulsed periodically: + // + // C C C C C + // + // Then the load starts, and we get a lot of prepares in between: + // + // C P P P C P P P C P P P C P P P C + // + // Then, the load cuts off, but the primary starts Injecting extra commits to + // keep the interval: + // + // C I I I C I I C I C .... + // + // The frequency of injected commits goes down to just one, but then gets stuck: + // + // C I C I C I C I C + // + // Both the first and the last lines are fixed points. For the first line, the ewma + // is 500ms and no commits are injected. For the last line, the ewma is 250ms, and + // at 250*1.5=350ms after the last C, an I gets injected. + // + // More generally, if we are currently injecting one extra message, we need to + // tolerate at least 2X injection delay to get to zero extra messages, _if_ we keep + // a steady pace of heartbeat commits. This means that delay to detect a crashed + // primary needs to grow proportionally more, and that feels like too much of a lag. + // Instead, we let go of the constraint of keeping the heartbeat steady, and skip + // the next normal beat whenever we inject one. + commit_timer = now; + + primary.signal(now); + backup.signal(now.add(backup_delay)); + }, + .red => unreachable, + } + switch (backup.tardy(now.add(backup_delay))) { + .green, .yellow => {}, + .red => unreachable, + } + } + assert(primary.interval_ewma.to_ms() == 499); + assert(backup.interval_ewma.to_ms() == 499); +} diff --git a/ocam/src/vsr/free_set.zig b/ocam/src/vsr/free_set.zig new file mode 100644 index 00000000..ee1f988b --- /dev/null +++ b/ocam/src/vsr/free_set.zig @@ -0,0 +1,1381 @@ +const std = @import("std"); +const assert = std.debug.assert; +const mem = std.mem; + +const DynamicBitSetUnmanaged = std.bit_set.DynamicBitSetUnmanaged; +const MaskInt = DynamicBitSetUnmanaged.MaskInt; + +const vsr = @import("../vsr.zig"); +const stdx = vsr.stdx; +const KiB = stdx.KiB; +const ewah = vsr.ewah(FreeSet.Word); +const constants = vsr.constants; + +const div_ceil = stdx.div_ceil; +const maybe = stdx.maybe; + +/// This is logically a range of addresses within the FreeSet, but its actual fields are block +/// indexes for ease of calculation. +/// +/// A reservation covers a range of both free and acquired blocks — when it is first created, +/// it is guaranteed to cover exactly as many free blocks as were requested by `reserve()`. +pub const Reservation = struct { + block_base: usize, + block_count: usize, + /// An identifier for each reservation cycle, to verify that old reservations are not reused. + session: usize, +}; + +/// The 0 address is reserved for usage as a sentinel and will never be returned by acquire(). +/// +/// Concurrent callers must reserve free blocks before acquiring them to ensure that +/// acquisition order is deterministic despite concurrent jobs acquiring blocks in +/// nondeterministic order. +/// +/// The reservation lifecycle is: +/// +/// 1. Reserve: In deterministic order, each job (e.g. compaction) calls `reserve()` to +/// reserve the upper bound of blocks that it may need to acquire to complete. +/// 2. Acquire: The jobs run concurrently. Each job acquires blocks only from its respective +/// reservation (via `acquire()`). +/// 3. Forfeit: When a job finishes, it calls `forfeit()` to drop its reservation. +/// 4. Done: When all pending reservations are forfeited, the reserved (but unacquired) space +/// is reclaimed. +/// +pub const FreeSet = struct { + pub const Word = u64; + pub const BitsetKind = enum { + blocks_acquired, + blocks_released, + }; + const BlocksReleasedPriorCheckpointDurability = std.AutoArrayHashMapUnmanaged(u64, void); + + // Free set is stored in the grid (see `CheckpointTrailer`) and is not available until the + // relevant blocks are fetched from disk (or other replicas) and decoded. + // + // Without the free set, only blocks belonging to the free set might be read and no blocks can + // be written. + opened: bool = false, + + /// Whether the current checkpoint is durable. + checkpoint_durable: bool = false, + + /// If a shard has any free blocks, the corresponding index bit is zero. + /// If a shard has no free blocks, the corresponding index bit is one. + index: DynamicBitSetUnmanaged, + + /// The maximum number of blocks the free set is allowed to reserve (driven by --limit-storage). + blocks_count_limit: u64, + + /// Set bits indicate acquired blocks; unset bits indicate free blocks. + blocks_acquired: DynamicBitSetUnmanaged, + + /// Set bits indicate blocks released in the current checkpoint, to be freed when the next + /// checkpoint becomes durable. + blocks_released: DynamicBitSetUnmanaged, + + /// Temporarily holds blocks released prior durability of the current checkpoint, to be freed + /// when the next checkpoint becomes durable. These blocks are moved to blocks_released once the + /// current checkpoint becomes durable. + blocks_released_prior_checkpoint_durability: BlocksReleasedPriorCheckpointDurability, + + /// The number of blocks that are reserved, counting both acquired and free blocks + /// from the start of `blocks_acquired`. + /// Alternatively, the index of the first non-reserved block in `blocks_acquired`. + reservation_blocks: usize = 0, + + /// The number of active reservations. + reservation_count: usize = 0, + + /// Verify that when the caller transitions from creating reservations to forfeiting them, + /// all reservations must be forfeited before additional reservations are made. + reservation_state: enum { + reserving, + forfeiting, + } = .reserving, + + /// Verifies that reservations are not allocated from or forfeited when they should not be. + reservation_session: usize = 1, + + // Each shard is 8 cache lines because the CPU line fill buffer can fetch 10 lines in parallel. + // And 8 is fast for division when computing the shard of a block. + // Since the shard is scanned sequentially, the prefetching amortizes the cost of the single + // cache miss. It also reduces the size of the index. + // + // e.g. 10TiB disk ÷ 64KiB/block ÷ 512*8 blocks/shard ÷ 8 shards/byte = 5120B index + const shard_cache_lines = 8; + pub const shard_bits = shard_cache_lines * constants.cache_line_size * @bitSizeOf(u8); + comptime { + assert(shard_bits == 4096); + assert(@bitSizeOf(MaskInt) == 64); + // Ensure there are no wasted padding bits at the end of the index. + assert(shard_bits % @bitSizeOf(MaskInt) == 0); + } + + pub fn init(allocator: mem.Allocator, options: struct { + grid_size_limit: usize, + blocks_released_prior_checkpoint_durability_max: usize, + }) !FreeSet { + const blocks_count = block_count_max(options.grid_size_limit); + assert(blocks_count % shard_bits == 0); + assert(blocks_count % @bitSizeOf(Word) == 0); + + // Every block bit is covered by exactly one index bit. + const shards_count = @divExact(blocks_count, shard_bits); + var index = try DynamicBitSetUnmanaged.initEmpty(allocator, shards_count); + errdefer index.deinit(allocator); + + var blocks_acquired = try DynamicBitSetUnmanaged.initEmpty(allocator, blocks_count); + errdefer blocks_acquired.deinit(allocator); + + var blocks_released = try DynamicBitSetUnmanaged.initEmpty(allocator, blocks_count); + errdefer blocks_released.deinit(allocator); + + var released_prior_checkpoint_durability: BlocksReleasedPriorCheckpointDurability = .{}; + try released_prior_checkpoint_durability.ensureTotalCapacity( + allocator, + options.blocks_released_prior_checkpoint_durability_max + + // `blocks_released` and `blocks_acquired` encoded in the CheckpointTrailer are + // released at checkpoint (see `mark_checkpoint_not_durable` in grid.zig). + 2 * vsr.checkpoint_trailer.block_count_for_trailer_size( + ewah.encode_size_max(blocks_count), + ), + ); + errdefer released_prior_checkpoint_durability.deinit(); + + assert(index.count() == 0); + assert(blocks_acquired.count() == 0); + assert(blocks_released.count() == 0); + assert(released_prior_checkpoint_durability.count() == 0); + + return FreeSet{ + .index = index, + .blocks_count_limit = @divFloor(options.grid_size_limit, constants.block_size), + .blocks_acquired = blocks_acquired, + .blocks_released = blocks_released, + .blocks_released_prior_checkpoint_durability = released_prior_checkpoint_durability, + }; + } + pub fn deinit(set: *FreeSet, allocator: mem.Allocator) void { + set.index.deinit(allocator); + set.blocks_acquired.deinit(allocator); + set.blocks_released.deinit(allocator); + set.blocks_released_prior_checkpoint_durability.deinit(allocator); + } + + pub fn reset(set: *FreeSet) void { + for ([_]*DynamicBitSetUnmanaged{ + &set.index, + &set.blocks_acquired, + &set.blocks_released, + }) |bitset| { + var it = bitset.iterator(.{}); + while (it.next()) |bit| bitset.unset(bit); + } + + set.blocks_released_prior_checkpoint_durability.clearRetainingCapacity(); + + set.* = .{ + .index = set.index, + .blocks_count_limit = set.blocks_count_limit, + .blocks_acquired = set.blocks_acquired, + .blocks_released = set.blocks_released, + .blocks_released_prior_checkpoint_durability = set + .blocks_released_prior_checkpoint_durability, + .reservation_session = set.reservation_session +% 1, + }; + + assert(set.index.count() == 0); + assert(set.blocks_acquired.count() == 0); + assert(set.blocks_released.count() == 0); + assert(set.blocks_released_prior_checkpoint_durability.count() == 0); + + assert(!set.opened); + } + + /// Opens a free set. Needs two inputs: + /// + /// - the byte buffers with the ewah-encoded acquired and released bitsets, + /// - the list of block addresses used to store both the encoded bitsets in the grid. + /// + /// Block addresses themselves are not a part of the encoded bitset for acquired blocks, + /// see CheckpointTrailer for details. + pub fn open(set: *FreeSet, options: struct { + encoded: struct { + blocks_acquired: []const []align(@alignOf(Word)) const u8, + blocks_released: []const []align(@alignOf(Word)) const u8, + }, + free_set_block_addresses: struct { + blocks_acquired: []const u64, + blocks_released: []const u64, + }, + }) void { + assert(!set.opened); + assert((options.encoded.blocks_acquired.len == 0 and + options.encoded.blocks_released.len == 0) == + (options.free_set_block_addresses.blocks_acquired.len == 0 and + options.free_set_block_addresses.blocks_released.len == 0)); + set.decode_chunks( + options.encoded.blocks_acquired, + options.encoded.blocks_released, + ); + set.mark_released(options.free_set_block_addresses.blocks_acquired); + set.mark_released(options.free_set_block_addresses.blocks_released); + set.opened = true; + } + + // A shortcut to initialize an empty free set for tests. + pub fn init_empty(allocator: mem.Allocator, blocks_count: usize) !FreeSet { + comptime assert(constants.verify); + var set = try init(allocator, .{ + .grid_size_limit = blocks_count * constants.block_size, + .blocks_released_prior_checkpoint_durability_max = 0, + }); + errdefer set.deinit(allocator); + + assert(!set.opened); + assert(!set.checkpoint_durable); + return set; + } + + // A shortcut to initialize and open an empty free set for tests. + pub fn open_empty(allocator: mem.Allocator, blocks_count: usize) !FreeSet { + comptime assert(constants.verify); + var set = try init(allocator, .{ + .grid_size_limit = blocks_count * constants.block_size, + .blocks_released_prior_checkpoint_durability_max = 0, + }); + errdefer set.deinit(allocator); + + set.open(.{ + .encoded = .{ .blocks_acquired = &.{}, .blocks_released = &.{} }, + .free_set_block_addresses = .{ .blocks_acquired = &.{}, .blocks_released = &.{} }, + }); + // Mark checkpoint as durable so tests use blocks_released for block releases. + // blocks_released_prior_checkpoint_durable is required to ensure correctness across + // multiple replicas, while tests check the following flows in a single process: + // * Block acquisition-release + // * Bitset encoding-decoding + set.checkpoint_durable = true; + + assert(set.opened); + assert(set.count_free() == blocks_count); + assert(set.count_released() == 0); + return set; + } + + fn verify_index(set: *const FreeSet) void { + for (0..set.index.bit_length) |shard| { + assert((set.find_free_block_in_shard(shard) == null) == set.index.isSet(shard)); + } + } + + /// Returns the number of active reservations. + pub fn count_reservations(set: FreeSet) usize { + assert(set.opened); + return set.reservation_count; + } + + /// Returns the number of free blocks. + pub fn count_free(set: FreeSet) usize { + assert(set.opened); + return set.blocks_acquired.capacity() - set.blocks_acquired.count(); + } + + /// Returns the number of acquired blocks. + pub fn count_acquired(set: FreeSet) usize { + assert(set.opened); + return set.blocks_acquired.count(); + } + + /// Returns the number of released blocks. + pub fn count_released(set: FreeSet) usize { + assert(set.opened); + return set.blocks_released.count() + + set.blocks_released_prior_checkpoint_durability.count(); + } + + /// Returns the address of the highest acquired block. + pub fn highest_address_acquired(set: FreeSet) ?u64 { + assert(set.opened); + var it = set.blocks_acquired.iterator(.{ + .kind = .set, + .direction = .reverse, + }); + + if (it.next()) |block| { + const address = block + 1; + return address; + } else { + // All blocks are free. + assert(set.blocks_acquired.count() == 0); + return null; + } + } + + /// Returns the address of the highest released block. + pub fn highest_address_released(set: FreeSet) ?u64 { + assert(set.opened); + var it = set.blocks_released.iterator(.{ + .kind = .set, + .direction = .reverse, + }); + + if (it.next()) |block| { + const address = block + 1; + return address; + } else { + assert(set.count_released() == 0); + return null; + } + } + + /// Reserve `reserve_count` free blocks. The blocks are not acquired yet. + /// + /// Invariants: + /// + /// - If a reservation is returned, it covers exactly `reserve_count` free blocks, along with + /// any interleaved already-acquired blocks. + /// - Active reservations are exclusive (i.e. disjoint). + /// (A reservation is active until `forfeit()` is called.) + /// + /// Returns null if there are not enough blocks free and vacant. + /// Returns a reservation which can be used with `acquire()`: + /// - The caller should consider the returned Reservation as opaque and immutable. + /// - Each `reserve()` call which returns a non-null Reservation must correspond to exactly one + /// `forfeit()` call. + pub fn reserve(set: *FreeSet, reserve_count: usize) ?Reservation { + assert(set.opened); + assert(set.reservation_state == .reserving); + assert(reserve_count > 0); + + const shard_start = find_bit( + set.index, + @divFloor(set.reservation_blocks, shard_bits), + set.index.bit_length, + .unset, + ) orelse return null; + + // The reservation may cover (and ignore) already-acquired blocks due to fragmentation. + var block = @max(shard_start * shard_bits, set.reservation_blocks); + for (0..reserve_count) |_| { + block = 1 + (find_bit( + set.blocks_acquired, + block, + set.blocks_acquired.bit_length, + .unset, + ) orelse return null); + + // The free block from the `blocks_acquired` bit set may be past the total number of + // blocks that this free set is allowed to acquire (see `block_count_max`). + if (block > set.blocks_count_limit) return null; + } + const block_base = set.reservation_blocks; + const block_count = block - set.reservation_blocks; + set.reservation_blocks += block_count; + set.reservation_count += 1; + + return Reservation{ + .block_base = block_base, + .block_count = block_count, + .session = set.reservation_session, + }; + } + + /// After invoking `forfeit()`, the reservation must never be used again. + pub fn forfeit(set: *FreeSet, reservation: Reservation) void { + assert(set.opened); + assert(set.reservation_session == reservation.session); + + set.reservation_count -= 1; + if (set.reservation_count == 0) { + // All reservations have been dropped. + set.reservation_blocks = 0; + set.reservation_session +%= 1; + set.reservation_state = .reserving; + } else { + set.reservation_state = .forfeiting; + } + } + + /// Marks a free block from the reservation as allocated, and returns the address. + /// The reservation must not have been forfeited yet. + /// The reservation must belong to the current cycle of reservations. + /// + /// Invariants: + /// + /// - An acquired block cannot be acquired again until it has been released and the release + /// has been checkpointed. + /// + /// Returns null if no free block is available in the reservation. + pub fn acquire(set: *FreeSet, reservation: Reservation) ?u64 { + assert(set.opened); + assert(set.reservation_count > 0); + assert(reservation.block_count > 0); + assert(reservation.block_base < set.reservation_blocks); + assert(reservation.block_base + reservation.block_count <= set.reservation_blocks); + assert(reservation.session == set.reservation_session); + + const shard_start = find_bit( + set.index, + @divFloor(reservation.block_base, shard_bits), + div_ceil(reservation.block_base + reservation.block_count, shard_bits), + .unset, + ) orelse return null; + assert(!set.index.isSet(shard_start)); + + const reservation_start = @max( + shard_start * shard_bits, + reservation.block_base, + ); + const reservation_end = reservation.block_base + reservation.block_count; + const block = find_bit( + set.blocks_acquired, + reservation_start, + reservation_end, + .unset, + ) orelse return null; + assert(block >= reservation.block_base); + assert(block <= reservation.block_base + reservation.block_count); + assert(!set.blocks_acquired.isSet(block)); + assert(!set.blocks_released.isSet(block)); + assert(!set.blocks_released_prior_checkpoint_durability.contains(block)); + + // Even if "shard_start" has free blocks, we might acquire our block from a later shard. + // (This is possible because our reservation begins part-way through the shard.) + const shard = @divFloor(block, shard_bits); + maybe(shard == shard_start); + assert(shard >= shard_start); + + set.blocks_acquired.set(block); + // Update the index when every block in the shard is acquired. + if (set.find_free_block_in_shard(shard) == null) set.index.set(shard); + const address = block + 1; + return address; + } + + fn find_free_block_in_shard(set: FreeSet, shard: usize) ?usize { + maybe(set.opened); + const shard_start = shard * shard_bits; + const shard_end = shard_start + shard_bits; + assert(shard_start < set.blocks_acquired.bit_length); + + return find_bit(set.blocks_acquired, shard_start, shard_end, .unset); + } + + pub fn is_free(set: FreeSet, address: u64) bool { + if (set.opened) { + const block = address - 1; + return !set.blocks_acquired.isSet(block); + } else { + // When the free set is not open, conservatively assume that the block is acquired. + // + // This path is hit only when the replica opens the free set, reading its blocks from + // the grid. + return false; + } + } + + pub fn is_released(set: *const FreeSet, address: u64) bool { + assert(set.opened); + const block = address - 1; + return set.blocks_released_prior_checkpoint_durability.contains(block) or + set.blocks_released.isSet(block); + } + + /// Returns `true` if the block at the given address would be freed when the current checkpoint + /// becomes durable (when checkpoint_durable is set to `true`). + /// + /// Calling this function is only valid while the current checkpoint is not durable. During this + /// period, blocks are marked as released in `blocks_released_prior_checkpoint_durability`; + /// `blocks_released` remains unchanged and contains blocks released during the previous + /// checkpoint interval. + pub fn to_be_freed_at_checkpoint_durability(set: *const FreeSet, address: u64) bool { + const block = address - 1; + + assert(set.opened); + assert(!set.checkpoint_durable); + + // Block address must be acquired, but is not necessarily released. + assert(set.blocks_acquired.isSet(block)); + assert(!set.blocks_released.isSet(block) or + !set.blocks_released_prior_checkpoint_durability.contains(block)); + maybe(set.blocks_released.isSet(block)); + maybe(set.blocks_released_prior_checkpoint_durability.contains(block)); + + return set.blocks_released.isSet(block); + } + + /// Leave the address acquired for now, but free it when the next checkpoint becomes durable. + /// This ensures that it will not be overwritten during the current checkpoint — the block may + /// still be needed if we crash and recover from the current checkpoint. + /// (TODO) If the block was created since the last checkpoint then it's safe to free + /// immediately. This may reduce space amplification, especially for smaller datasets. + /// (Note: This must be careful not to release while any reservations are held + /// to avoid making the reservation's acquire()s nondeterministic). + pub fn release(set: *FreeSet, address: u64) void { + assert(set.opened); + + const block = address - 1; + assert(set.blocks_acquired.isSet(block)); + assert(!set.blocks_released.isSet(block)); + assert(!set.blocks_released_prior_checkpoint_durability.contains(block)); + + // `blocks_released` remains unchanged while the current checkpoint is not durable, + // since it contains blocks released in the previous checkpoint. These blocks must not be + // freed till the current checkpoint is durable, so as to maintain the durability of these + // blocks on a commit quorum of replicas. + if (set.checkpoint_durable) { + set.blocks_released.set(block); + } else { + set.blocks_released_prior_checkpoint_durability.putAssumeCapacity(block, {}); + } + } + + /// Mark the given addresses as allocated in the current checkpoint, but free in the next one. + /// + /// This is used only when reading a free set from the grid. On disk representation of the + /// free set doesn't include the blocks storing the free set itself, and these blocks must be + /// manually patched in after decoding. As the next checkpoint will have a completely different + /// free set, the blocks can be simultaneously released. + fn mark_released(set: *FreeSet, addresses: []const u64) void { + assert(!set.opened); + assert(!set.checkpoint_durable); + + var address_previous: u64 = 0; + for (addresses) |address| { + assert(address > 0); + + // Assert that addresses are sorted and unique. Sortedness is not a requirement, but + // a consequence of "first free" allocation algorithm. + assert(address > address_previous); + address_previous = address; + + const block = address - 1; + + assert(!set.blocks_acquired.isSet(block)); + assert(!set.blocks_released.isSet(block)); + assert(!set.blocks_released_prior_checkpoint_durability.contains(block)); + + set.blocks_acquired.set(block); + + const shard = @divFloor(block, shard_bits); + // Update the index when every block in the shard is acquired. + if (set.find_free_block_in_shard(shard) == null) set.index.set(shard); + + set.blocks_released_prior_checkpoint_durability.putAssumeCapacity(block, {}); + } + } + + /// Given the address, marks an acquired block as free. + fn free(set: *FreeSet, address: u64) void { + assert(set.opened); + assert(set.checkpoint_durable); + + const block = address - 1; + assert(set.blocks_acquired.isSet(block)); + assert(set.blocks_released.isSet(block)); + assert(!set.blocks_released_prior_checkpoint_durability.contains(block)); + + assert(set.reservation_count == 0); + assert(set.reservation_blocks == 0); + + set.index.unset(@divFloor(block, shard_bits)); + set.blocks_acquired.unset(block); + set.blocks_released.unset(block); + } + + pub fn mark_checkpoint_not_durable(set: *FreeSet) void { + assert(set.opened); + assert(set.checkpoint_durable); + assert(set.blocks_released_prior_checkpoint_durability.count() == 0); + set.checkpoint_durable = false; + } + + /// Now that the checkpoint is durable on a commit quorum of replicas: + /// 1. Mark the current checkpoint as durable. + /// 2. Mark all released blocks in `blocks_released` as free. + /// 3. Move released blocks from `blocks_released_prior_checkpoint_durability` to + /// `blocks_released`. + pub fn mark_checkpoint_durable(set: *FreeSet) void { + assert(set.opened); + assert(!set.checkpoint_durable); + + set.checkpoint_durable = true; + + var it = set.blocks_released.iterator(.{ .kind = .set }); + while (it.next()) |block| set.free(block + 1); + + assert(set.blocks_released.count() == 0); + + // Block releases from the current checkpoint that were temporarily recorded in + // blocks_released_prior_checkpoint_durability can now be moved to blocks_released. + while (set.blocks_released_prior_checkpoint_durability.pop()) |block_entry| { + const block = block_entry.key; + set.blocks_released.set(block); + } + assert(set.blocks_released_prior_checkpoint_durability.count() == 0); + + // Index verification is O(blocks.bit_length) so do it only when checkpoint is marked + // durable, which is also linear (as we free released blocks in `blocks_released`). + set.verify_index(); + } + + /// Decodes the compressed bitset chunks in `source_chunks` into `target_bitset`. + /// Panics if the `source_chunks` encoding is invalid. + fn decode( + set: *FreeSet, + target_bitset: FreeSet.BitsetKind, + source_chunks: []const []align(@alignOf(Word)) const u8, + ) void { + assert(!set.opened); + assert(!set.checkpoint_durable); + + var source_size: usize = 0; + + for (source_chunks) |source_chunk| source_size += source_chunk.len; + + const target_bitset_words = switch (target_bitset) { + .blocks_acquired => bit_set_masks(set.blocks_acquired), + .blocks_released => bit_set_masks(set.blocks_released), + }; + + var decoder = ewah.decode_chunks(target_bitset_words, source_size); + + var words_decoded: usize = 0; + for (source_chunks) |source_chunk| { + words_decoded += decoder.decode_chunk(source_chunk); + } + assert(decoder.done()); + + assert(@bitSizeOf(Word) == @bitSizeOf(MaskInt)); + assert(words_decoded * @bitSizeOf(Word) <= set.blocks_acquired.bit_length); + + // The encoder does not encode trailing 0s, so everything past words_decoded must be zeroed. + assert(stdx.zeroed(std.mem.sliceAsBytes(target_bitset_words[words_decoded..]))); + // TODO: uncomment on the next release: + // if (words_decoded > 0) assert(target_bitset_words[words_decoded - 1] != 0); + } + + pub fn decode_chunks( + set: *FreeSet, + source_chunks_blocks_acquired: []const []align(@alignOf(Word)) const u8, + source_chunks_blocks_released: []const []align(@alignOf(Word)) const u8, + ) void { + assert(!set.opened); + assert(!set.checkpoint_durable); + + // Verify that this FreeSet is entirely unallocated. + assert(set.index.count() == 0); + assert(set.blocks_acquired.count() == 0); + assert(set.blocks_released.count() == 0); + assert(set.blocks_released_prior_checkpoint_durability.count() == 0); + + assert(set.reservation_count == 0); + assert(set.reservation_blocks == 0); + + set.decode(.blocks_acquired, source_chunks_blocks_acquired); + set.decode(.blocks_released, source_chunks_blocks_released); + + for (0..set.index.bit_length) |shard| { + if (set.find_free_block_in_shard(shard) == null) set.index.set(shard); + } + + set.verify_index(); + } + + /// Returns the number of blocks that the free set can physically reference via the acquired + /// and released bitsets. Logically, the limit on the number of blocks that can be acquired by + /// the free set is imposed by --limit-storage. + pub fn block_count_max(grid_size_limit: usize) usize { + const block_count_limit = @divFloor(grid_size_limit, constants.block_size); + return stdx.div_ceil(block_count_limit, shard_bits) * shard_bits; + } + + /// Returns the maximum number of bytes needed for encoding the acquired/released bitset. + pub fn encode_size_max(set: *const FreeSet) usize { + assert(set.blocks_acquired.bit_length == set.blocks_released.bit_length); + + const blocks_count = set.blocks_acquired.bit_length; + assert(blocks_count % shard_bits == 0); + assert(blocks_count % @bitSizeOf(usize) == 0); + + return ewah.encode_size_max(@divExact(blocks_count, @bitSizeOf(Word))); + } + + fn encode( + set: *const FreeSet, + source_bitset: FreeSet.BitsetKind, + target_chunks: []const []align(@alignOf(Word)) u8, + ) usize { + assert(set.opened); + assert(set.checkpoint_durable); + + var encoder = switch (source_bitset) { + .blocks_acquired => ewah.encode_chunks(bit_set_masks(set.blocks_acquired)), + .blocks_released => ewah.encode_chunks(bit_set_masks(set.blocks_released)), + }; + defer assert(encoder.done()); + + var bytes_encoded_total: u64 = 0; + for (target_chunks) |chunk| { + const bytes_encoded = + @as(u32, @intCast(encoder.encode_chunk(chunk))); + assert(bytes_encoded > 0); + + bytes_encoded_total += bytes_encoded; + + if (encoder.done()) break; + } else unreachable; + + // Don't explicitly encode trailing zeros to ensure that the encoding is the same regardless + // of the runtime-configurable capacity of the bit set (driven by --limit-storage). + const bytes_trailing_zero_runs = encoder.trailing_zero_runs_count * @sizeOf(ewah.Marker); + + return bytes_encoded_total - bytes_trailing_zero_runs; + } + + pub fn encode_chunks( + set: *const FreeSet, + target_chunks_blocks_acquired: []const []align(@alignOf(Word)) u8, + target_chunks_blocks_released: []const []align(@alignOf(Word)) u8, + ) struct { encoded_size_blocks_acquired: u64, encoded_size_blocks_released: u64 } { + assert(set.opened); + assert(set.checkpoint_durable); + assert(set.reservation_count == 0); + assert(set.reservation_blocks == 0); + + return .{ + .encoded_size_blocks_acquired = set.encode( + .blocks_acquired, + target_chunks_blocks_acquired, + ), + .encoded_size_blocks_released = set.encode( + .blocks_released, + target_chunks_blocks_released, + ), + }; + } +}; + +fn bit_set_masks(bit_set: DynamicBitSetUnmanaged) []MaskInt { + const len = div_ceil(bit_set.bit_length, @bitSizeOf(MaskInt)); + return bit_set.masks[0..len]; +} + +test "FreeSet block shard count" { + if (constants.block_size != 64 * KiB) return; + const blocks_in_tb = @divExact(1 << 40, constants.block_size); + try test_block_shards_count(5120 * 8, 10 * blocks_in_tb); + try test_block_shards_count(5120 * 8 - 1, 10 * blocks_in_tb - FreeSet.shard_bits); + try test_block_shards_count(1, FreeSet.shard_bits); // Must be at least one index bit. +} + +fn test_block_shards_count(expect_shards_count: usize, blocks_count: usize) !void { + const gpa = std.testing.allocator; + + var set = try FreeSet.open_empty(gpa, blocks_count); + defer set.deinit(gpa); + + try std.testing.expectEqual(expect_shards_count, set.index.bit_length); +} + +test "FreeSet highest_address_acquired" { + const expectEqual = std.testing.expectEqual; + const blocks_count = FreeSet.shard_bits; + const gpa = std.testing.allocator; + + var set = try FreeSet.open_empty(gpa, blocks_count); + defer set.deinit(gpa); + + { + const reservation = set.reserve(6).?; + defer set.forfeit(reservation); + + try expectEqual(@as(?u64, null), set.highest_address_acquired()); + try expectEqual(@as(?u64, 1), set.acquire(reservation)); + try expectEqual(@as(?u64, 2), set.acquire(reservation)); + try expectEqual(@as(?u64, 3), set.acquire(reservation)); + } + + try expectEqual(@as(?u64, 3), set.highest_address_acquired()); + + set.release(2); + set.free(2); + try expectEqual(@as(?u64, 3), set.highest_address_acquired()); + + set.release(3); + set.free(3); + try expectEqual(@as(?u64, 1), set.highest_address_acquired()); + + set.release(1); + set.free(1); + try expectEqual(@as(?u64, null), set.highest_address_acquired()); + + { + const reservation = set.reserve(6).?; + defer set.forfeit(reservation); + + try expectEqual(@as(?u64, 1), set.acquire(reservation)); + try expectEqual(@as(?u64, 2), set.acquire(reservation)); + try expectEqual(@as(?u64, 3), set.acquire(reservation)); + } + + { + set.release(3); + try expectEqual(@as(?u64, 3), set.highest_address_acquired()); + + set.free(3); + try expectEqual(@as(?u64, 2), set.highest_address_acquired()); + } +} + +test "FreeSet acquire/release" { + try test_acquire_release(FreeSet.shard_bits); + try test_acquire_release(2 * FreeSet.shard_bits); + try test_acquire_release(63 * FreeSet.shard_bits); + try test_acquire_release(64 * FreeSet.shard_bits); + try test_acquire_release(65 * FreeSet.shard_bits); +} + +fn test_acquire_release(blocks_count: usize) !void { + const gpa = std.testing.allocator; + const expectEqual = std.testing.expectEqual; + // Acquire everything, then release, then acquire again. + var set = try FreeSet.open_empty(gpa, blocks_count); + defer set.deinit(gpa); + + var empty = try FreeSet.open_empty(gpa, blocks_count); + defer empty.deinit(gpa); + + { + const reservation = set.reserve(blocks_count).?; + defer set.forfeit(reservation); + + for (0..blocks_count) |i| { + try expectEqual(@as(?u64, i + 1), set.acquire(reservation)); + } + try expectEqual(@as(?u64, null), set.acquire(reservation)); + } + + try expectEqual(@as(u64, set.blocks_acquired.bit_length), set.count_acquired()); + try expectEqual(@as(u64, 0), set.count_free()); + + { + for (0..blocks_count) |i| { + set.release(@as(u64, i + 1)); + set.free(@as(u64, i + 1)); + } + try expect_free_set_equal(empty, set); + } + + try expectEqual(@as(u64, 0), set.count_acquired()); + try expectEqual(@as(u64, set.blocks_acquired.bit_length), set.count_free()); + + { + const reservation = set.reserve(blocks_count).?; + defer set.forfeit(reservation); + + for (0..blocks_count) |i| { + try expectEqual(@as(?u64, i + 1), set.acquire(reservation)); + } + try expectEqual(@as(?u64, null), set.acquire(reservation)); + } +} + +test "FreeSet.reserve/acquire" { + const gpa = std.testing.allocator; + const blocks_count_total = 4096; + var set = try FreeSet.open_empty(gpa, blocks_count_total); + defer set.deinit(gpa); + + // At most `blocks_count_total` blocks are initially available for reservation. + try std.testing.expectEqual(set.reserve(blocks_count_total + 1), null); + const r1 = set.reserve(blocks_count_total - 1); + const r2 = set.reserve(1); + try std.testing.expectEqual(set.reserve(1), null); + set.forfeit(r1.?); + set.forfeit(r2.?); + + var address: usize = 1; // Start at 1 because addresses are >0. + { + const reservation = set.reserve(2).?; + defer set.forfeit(reservation); + + try std.testing.expectEqual(set.acquire(reservation), address + 0); + try std.testing.expectEqual(set.acquire(reservation), address + 1); + try std.testing.expectEqual(set.acquire(reservation), null); + } + address += 2; + + { + // Blocks are acquired from the target reservation. + const reservation_1 = set.reserve(2).?; + const reservation_2 = set.reserve(2).?; + defer set.forfeit(reservation_1); + defer set.forfeit(reservation_2); + + try std.testing.expectEqual(set.acquire(reservation_1), address + 0); + try std.testing.expectEqual(set.acquire(reservation_2), address + 2); + try std.testing.expectEqual(set.acquire(reservation_1), address + 1); + try std.testing.expectEqual(set.acquire(reservation_1), null); + try std.testing.expectEqual(set.acquire(reservation_2), address + 3); + try std.testing.expectEqual(set.acquire(reservation_2), null); + } + address += 4; +} + +test "FreeSet checkpoint" { + const gpa = std.testing.allocator; + const expectEqual = std.testing.expectEqual; + const blocks_count = FreeSet.shard_bits; + var set = try FreeSet.open_empty(gpa, blocks_count); + defer set.deinit(gpa); + + var empty = try FreeSet.open_empty(gpa, blocks_count); + defer empty.deinit(gpa); + + var full = try FreeSet.open_empty(gpa, blocks_count); + defer full.deinit(gpa); + + { + // Acquire all of `full`'s blocks. + const reservation = full.reserve(blocks_count).?; + defer full.forfeit(reservation); + + for (0..full.blocks_acquired.bit_length) |i| { + try expectEqual(@as(?u64, i + 1), full.acquire(reservation)); + } + } + + { + // Acquire & stage-release every block. + const reservation = set.reserve(blocks_count).?; + defer set.forfeit(reservation); + + for (0..set.blocks_acquired.bit_length) |i| { + try expectEqual(@as(?u64, i + 1), set.acquire(reservation)); + set.release(i + 1); + + // These count functions treat staged blocks as acquired. + try expectEqual(@as(u64, i + 1), set.count_acquired()); + try expectEqual(@as(u64, set.blocks_acquired.bit_length - i - 1), set.count_free()); + } + // All blocks are still acquired, though staged to release at the next checkpoint. + try expectEqual(@as(?u64, null), set.acquire(reservation)); + } + + // Perform checkpoint-related operations. + set.mark_checkpoint_not_durable(); + set.mark_checkpoint_durable(); + + try expect_free_set_equal(empty, set); + try expectEqual(@as(usize, 0), set.blocks_released.count()); + + { + // Allocate & stage-release all blocks again. + const reservation = set.reserve(blocks_count).?; + defer set.forfeit(reservation); + + for (0..set.blocks_acquired.bit_length) |i| { + try expectEqual(@as(?u64, i + 1), set.acquire(reservation)); + set.release(i + 1); + } + } + + const set_encoded_blocks_acquired = try gpa.alignedAlloc( + u8, + @alignOf(FreeSet.Word), + set.encode_size_max(), + ); + const set_encoded_blocks_released = try gpa.alignedAlloc( + u8, + @alignOf(FreeSet.Word), + set.encode_size_max(), + ); + + defer gpa.free(set_encoded_blocks_acquired); + defer gpa.free(set_encoded_blocks_released); + + var set_decoded = try FreeSet.init_empty(gpa, blocks_count); + + defer set_decoded.deinit(gpa); + + { + const free_set_encoded = set.encode_chunks( + &.{set_encoded_blocks_acquired}, + &.{set_encoded_blocks_released}, + ); + + set_decoded.decode_chunks( + &.{set_encoded_blocks_acquired[0..free_set_encoded.encoded_size_blocks_acquired]}, + &.{set_encoded_blocks_released[0..free_set_encoded.encoded_size_blocks_released]}, + ); + try expect_free_set_equal(set, set_decoded); + } + + { + const free_set_encoded = full.encode_chunks( + &.{set_encoded_blocks_acquired}, + &.{set_encoded_blocks_released}, + ); + + set_decoded.reset(); + set_decoded.decode_chunks( + &.{set_encoded_blocks_acquired[0..free_set_encoded.encoded_size_blocks_acquired]}, + &.{set_encoded_blocks_released[0..free_set_encoded.encoded_size_blocks_released]}, + ); + try expect_free_set_equal(full, set_decoded); + } +} + +test "FreeSet encode, decode, encode" { + const shard_bits = FreeSet.shard_bits / @bitSizeOf(usize); + const gpa = std.testing.allocator; + + // Uniform. + try test_encode(&.{.{ .fill = .uniform_ones, .words = shard_bits }}); + try test_encode(&.{.{ .fill = .uniform_zeros, .words = shard_bits }}); + try test_encode(&.{.{ .fill = .literal, .words = shard_bits }}); + try test_encode(&.{.{ .fill = .uniform_ones, .words = std.math.maxInt(u16) + 1 }}); + + // Mixed. + try test_encode(&.{ + .{ .fill = .uniform_ones, .words = shard_bits / 4 }, + .{ .fill = .uniform_zeros, .words = shard_bits / 4 }, + .{ .fill = .literal, .words = shard_bits / 4 }, + .{ .fill = .uniform_ones, .words = shard_bits / 4 }, + }); + + // Random. + const seed = std.crypto.random.int(u64); + var prng = stdx.PRNG.from_seed(seed); + + const fills = [_]TestPatternFill{ .uniform_ones, .uniform_zeros, .literal }; + for (0..10) |_| { + var patterns = std.ArrayList(TestPattern).init(gpa); + defer patterns.deinit(); + + for (0..shard_bits) |_| { + try patterns.append(.{ + .fill = fills[prng.index(fills)], + .words = 1, + }); + } + try test_encode(patterns.items); + } +} + +const TestPattern = struct { + fill: TestPatternFill, + words: usize, +}; + +const TestPatternFill = enum { uniform_ones, uniform_zeros, literal }; + +fn test_encode(patterns: []const TestPattern) !void { + const gpa = std.testing.allocator; + const seed = std.crypto.random.int(u64); + var prng = stdx.PRNG.from_seed(seed); + + var blocks_count: usize = 0; + for (patterns) |pattern| blocks_count += pattern.words * @bitSizeOf(usize); + + var decoded_expect = try FreeSet.open_empty(gpa, blocks_count); + defer decoded_expect.deinit(gpa); + + { + // The `index` will start out one-filled. Every pattern containing a zero will update the + // corresponding index bit with a zero (probably multiple times) to ensure it ends up synced + // with `blocks`. + decoded_expect.index.toggleAll(); + assert(decoded_expect.index.count() == decoded_expect.index.capacity()); + + // Fill the bitset according to the patterns. + var blocks = bit_set_masks(decoded_expect.blocks_acquired); + var blocks_offset: usize = 0; + for (patterns) |pattern| { + for (0..pattern.words) |_| { + blocks[blocks_offset] = switch (pattern.fill) { + .uniform_ones => ~@as(usize, 0), + .uniform_zeros => 0, + .literal => prng.range_inclusive(usize, 1, std.math.maxInt(usize) - 1), + }; + const index_bit = blocks_offset * @bitSizeOf(usize) / FreeSet.shard_bits; + if (pattern.fill != .uniform_ones) decoded_expect.index.unset(index_bit); + blocks_offset += 1; + } + } + assert(blocks_offset == blocks.len); + } + + var encoded = try gpa.alignedAlloc( + u8, + @alignOf(FreeSet.Word), + decoded_expect.encode_size_max(), + ); + defer gpa.free(encoded); + + try std.testing.expectEqual(encoded.len % 8, 0); + const encoded_length = decoded_expect.encode(.blocks_acquired, &.{encoded}); + + var decoded_actual = try FreeSet.init_empty(gpa, blocks_count); + defer decoded_actual.deinit(gpa); + + decoded_actual.decode_chunks(&.{encoded[0..encoded_length]}, &.{}); + try expect_free_set_equal(decoded_expect, decoded_actual); +} + +fn expect_free_set_equal(a: FreeSet, b: FreeSet) !void { + try expect_bit_set_equal(a.blocks_acquired, b.blocks_acquired); + try expect_bit_set_equal(a.blocks_released, b.blocks_released); + try expect_bit_set_equal(a.index, b.index); + + try std.testing.expectEqual( + a.blocks_released_prior_checkpoint_durability.count(), + b.blocks_released_prior_checkpoint_durability.count(), + ); + + for ( + a.blocks_released_prior_checkpoint_durability.keys(), + b.blocks_released_prior_checkpoint_durability.keys(), + ) |address_a, address_b| { + assert(address_a == address_b); + } +} + +fn expect_bit_set_equal(a: DynamicBitSetUnmanaged, b: DynamicBitSetUnmanaged) !void { + try std.testing.expectEqual(a.bit_length, b.bit_length); + const a_masks = bit_set_masks(a); + const b_masks = bit_set_masks(b); + for (a_masks, 0..) |aw, i| try std.testing.expectEqual(aw, b_masks[i]); +} + +test "FreeSet decode small bitset into large bitset" { + const gpa = std.testing.allocator; + const shard_bits = FreeSet.shard_bits; + var small_set = try FreeSet.open_empty(gpa, shard_bits); + defer small_set.deinit(gpa); + + { + // Set up a small bitset (with blocks_count==shard_bits) with no free blocks. + const reservation = small_set.reserve(small_set.blocks_acquired.bit_length).?; + defer small_set.forfeit(reservation); + + for (0..small_set.blocks_acquired.bit_length) |_| { + _ = small_set.acquire(reservation); + } + } + + var small_buffer = try gpa.alignedAlloc( + u8, + @alignOf(usize), + small_set.encode_size_max(), + ); + defer gpa.free(small_buffer); + + const small_buffer_written = small_set.encode(.blocks_acquired, &.{small_buffer}); + + // Decode the serialized small bitset into a larger bitset (with blocks_count==2*shard_bits). + var big_set = try FreeSet.init_empty(gpa, 2 * shard_bits); + defer big_set.deinit(gpa); + + big_set.decode(.blocks_acquired, &.{small_buffer[0..small_buffer_written]}); + big_set.opened = true; + + for (0..2 * shard_bits) |block| { + const address = block + 1; + try std.testing.expectEqual(shard_bits <= block, big_set.is_free(address)); + } +} + +test "FreeSet encode/decode manual" { + const encoded_expect = mem.sliceAsBytes(&[_]usize{ + // Mask 1: run of 2 words of 0s, then 3 literals + 0 | (2 << 1) | (3 << 32), + 0b10101010_10101010_10101010_10101010_10101010_10101010_10101010_10101010, // literal 1 + 0b01010101_01010101_01010101_01010101_01010101_01010101_01010101_01010101, // literal 2 + 0b10101010_10101010_10101010_10101010_10101010_10101010_10101010_10101010, // literal 3 + // Mask 2: run of 59 words of 1s, then 0 literals + // + // 59 is chosen so that because the blocks_count must be a multiple of the shard size: + // shard_bits = 4096 bits = 64 words × 64 bits/word = (2+3+59)*64 + 1 | ((64 - 5) << 1), + }); + const decoded_expect = [_]usize{ + 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, // run 1 + 0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00000000, + 0b10101010_10101010_10101010_10101010_10101010_10101010_10101010_10101010, // literal 1 + 0b01010101_01010101_01010101_01010101_01010101_01010101_01010101_01010101, // literal 2 + 0b10101010_10101010_10101010_10101010_10101010_10101010_10101010_10101010, // literal 3 + } ++ ([1]usize{~@as(usize, 0)} ** (64 - 5)); + const blocks_count = decoded_expect.len * @bitSizeOf(usize); + + const gpa = std.testing.allocator; + // Test decode. + var decoded_actual = try FreeSet.init_empty(gpa, blocks_count); + defer decoded_actual.deinit(gpa); + + decoded_actual.decode(.blocks_acquired, &.{encoded_expect}); + + try std.testing.expectEqual( + decoded_expect.len, + bit_set_masks(decoded_actual.blocks_acquired).len, + ); + try std.testing.expectEqualSlices( + usize, + &decoded_expect, + bit_set_masks(decoded_actual.blocks_acquired), + ); + + // Test encode. + const encoded_actual = try gpa.alignedAlloc( + u8, + @alignOf(usize), + decoded_actual.encode_size_max(), + ); + defer gpa.free(encoded_actual); + + // Pretend `opened` and `checkpoint_durable` are True as it is asserted in `encode`. + decoded_actual.opened = true; + decoded_actual.checkpoint_durable = true; + const encoded_actual_length = decoded_actual.encode(.blocks_acquired, &.{encoded_actual}); + try std.testing.expectEqual(encoded_expect.len, encoded_actual_length); +} + +/// Returns the index of the first set/unset bit (relative to the start of the bitset) within +/// the range bit_min…bit_max (inclusive…exclusive). +fn find_bit( + bit_set: DynamicBitSetUnmanaged, + bit_min: usize, + bit_max: usize, + comptime bit_kind: std.bit_set.IteratorOptions.Type, +) ?usize { + assert(bit_max >= bit_min); + assert(bit_max <= bit_set.bit_length); + + const word_start = @divFloor(bit_min, @bitSizeOf(MaskInt)); // Inclusive. + const word_offset = @mod(bit_min, @bitSizeOf(MaskInt)); + const word_end = div_ceil(bit_max, @bitSizeOf(MaskInt)); // Exclusive. + const words_total = div_ceil(bit_set.bit_length, @bitSizeOf(MaskInt)); + if (word_end == word_start) return null; + assert(word_end > word_start); + + // Only iterate over the subset of bits that were requested. + var iterator = bit_set.iterator(.{ .kind = bit_kind }); + iterator.words_remain = bit_set.masks[word_start + 1 .. word_end]; + + const mask = ~@as(MaskInt, 0); + var word = bit_set.masks[word_start]; + if (bit_kind == .unset) word = ~word; + iterator.bits_remain = word & std.math.shl(MaskInt, mask, word_offset); + + if (word_end != words_total) iterator.last_word_mask = mask; + + const b = bit_min - word_offset + (iterator.next() orelse return null); + return if (b < bit_max) b else null; +} + +test "find_bit" { + var prng = stdx.PRNG.from_seed_testing(); + + const gpa = std.testing.allocator; + for (1..(@bitSizeOf(std.DynamicBitSetUnmanaged.MaskInt) * 4) + 1) |bit_length| { + var bit_set = try std.DynamicBitSetUnmanaged.initEmpty(gpa, bit_length); + defer bit_set.deinit(gpa); + + const p = prng.int_inclusive(usize, 100); + + for (0..bit_length) |b| bit_set.setValue(b, p < prng.int_inclusive(usize, 100)); + + for (0..20) |_| try test_find_bit(&prng, bit_set, .set); + for (20..40) |_| try test_find_bit(&prng, bit_set, .unset); + } +} + +fn test_find_bit( + prng: *stdx.PRNG, + bit_set: DynamicBitSetUnmanaged, + comptime bit_kind: std.bit_set.IteratorOptions.Type, +) !void { + const bit_min = prng.int_inclusive(usize, bit_set.bit_length - 1); + const bit_max = prng.range_inclusive(usize, bit_min, bit_set.bit_length); + assert(bit_max >= bit_min); + assert(bit_max <= bit_set.bit_length); + + const bit_actual = find_bit(bit_set, bit_min, bit_max, bit_kind); + if (bit_actual) |bit| { + assert(bit_set.isSet(bit) == (bit_kind == .set)); + assert(bit >= bit_min); + assert(bit < bit_max); + } + + var iterator = bit_set.iterator(.{ .kind = bit_kind }); + while (iterator.next()) |bit| { + if (bit_min <= bit and bit < bit_max) { + try std.testing.expectEqual(bit_actual, bit); + break; + } + } else { + try std.testing.expectEqual(bit_actual, null); + } +} + +test "FreeSet.acquire part-way through a shard" { + const gpa = std.testing.allocator; + var set = try FreeSet.open_empty(gpa, FreeSet.shard_bits * 3); + defer set.deinit(gpa); + + const reservation_a = set.reserve(1).?; + defer set.forfeit(reservation_a); + + const reservation_b = set.reserve(2 * FreeSet.shard_bits).?; + defer set.forfeit(reservation_b); + + // Acquire all of reservation B. + // At the end, the first shard still has a bit free (reserved by A). + for (0..reservation_b.block_count) |i| { + const address = set.acquire(reservation_b).?; + try std.testing.expectEqual(address - 1, reservation_a.block_count + i); + set.verify_index(); + } + try std.testing.expectEqual(set.acquire(reservation_b), null); +} + +test "FreeSet decode big bitset into small bitset" { + const shard_bits = FreeSet.shard_bits; + + const gpa = std.testing.allocator; + var big_set = try FreeSet.open_empty(gpa, 2 * shard_bits); + defer big_set.deinit(gpa); + + { + // Set up a big bitset (with blocks_count==2*shard_bits) with half the blocks free. + const acquired_block_count = @divFloor(big_set.blocks_acquired.bit_length, 2); + const reservation = big_set.reserve(acquired_block_count).?; + defer big_set.forfeit(reservation); + + for (0..acquired_block_count) |_| { + _ = big_set.acquire(reservation); + } + } + + var big_buffer = try gpa.alignedAlloc( + u8, + @alignOf(usize), + big_set.encode_size_max(), + ); + defer gpa.free(big_buffer); + + const big_buffer_written = big_set.encode(.blocks_acquired, &.{big_buffer}); + + // Decode the serialized big bitset into a smaller bitset (with blocks_count==shard_bits). + var small_set = try FreeSet.init_empty(gpa, shard_bits); + defer small_set.deinit(gpa); + + small_set.decode(.blocks_acquired, &.{big_buffer[0..big_buffer_written]}); + for (0..shard_bits) |block| { + const address = block + 1; + try std.testing.expectEqual(big_set.is_free(address), false); + } +} diff --git a/ocam/src/vsr/free_set_fuzz.zig b/ocam/src/vsr/free_set_fuzz.zig new file mode 100644 index 00000000..56a5a00a --- /dev/null +++ b/ocam/src/vsr/free_set_fuzz.zig @@ -0,0 +1,315 @@ +//! Fuzz FreeSet reserve/acquire/release flow. +//! +//! This fuzzer does *not* cover FreeSet encoding/decoding. +const std = @import("std"); +const assert = std.debug.assert; +const log = std.log.scoped(.fuzz_vsr_free_set); +const stdx = @import("stdx"); + +const FreeSet = @import("./free_set.zig").FreeSet; +const Reservation = @import("./free_set.zig").Reservation; +const fuzz = @import("../testing/fuzz.zig"); + +pub fn main(gpa: std.mem.Allocator, args: fuzz.FuzzArgs) !void { + var prng = stdx.PRNG.from_seed(args.seed); + + const blocks_count = FreeSet.shard_bits * prng.range_inclusive(usize, 1, 10); + const events_count = @min( + args.events_max orelse @as(usize, 2_000_000), + fuzz.random_int_exponential(&prng, usize, blocks_count * 100), + ); + const events = try generate_events(gpa, &prng, .{ + .blocks_count = blocks_count, + .events_count = events_count, + }); + defer gpa.free(events); + + try run_fuzz(gpa, &prng, blocks_count, events); +} + +fn run_fuzz( + gpa: std.mem.Allocator, + prng: *stdx.PRNG, + blocks_count: usize, + events: []const FreeSetEvent, +) !void { + var free_set = try FreeSet.open_empty(gpa, blocks_count); + defer free_set.deinit(gpa); + + var free_set_model = try FreeSetModel.init(gpa, blocks_count); + defer free_set_model.deinit(gpa); + + var active_reservations = std.ArrayList(Reservation).init(gpa); + defer active_reservations.deinit(); + + var active_addresses = std.ArrayList(u64).init(gpa); + defer active_addresses.deinit(); + + for (events) |event| { + log.debug("event={}", .{event}); + switch (event) { + .reserve => |reserve| { + const reservation_actual = free_set.reserve(reserve.blocks); + const reservation_expect = free_set_model.reserve(reserve.blocks); + assert(std.meta.eql(reservation_expect, reservation_actual)); + + if (reservation_expect) |reservation| { + try active_reservations.append(reservation); + } + }, + .forfeit => { + prng.shuffle(Reservation, active_reservations.items); + for (active_reservations.items) |reservation| { + free_set.forfeit(reservation); + free_set_model.forfeit(reservation); + } + active_reservations.clearRetainingCapacity(); + }, + .acquire => |data| { + if (active_reservations.items.len == 0) continue; + const reservation = active_reservations.items[ + data.reservation % active_reservations.items.len + ]; + const address_actual = free_set.acquire(reservation); + const address_expect = free_set_model.acquire(reservation); + assert(std.meta.eql(address_expect, address_actual)); + if (address_expect) |address| { + try active_addresses.append(address); + } + }, + .release => |data| { + if (active_addresses.items.len == 0) continue; + + const address_index = data.address % active_addresses.items.len; + const address = active_addresses.swapRemove(address_index); + free_set.release(address); + free_set_model.release(address); + }, + .checkpoint => { + prng.shuffle(Reservation, active_reservations.items); + for (active_reservations.items) |reservation| { + free_set.forfeit(reservation); + free_set_model.forfeit(reservation); + } + active_reservations.clearRetainingCapacity(); + + // The fuzzer runs in a single process, all checkpoints are trivially durable. + free_set.mark_checkpoint_not_durable(); + free_set.mark_checkpoint_durable(); + + free_set_model.checkpoint(); + }, + } + + assert(free_set_model.count_reservations() == free_set.count_reservations()); + assert(free_set_model.count_free() == free_set.count_free()); + assert(free_set_model.count_acquired() == free_set.count_acquired()); + assert(std.meta.eql( + free_set_model.highest_address_acquired(), + free_set.highest_address_acquired(), + )); + } +} + +const FreeSetEvent = union(enum) { + reserve: struct { blocks: usize }, + forfeit: void, + acquire: struct { reservation: usize }, + release: struct { address: usize }, + checkpoint: void, + + const Tag = std.meta.Tag(FreeSetEvent); +}; + +fn generate_events(gpa: std.mem.Allocator, prng: *stdx.PRNG, options: struct { + blocks_count: usize, + events_count: usize, +}) ![]const FreeSetEvent { + const event_weights = stdx.PRNG.EnumWeightsType(FreeSetEvent.Tag){ + .reserve = prng.range_inclusive(u64, 1, 100), + .forfeit = 1, + .acquire = prng.range_inclusive(u64, 1, 1000), + .release = if (prng.boolean()) 0 else prng.range_inclusive(u64, 0, 500), + .checkpoint = fuzz.random_int_exponential(prng, u64, 10), + }; + + const events = try gpa.alloc(FreeSetEvent, options.events_count); + errdefer gpa.free(events); + + log.info("event_weights = {:.2}", .{event_weights}); + log.info("event_count = {d}", .{events.len}); + + const reservation_blocks_mean = + prng.range_inclusive(usize, 1, @divFloor(options.blocks_count, 20)); + for (events) |*event| { + event.* = switch (prng.enum_weighted(FreeSetEvent.Tag, event_weights)) { + .reserve => FreeSetEvent{ .reserve = .{ + .blocks = 1 + fuzz.random_int_exponential(prng, usize, reservation_blocks_mean), + } }, + .forfeit => FreeSetEvent{ .forfeit = {} }, + .acquire => FreeSetEvent{ .acquire = .{ .reservation = prng.int(usize) } }, + .release => FreeSetEvent{ .release = .{ + .address = prng.int(usize), + } }, + .checkpoint => FreeSetEvent{ .checkpoint = {} }, + }; + } + return events; +} + +const FreeSetModel = struct { + /// Set bits indicate acquired blocks. + blocks_acquired: std.DynamicBitSetUnmanaged, + + /// Set bits indicate blocks that will be released at the next checkpoint. + blocks_released: std.DynamicBitSetUnmanaged, + + /// Set bits indicate blocks that are currently reserved and not yet forfeited. + blocks_reserved: std.DynamicBitSetUnmanaged, + + reservation_count: usize = 0, + reservation_session: usize = 1, + + fn init(gpa: std.mem.Allocator, blocks_count: usize) !FreeSetModel { + var blocks_acquired = try std.DynamicBitSetUnmanaged.initEmpty(gpa, blocks_count); + errdefer blocks_acquired.deinit(gpa); + + var blocks_released = try std.DynamicBitSetUnmanaged.initEmpty(gpa, blocks_count); + errdefer blocks_released.deinit(gpa); + + var blocks_reserved = try std.DynamicBitSetUnmanaged.initEmpty(gpa, blocks_count); + errdefer blocks_reserved.deinit(gpa); + + return FreeSetModel{ + .blocks_acquired = blocks_acquired, + .blocks_released = blocks_released, + .blocks_reserved = blocks_reserved, + }; + } + + fn deinit(set: *FreeSetModel, gpa: std.mem.Allocator) void { + set.blocks_acquired.deinit(gpa); + set.blocks_released.deinit(gpa); + set.blocks_reserved.deinit(gpa); + } + + pub fn count_reservations(set: FreeSetModel) usize { + return set.reservation_count; + } + + pub fn count_free(set: FreeSetModel) usize { + return set.blocks_acquired.capacity() - set.blocks_acquired.count(); + } + + pub fn count_acquired(set: FreeSetModel) usize { + return set.blocks_acquired.count(); + } + + pub fn highest_address_acquired(set: FreeSetModel) ?u64 { + var it = set.blocks_acquired.iterator(.{ + .direction = .reverse, + }); + const block = it.next() orelse return null; + return block + 1; + } + + pub fn reserve(set: *FreeSetModel, reserve_count: usize) ?Reservation { + assert(reserve_count > 0); + + var blocks_found_free: usize = 0; + var iterator = set.blocks_acquired.iterator(.{ .kind = .unset }); + const blocks_reserved_count = set.blocks_reserved.count(); + while (iterator.next()) |block| { + if (block < blocks_reserved_count) { + assert(set.blocks_reserved.isSet(block)); + continue; + } + + blocks_found_free += 1; + if (blocks_found_free == reserve_count) { + const block_base = blocks_reserved_count; + const block_count = block + 1 - block_base; + + var i: usize = 0; + while (i < block_count) : (i += 1) set.blocks_reserved.set(block_base + i); + + set.reservation_count += 1; + return Reservation{ + .block_base = block_base, + .block_count = block_count, + .session = set.reservation_session, + }; + } + } + return null; + } + + pub fn forfeit(set: *FreeSetModel, reservation: Reservation) void { + set.assert_reservation_active(reservation); + set.reservation_count -= 1; + + var i: usize = 0; + while (i < reservation.block_count) : (i += 1) { + set.blocks_reserved.unset(reservation.block_base + i); + } + + if (set.reservation_count == 0) { + set.reservation_session +%= 1; + assert(set.blocks_reserved.count() == 0); + } + } + + pub fn acquire(set: *FreeSetModel, reservation: Reservation) ?u64 { + assert(reservation.block_count > 0); + assert(reservation.block_base < set.blocks_acquired.capacity()); + assert(reservation.session == set.reservation_session); + set.assert_reservation_active(reservation); + + var iterator = set.blocks_acquired.iterator(.{ .kind = .unset }); + while (iterator.next()) |block| { + if (block >= reservation.block_base and + block < reservation.block_base + reservation.block_count) + { + assert(!set.blocks_acquired.isSet(block)); + set.blocks_acquired.set(block); + + const address = block + 1; + return address; + } + } + return null; + } + + pub fn is_free(set: *FreeSetModel, address: u64) bool { + return !set.blocks_acquired.isSet(address - 1); + } + + pub fn release(set: *FreeSetModel, address: u64) void { + const block = address - 1; + set.blocks_released.set(block); + } + + pub fn checkpoint(set: *FreeSetModel) void { + assert(set.blocks_reserved.count() == 0); + + var iterator = set.blocks_released.iterator(.{}); + while (iterator.next()) |block| { + assert(set.blocks_released.isSet(block)); + assert(set.blocks_acquired.isSet(block)); + + set.blocks_released.unset(block); + set.blocks_acquired.unset(block); + } + assert(set.blocks_released.count() == 0); + } + + fn assert_reservation_active(set: FreeSetModel, reservation: Reservation) void { + assert(set.reservation_count > 0); + assert(set.reservation_session == reservation.session); + + var i: usize = 0; + while (i < reservation.block_count) : (i += 1) { + assert(set.blocks_reserved.isSet(reservation.block_base + i)); + } + } +}; diff --git a/ocam/src/vsr/grid.zig b/ocam/src/vsr/grid.zig new file mode 100644 index 00000000..b2abed82 --- /dev/null +++ b/ocam/src/vsr/grid.zig @@ -0,0 +1,1722 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const mem = std.mem; + +const constants = @import("../constants.zig"); +const vsr = @import("../vsr.zig"); +const schema = @import("../lsm/schema.zig"); + +const SuperBlockType = vsr.SuperBlockType; +const QueueType = @import("../queue.zig").QueueType; +const IOPSType = stdx.IOPSType; +const SetAssociativeCacheType = @import("../lsm/set_associative_cache.zig").SetAssociativeCacheType; +const stdx = @import("stdx"); +const GridBlocksMissing = @import("./grid_blocks_missing.zig").GridBlocksMissing; +const Tracer = vsr.trace.Tracer; + +const FreeSet = @import("./free_set.zig").FreeSet; + +const log = stdx.log.scoped(.grid); + +pub const BlockPtr = *align(constants.sector_size) [constants.block_size]u8; +pub const BlockPtrConst = *align(constants.sector_size) const [constants.block_size]u8; + +/// The Grid provides access to on-disk blocks (blobs of `block_size` bytes). +/// Each block is identified by an "address" (`u64`, beginning at 1). +/// +/// Recently/frequently-used blocks are transparently cached in memory. +pub fn GridType(comptime Storage: type) type { + const block_size = constants.block_size; + const SuperBlock = SuperBlockType(Storage); + + return struct { + const Grid = @This(); + const CheckpointTrailer = vsr.CheckpointTrailerType(Storage); + + pub const read_iops_max = constants.grid_iops_read_max; + pub const write_iops_max = constants.grid_iops_write_max; + + pub const RepairTable = GridBlocksMissing.RepairTable; + pub const RepairTableResult = GridBlocksMissing.RepairTableResult; + pub const Reservation = @import("./free_set.zig").Reservation; + + // Grid just reuses the Storage's NextTick abstraction for simplicity. + pub const NextTick = Storage.NextTick; + + pub const Write = struct { + callback: *const fn (*Grid.Write) void, + address: u64, + repair: bool, + block: *BlockPtr, + /// The current checkpoint when the write began. + /// Verifies that the checkpoint does not advance during the (non-repair) write. + checkpoint_id: u128, + + /// Link for the Grid.write_queue linked list. + link: QueueType(Write).Link = .{}, + }; + + const WriteIOP = struct { + grid: *Grid, + completion: Storage.Write, + write: *Write, + }; + + const ReadBlockCallback = union(enum) { + /// If the local read fails, report the error. + from_local_storage: *const fn (*Grid.Read, ReadBlockResult) void, + /// If the local read fails, this read will be added to a linked list, which Replica can + /// then interrogate each tick(). The callback passed to this function won't be called + /// until the block has been recovered. + from_local_or_global_storage: *const fn (*Grid.Read, BlockPtrConst) void, + }; + + pub const Read = struct { + callback: ReadBlockCallback, + address: u64, + checksum: u128, + /// The current checkpoint when the read began. + /// Used to verify that the checkpoint does not advance while the read is in progress. + checkpoint_id: u128, + checkpoint_durable: bool, + + /// When coherent=true: + /// - the block (address+checksum) is part of the current checkpoint. + /// - the read will complete before the next checkpoint occurs. + /// - callback == .from_local_or_global_storage + /// When coherent=false: + /// - the block (address+checksum) is not necessarily part of the current checkpoint. + /// - the read may complete after a future checkpoint. + /// - callback == .from_local_storage + coherent: bool, + cache_read: bool, + cache_write: bool, + pending: ReadPending = .{}, + resolves: QueueType(ReadPending) = QueueType(ReadPending).init(.{ .name = null }), + + grid: *Grid, + next_tick: Grid.NextTick = undefined, + + /// Link for Grid.read_queue/Grid.read_global_queue linked lists. + link: QueueType(Read).Link = .{}, + }; + + /// Although we distinguish between the reasons why the block is invalid, we only use this + /// info for logging, not logic. + pub const ReadBlockResult = union(enum) { + valid: BlockPtrConst, + /// Checksum of block header is invalid. + invalid_checksum, + /// Checksum of block body is invalid. + invalid_checksum_body, + /// The block header is valid, but its `header.command` is not `block`. + /// (This is possible due to misdirected IO). + unexpected_command, + /// The block is valid, but it is not the block we expected. + unexpected_checksum, + /// The block is valid, and it is the block we expected, but the last sector's padding + /// is corrupt, so we will repair it just to be safe. + invalid_padding, + }; + + const ReadPending = struct { + /// Link for Read.resolves linked lists. + link: QueueType(ReadPending).Link = .{}, + }; + + const ReadIOP = struct { + completion: Storage.Read, + read: *Read, + }; + + const cache_interface = struct { + inline fn address_from_address(address: *const u64) u64 { + return address.*; + } + + inline fn hash_address(address: u64) u64 { + assert(address > 0); + return stdx.hash_inline(address); + } + }; + + const set_associative_cache_ways = 16; + + pub const Cache = SetAssociativeCacheType( + u64, + u64, + cache_interface.address_from_address, + cache_interface.hash_address, + .{ + .ways = set_associative_cache_ways, + // layout.cache_line_size isn't actually used to compute anything. Rather, it's + // used by the SetAssociativeCache to assert() on sub-optimal values. In this case, + // it's better to allow the user to be able to run with a much smaller grid cache + // (256MiB vs 1GiB!) than trying to be completely optimal. + .cache_line_size = 16, + .value_alignment = @alignOf(u64), + }, + ); + + superblock: *SuperBlock, + trace: *Tracer, + free_set: FreeSet, + free_set_checkpoint_blocks_acquired: CheckpointTrailer, + free_set_checkpoint_blocks_released: CheckpointTrailer, + + /// Entries in `blocks` correspond to entries in `blocks_references`. + blocks: []align(constants.sector_size) [constants.block_size]u8, + blocks_references: []u8, + blocks_missing: GridBlocksMissing, + + cache: Cache, + /// The block at `cache[x]` is found at `blocks[cache_locations[x]]`. + /// This indirection is necessary because the SetAssociativeCache is not aware of references + /// held by e.g. scans, so it may evict a block we still need. + /// + /// Invariants: + /// - `cache_locations[i] < blocks.len` + /// - `cache_locations[i] != cache_locations[j] iff i != j` + cache_locations: []u32, + + // Invariants: + // - `stash_free.len + stash_used.len == stash_blocks_count` + // - `stash_free.keys`, `stash_used.keys`, and `cache_locations` have no overlapping values. + stash_free: std.AutoArrayHashMapUnmanaged(u32, void), + stash_used: std.AutoArrayHashMapUnmanaged(u32, void), + /// NB: stash_free.count() may exceed stash_available. This occurs when there are multiple + /// references taken to a single block. Even if there are free blocks in `stash_free`, + /// taking more than `stash_available` references is not permitted. + /// + /// Invariants: + /// - stash_available ≤ stash_blocks_count + /// - stash_available ≤ stash_free.count() + stash_available: u32, + + write_iops: IOPSType(WriteIOP, write_iops_max) = .{}, + write_queue: QueueType(Write) = QueueType(Write).init(.{ .name = "grid_write" }), + + // Each read_iops has a corresponding block. + read_iop_blocks: [read_iops_max]BlockPtr, + read_iops: IOPSType(ReadIOP, read_iops_max) = .{}, + read_queue: QueueType(Read) = QueueType(Read).init(.{ .name = "grid_read" }), + + // List of Read.pending's which are in `read_queue` but also waiting for a free `read_iops`. + read_pending_queue: QueueType(ReadPending) = QueueType(ReadPending).init(.{ + .name = "grid_read_pending", + }), + /// List of `Read`s which are waiting for a block repair from another replica. + /// (Reads in this queue have already failed locally). + /// + /// Invariants: + /// - For each read, read.callback=from_local_or_global_storage. + read_global_queue: QueueType(Read) = QueueType(Read).init(.{ .name = "grid_read_global" }), + // True if there's a read that is resolving callbacks. + // If so, the read cache must not be invalidated. + read_resolving: bool = false, + + callback: union(enum) { + none, + open: *const fn (*Grid) void, + checkpoint: *const fn (*Grid) void, + checkpoint_durable: *const fn (*Grid) void, + cancel: *const fn (*Grid) void, + } = .none, + + canceling_tick_context: NextTick = undefined, + + pub fn init(allocator: mem.Allocator, options: struct { + superblock: *SuperBlock, + trace: *Tracer, + cache_blocks_count: u64 = Cache.value_count_max_multiple, + stash_blocks_count: u64, + missing_blocks_max: usize, + missing_tables_max: usize, + blocks_released_prior_checkpoint_durability_max: usize, + }) !Grid { + assert(options.stash_blocks_count > 0); + + var free_set = try FreeSet.init(allocator, .{ + .grid_size_limit = options.superblock.grid_size_limit(), + .blocks_released_prior_checkpoint_durability_max = options + .blocks_released_prior_checkpoint_durability_max, + }); + errdefer free_set.deinit(allocator); + + const free_set_encoded_size_max = free_set.encode_size_max(); + var free_set_checkpoint_blocks_acquired = + try CheckpointTrailer.init(allocator, .free_set, free_set_encoded_size_max); + errdefer free_set_checkpoint_blocks_acquired.deinit(allocator); + + var free_set_checkpoint_blocks_released = + try CheckpointTrailer.init(allocator, .free_set, free_set_encoded_size_max); + errdefer free_set_checkpoint_blocks_released.deinit(allocator); + + const stash_blocks_count = options.stash_blocks_count + + vsr.checkpoint_trailer.block_count_for_trailer_size(free_set_encoded_size_max) * 2 + + 1; // +1 for burst in read_block_callback(); + const blocks_count = options.cache_blocks_count + stash_blocks_count; + const blocks = try allocator.alignedAlloc( + [constants.block_size]u8, + constants.sector_size, + blocks_count, + ); + errdefer allocator.free(blocks); + + const blocks_references = try allocator.alloc(u8, blocks_count); + errdefer allocator.free(blocks_references); + @memset(blocks_references, 0); + + var blocks_missing = try GridBlocksMissing.init(allocator, .{ + .blocks_max = options.missing_blocks_max, + .tables_max = options.missing_tables_max, + }); + errdefer blocks_missing.deinit(allocator); + + var cache = try Cache.init(allocator, options.cache_blocks_count, .{ .name = "grid" }); + errdefer cache.deinit(allocator); + + const cache_locations = try allocator.alloc(u32, options.cache_blocks_count); + errdefer allocator.free(cache_locations); + + var stash_free = std.AutoArrayHashMapUnmanaged(u32, void).empty; + try stash_free.ensureTotalCapacity(allocator, stash_blocks_count); + errdefer stash_free.deinit(allocator); + + var stash_used = std.AutoArrayHashMapUnmanaged(u32, void).empty; + try stash_used.ensureTotalCapacity(allocator, stash_blocks_count); + errdefer stash_used.deinit(allocator); + + for (0..blocks_count) |i| { + const location: u32 = @intCast(i); + if (i < options.cache_blocks_count) { + cache_locations[i] = location; + } else { + stash_free.putAssumeCapacityNoClobber(location, {}); + } + } + assert(stash_free.count() == stash_blocks_count); + + var read_iop_blocks: [read_iops_max]BlockPtr = undefined; + for (&read_iop_blocks) |*read_iop_block| { + const location = stash_free.pop().?.key; + read_iop_block.* = &blocks[location]; + blocks_references[location] += 1; + stash_used.putAssumeCapacityNoClobber(location, {}); + } + + return Grid{ + .superblock = options.superblock, + .trace = options.trace, + .free_set = free_set, + .free_set_checkpoint_blocks_acquired = free_set_checkpoint_blocks_acquired, + .free_set_checkpoint_blocks_released = free_set_checkpoint_blocks_released, + .blocks = blocks, + .blocks_references = blocks_references, + .blocks_missing = blocks_missing, + .cache = cache, + .cache_locations = cache_locations, + .stash_used = stash_used, + .stash_free = stash_free, + .stash_available = @intCast(stash_blocks_count - read_iops_max), + .read_iop_blocks = read_iop_blocks, + }; + } + + pub fn deinit(grid: *Grid, allocator: mem.Allocator) void { + // Release the remaining block references: + for (&grid.read_iop_blocks) |block| grid.block_unref(block); + grid.free_set_checkpoint_blocks_acquired.deinit(allocator); + grid.free_set_checkpoint_blocks_released.deinit(allocator); + + // There are no more outstanding references to blocks. + assert(grid.stash_used.count() == 0); + assert(grid.stash_free.count() == grid.blocks.len - grid.cache_locations.len); + assert(grid.stash_available == grid.blocks.len - grid.cache_locations.len); + + var references: u32 = 0; + for (grid.blocks_references) |ref| references += ref; + assert(references == 0); + + grid.blocks_missing.deinit(allocator); + allocator.free(grid.blocks_references); + allocator.free(grid.blocks); + + grid.stash_used.deinit(allocator); + grid.stash_free.deinit(allocator); + allocator.free(grid.cache_locations); + grid.cache.deinit(allocator); + grid.free_set.deinit(allocator); + + grid.* = undefined; + } + + pub fn open(grid: *Grid, callback: *const fn (*Grid) void) void { + assert(grid.callback == .none); + + grid.callback = .{ .open = callback }; + grid.free_set_checkpoint_blocks_acquired.open( + grid, + grid.superblock.working.free_set_reference(.blocks_acquired), + open_free_set_callback_blocks_acquired, + ); + grid.free_set_checkpoint_blocks_released.open( + grid, + grid.superblock.working.free_set_reference(.blocks_released), + open_free_set_callback_blocks_released, + ); + } + + fn open_free_set_callback_blocks_acquired(trailer: *CheckpointTrailer) void { + assert(trailer.callback == .none); + const grid: *Grid = @fieldParentPtr("free_set_checkpoint_blocks_acquired", trailer); + grid.open_free_set_callback(); + } + + fn open_free_set_callback_blocks_released(trailer: *CheckpointTrailer) void { + assert(trailer.callback == .none); + const grid: *Grid = @fieldParentPtr("free_set_checkpoint_blocks_released", trailer); + grid.open_free_set_callback(); + } + + fn open_free_set_callback(grid: *Grid) void { + assert(grid.free_set_checkpoint_blocks_acquired.callback == .none or + grid.free_set_checkpoint_blocks_released.callback == .none); + + const callback = grid.callback.open; + // May still be reading the CheckpointTrailer for `blocks_acquired`. + if (grid.free_set_checkpoint_blocks_acquired.callback == .open) return; + assert(grid.free_set_checkpoint_blocks_acquired.callback == .none); + + // May still be reading the CheckpointTrailer for `blocks_released`. + if (grid.free_set_checkpoint_blocks_released.callback == .open) return; + assert(grid.free_set_checkpoint_blocks_released.callback == .none); + + { + assert(!grid.free_set.opened); + defer assert(grid.free_set.opened); + + const block_count_encoded_blocks_acquired = + grid.free_set_checkpoint_blocks_acquired.block_count(); + const block_count_encoded_blocks_released = + grid.free_set_checkpoint_blocks_released.block_count(); + grid.free_set.open(.{ + .encoded = .{ + .blocks_acquired = grid.free_set_checkpoint_blocks_acquired.decode_chunks(), + .blocks_released = grid.free_set_checkpoint_blocks_released.decode_chunks(), + }, + .free_set_block_addresses = .{ + .blocks_acquired = grid.free_set_checkpoint_blocks_acquired + .block_addresses[0..block_count_encoded_blocks_acquired], + .blocks_released = grid.free_set_checkpoint_blocks_released + .block_addresses[0..block_count_encoded_blocks_released], + }, + }); + assert((grid.free_set.count_acquired() > 0) == + (grid.free_set_checkpoint_blocks_acquired.size > 0)); + + // Assert that the highest acquired address is compatible with storage_size. + const storage_size: u64 = storage_size: { + var storage_size = vsr.superblock.data_file_size_min; + if (grid.free_set.highest_address_acquired()) |address| { + assert(address > 0); + assert(grid.free_set_checkpoint_blocks_acquired.size > 0); + maybe(grid.free_set_checkpoint_blocks_released.size == 0); + + storage_size += address * constants.block_size; + } else { + assert(grid.free_set_checkpoint_blocks_acquired.size == 0); + assert(grid.free_set_checkpoint_blocks_released.size == 0); + + assert(grid.free_set.count_released() == 0); + } + break :storage_size storage_size; + }; + assert(storage_size == grid.superblock.working.vsr_state.checkpoint.storage_size); + + assert(grid.free_set.count_released() >= + (grid.free_set_checkpoint_blocks_acquired.block_count() + + grid.free_set_checkpoint_blocks_released.block_count())); + + assert(grid.free_set.count_reservations() == 0); + } + grid.callback = .none; + callback(grid); + } + + /// Checkpoint process is delicate: + /// 1. Encode free set. + /// 2. Derive the number of blocks required to store the encoding. + /// 3. Allocate free set blocks for the encoding (in the old checkpoint). + /// 4. Write the free set blocks to disk. + /// 5. Mark the free set's own blocks as released (but not yet free). + /// + /// This function handles step 1, and calls CheckpointTrailer.checkpoint, which handles 2-4. + /// The caller is responsible for calling Grid.mark_checkpoint_not_durable, which handles 5. + pub fn checkpoint(grid: *Grid, callback: *const fn (*Grid) void) void { + assert(grid.callback == .none); + assert(grid.read_global_queue.empty()); + + { + assert(grid.free_set.count_reservations() == 0); + + const free_set_encoded = grid.free_set.encode_chunks( + grid.free_set_checkpoint_blocks_acquired.encode_chunks(), + grid.free_set_checkpoint_blocks_released.encode_chunks(), + ); + + grid.free_set_checkpoint_blocks_acquired.size = + free_set_encoded.encoded_size_blocks_acquired; + grid.free_set_checkpoint_blocks_released.size = + free_set_encoded.encoded_size_blocks_released; + + assert(grid.free_set_checkpoint_blocks_acquired.size % @sizeOf(FreeSet.Word) == 0); + assert(grid.free_set_checkpoint_blocks_released.size % @sizeOf(FreeSet.Word) == 0); + } + + grid.callback = .{ .checkpoint = callback }; + grid.free_set_checkpoint_blocks_acquired + .checkpoint(checkpoint_free_set_blocks_acquired_callback); + grid.free_set_checkpoint_blocks_released + .checkpoint(checkpoint_free_set_blocks_released_callback); + } + + fn checkpoint_free_set_blocks_acquired_callback(trailer: *CheckpointTrailer) void { + assert(trailer.callback == .none); + const grid: *Grid = @fieldParentPtr("free_set_checkpoint_blocks_acquired", trailer); + assert(grid.callback == .checkpoint); + + grid.checkpoint_join(); + } + + fn checkpoint_free_set_blocks_released_callback(trailer: *CheckpointTrailer) void { + assert(trailer.callback == .none); + const grid: *Grid = @fieldParentPtr("free_set_checkpoint_blocks_released", trailer); + assert(grid.callback == .checkpoint); + + grid.checkpoint_join(); + } + + fn checkpoint_join(grid: *Grid) void { + assert(grid.callback == .checkpoint); + assert(grid.read_global_queue.empty()); + + if (grid.free_set_checkpoint_blocks_acquired.callback == .checkpoint) { + return; // Still writing free set `blocks_acquired` bitset. + } + assert(grid.free_set_checkpoint_blocks_acquired.callback == .none); + + if (grid.free_set_checkpoint_blocks_released.callback == .checkpoint) { + return; // Still writing free set `blocks_released` bitset. + } + assert(grid.free_set_checkpoint_blocks_released.callback == .none); + + const callback = grid.callback.checkpoint; + grid.callback = .none; + callback(grid); + } + + /// Mark the current checkpoint as not durable, then release the blocks acquired for the + /// FreeSet checkpoints (to be freed when the *next* checkpoint becomes durable). + /// + /// The ordering is important here, if we were to release these blocks before the checkpoint + /// is marked as not durable, they would erroneously be freed when the *current* checkpoint + /// becomes durable. + pub fn mark_checkpoint_not_durable(grid: *Grid) void { + assert(grid.free_set.checkpoint_durable); + defer assert(!grid.free_set.checkpoint_durable); + + grid.free_set.mark_checkpoint_not_durable(); + grid.release(grid.free_set_checkpoint_blocks_acquired + .block_addresses[0..grid.free_set_checkpoint_blocks_acquired.block_count()]); + grid.release(grid.free_set_checkpoint_blocks_released + .block_addresses[0..grid.free_set_checkpoint_blocks_released.block_count()]); + } + + /// Now that the checkpoint is durable on a commit quorum of replicas: + /// 1. Await all pending repair-writes to blocks that are about to be freed. + /// 2. Mark currently released blocks as free and eligible for acquisition. + /// + /// This function handles step 1. + /// The caller is responsible for calling FreeSet.checkpoint which handles 2. + pub fn checkpoint_durable(grid: *Grid, callback: *const fn (*Grid) void) void { + assert(!grid.free_set.checkpoint_durable); + grid.callback = .{ .checkpoint_durable = callback }; + + grid.blocks_missing.checkpoint_durable_commence(&grid.free_set); + if (grid.blocks_missing.state.checkpoint_durable.aborting == 0) { + grid.checkpoint_durable_join(); + } + } + + fn checkpoint_durable_join(grid: *Grid) void { + assert(grid.callback == .checkpoint_durable); + + // We are still repairing some blocks released during the previous checkpoint interval. + if (!grid.blocks_missing.checkpoint_durable_complete()) { + assert(grid.write_iops.executing() > 0); + return; + } + + var write_queue_iterator = grid.write_queue.iterate(); + while (write_queue_iterator.next()) |write| { + maybe(write.repair); + assert(!grid.free_set.is_free(write.address)); + assert(!grid.free_set.to_be_freed_at_checkpoint_durability(write.address)); + } + + var write_iops_iterator = grid.write_iops.iterate(); + while (write_iops_iterator.next()) |iop| { + assert(!grid.free_set.is_free(iop.write.address)); + assert(!grid.free_set.to_be_freed_at_checkpoint_durability(iop.write.address)); + } + + // Now that there are no writes to released blocks, we can safely mark them as free, + // and also mark the checkpoint as durable. + assert(!grid.free_set.checkpoint_durable); + defer assert(grid.free_set.checkpoint_durable); + + grid.free_set.mark_checkpoint_durable(); + + const callback = grid.callback.checkpoint_durable; + grid.callback = .none; + callback(grid); + } + + pub fn cancel(grid: *Grid, callback: *const fn (*Grid) void) void { + // grid.open() is cancellable the same way that read_block()/write_block() are. + switch (grid.callback) { + .none => {}, + .open => {}, + .checkpoint_durable => {}, + .checkpoint => unreachable, + .cancel => unreachable, + } + + grid.callback = .{ .cancel = callback }; + + grid.blocks_missing.cancel(); + grid.read_queue.reset(); + grid.read_pending_queue.reset(); + grid.read_global_queue.reset(); + grid.write_queue.reset(); + grid.superblock.storage.reset_next_tick_lsm(); + grid.superblock.storage.on_next_tick( + .vsr, + cancel_tick_callback, + &grid.canceling_tick_context, + ); + } + + fn cancel_tick_callback(next_tick: *NextTick) void { + const grid: *Grid = @alignCast(@fieldParentPtr("canceling_tick_context", next_tick)); + if (grid.callback != .cancel) return; + + assert(grid.read_queue.empty()); + assert(grid.read_pending_queue.empty()); + assert(grid.read_global_queue.empty()); + assert(grid.write_queue.empty()); + + grid.cancel_join_callback(); + } + + fn cancel_join_callback(grid: *Grid) void { + assert(grid.callback == .cancel); + assert(grid.read_queue.empty()); + assert(grid.read_pending_queue.empty()); + assert(grid.read_global_queue.empty()); + assert(grid.write_queue.empty()); + + if (grid.read_iops.executing() == 0 and + grid.write_iops.executing() == 0) + { + const callback = grid.callback.cancel; + grid.callback = .none; + + callback(grid); + } + } + + pub fn on_next_tick( + grid: *Grid, + callback: *const fn (*Grid.NextTick) void, + next_tick: *Grid.NextTick, + ) void { + assert(grid.callback != .cancel); + grid.superblock.storage.on_next_tick(.lsm, callback, next_tick); + } + + /// Aborts if there are not enough free blocks to fill the reservation. + /// Should a use case arise where a null return would be preferred, this can be split + /// into panicking and non-panicking versions. + pub fn reserve(grid: *Grid, blocks_count: usize) Reservation { + assert(grid.callback == .none); + return grid.free_set.reserve(blocks_count) orelse vsr.fatal( + .storage_size_would_exceed_limit, + "data file would become too large size={} + reservation={} > limit={}, " ++ + "restart the replica increasing '--limit-storage'", + .{ + grid.superblock.working.vsr_state.checkpoint.storage_size, + blocks_count * constants.block_size, + grid.superblock.storage_size_limit, + }, + ); + } + + /// Forfeit a reservation. + pub fn forfeit(grid: *Grid, reservation: Reservation) void { + assert(grid.callback == .none); + return grid.free_set.forfeit(reservation); + } + + /// Returns a just-allocated block. + /// The caller is responsible for not acquiring more blocks than they reserved. + pub fn acquire(grid: *Grid, reservation: Reservation) u64 { + assert(grid.callback == .none); + return grid.free_set.acquire(reservation).?; + } + + /// This function should be used to release addresses, instead of release() + /// on the free set directly, as this also demotes the address within the block cache. + /// This reduces conflict misses in the block cache, by freeing ways soon after they are + /// released. + /// + /// This does not remove the blocks from the cache — the blocks can be read until the next + /// checkpoint. + /// + /// Asserts that the addresses are not currently being read from or written to. + pub fn release(grid: *Grid, addresses: []const u64) void { + assert(grid.callback == .none); + for (addresses) |address| { + assert(address > 0); + + // It's safe to release an address that is being read from or + // written to, as it can only be overwritten in the next + // checkpoint (when the address is freed and can be reacquired). + maybe(grid.writing(address, null) == .create); + + grid.cache.demote(address); + grid.free_set.release(address); + } + } + + const Writing = enum { create, repair, not_writing }; + + /// If the address is being written to by a non-repair, return `.create`. + /// If the address is being written to by a repair, return `.repair`. + /// Otherwise return `.not_writing`. + /// + /// Assert that the block pointer is not being used for any write if non-null. + pub fn writing(grid: *Grid, address: u64, block: ?BlockPtrConst) Writing { + assert(address > 0); + + var result = Writing.not_writing; + { + var it = grid.write_queue.iterate(); + while (it.next()) |queued_write| { + assert(block != queued_write.block.*); + if (address == queued_write.address) { + assert(result == .not_writing); + result = if (queued_write.repair) .repair else .create; + } + } + } + { + var it = grid.write_iops.iterate(); + while (it.next()) |iop| { + assert(block != iop.write.block.*); + if (address == iop.write.address) { + assert(result == .not_writing); + result = if (iop.write.repair) .repair else .create; + } + } + } + return result; + } + + /// Assert that the address is not currently being read from (disregarding repairs). + /// Assert that the block pointer is not being used for any read if non-null. + fn assert_not_reading(grid: *const Grid, address: u64, block: ?BlockPtrConst) void { + assert(address > 0); + + for ([_]*const QueueType(Read){ + &grid.read_queue, + &grid.read_global_queue, + }) |queue| { + var it = queue.iterate(); + while (it.next()) |queued_read| { + if (queued_read.coherent) { + assert(address != queued_read.address); + } + } + } + { + var it = grid.read_iops.iterate_const(); + while (it.next()) |iop| { + if (iop.read.coherent) { + assert(address != iop.read.address); + } + const iop_block = grid.read_iop_blocks[grid.read_iops.index(iop)]; + assert(block != iop_block); + } + } + } + + pub fn assert_only_repairing(grid: *const Grid) void { + assert(grid.callback != .cancel); + assert(grid.read_global_queue.empty()); + + var read_queue_iterator = grid.read_queue.iterate(); + while (read_queue_iterator.next()) |read| { + // Scrubber reads are independent from LSM operations. + assert(!read.coherent); + } + + var write_queue_iterator = grid.write_queue.iterate(); + while (write_queue_iterator.next()) |write| { + assert(write.repair); + assert(!grid.free_set.is_free(write.address)); + } + + var write_iops = grid.write_iops.iterate_const(); + while (write_iops.next()) |iop| { + assert(iop.write.repair); + assert(!grid.free_set.is_free(iop.write.address)); + } + } + + /// Return a block from the stash which had no outstanding references. + pub fn get_block(grid: *Grid) BlockPtr { + const stash_entry = grid.stash_free.pop() orelse @panic("stash has no free blocks"); + const stash_location = stash_entry.key; + + assert(grid.blocks_references[stash_location] == 0); + grid.blocks_references[stash_location] += 1; + grid.stash_available -= 1; + + grid.stash_used.putAssumeCapacityNoClobber(stash_location, {}); + + return &grid.blocks[stash_location]; + } + + pub fn block_ref(grid: *Grid, block: BlockPtrConst) BlockPtrConst { + const block_header = schema.header_from_block(block); + assert(block_header.valid_checksum()); + + const location = grid.location_from_block(block); + assert(!grid.stash_free.contains(location)); + + if (grid.blocks_references[location] == 0) { + // The only way to call block_ref() on a zero-reference block is if we got the block + // from the cache, not the stash. + assert(!grid.stash_used.contains(location)); + } + + grid.blocks_references[location] += 1; + grid.stash_available -= 1; + + return block; + } + + pub fn block_unref(grid: *Grid, block: BlockPtrConst) void { + const location = grid.location_from_block(block); + assert(!grid.stash_free.contains(location)); + + assert(grid.blocks_references[location] > 0); + grid.blocks_references[location] -= 1; + grid.stash_available += 1; + + if (grid.blocks_references[location] == 0) { + if (grid.stash_used.swapRemove(location)) { + grid.stash_free.putAssumeCapacityNoClobber(location, {}); + } + } + + assert(grid.stash_available <= grid.stash_free.count()); + } + + pub fn block_references(grid: *const Grid, block: BlockPtrConst) u8 { + return grid.blocks_references[grid.location_from_block(block)]; + } + + pub fn fulfill_block(grid: *Grid, block: BlockPtrConst) bool { + assert(grid.superblock.opened); + assert(grid.callback != .cancel); + + const block_header = schema.header_from_block(block); + assert(block_header.cluster == grid.superblock.working.cluster); + + var reads_iterator = grid.read_global_queue.iterate(); + while (reads_iterator.next()) |read| { + if (read.checksum == block_header.checksum and + read.address == block_header.address) + { + assert(block_header.release.value <= + grid.superblock.working.vsr_state.checkpoint.release.value); + grid.read_global_queue.remove(read); + grid.read_block_resolve(read, .{ .valid = block }); + return true; + } + } + return false; + } + + pub fn repair_block_waiting(grid: *Grid, address: u64, checksum: u128) bool { + assert(grid.superblock.opened); + assert(grid.callback != .cancel); + return grid.blocks_missing.block_waiting(address, checksum); + } + + /// Write a block that should already exist but (maybe) doesn't because of: + /// - a disk fault, or + /// - the block was missed due to state sync. + /// + /// NOTE: This will consume `block` and replace it with a fresh block. + pub fn repair_block( + grid: *Grid, + callback: *const fn (*Grid.Write) void, + write: *Grid.Write, + block: *BlockPtr, + ) void { + const block_header = schema.header_from_block(block.*); + assert(grid.superblock.opened); + assert(grid.callback != .cancel); + assert(grid.writing(block_header.address, block.*) == .not_writing); + assert(grid.blocks_missing.block_waiting(block_header.address, block_header.checksum)); + assert(!grid.free_set.is_free(block_header.address)); + + grid.blocks_missing.write_commence(block_header.address, block_header.checksum); + grid.write_block(callback, write, block, .repair); + } + + /// Write a block for the first time. + /// NOTE: This will consume `block` and replace it with a fresh block. + pub fn create_block( + grid: *Grid, + callback: *const fn (*Grid.Write) void, + write: *Grid.Write, + block: *BlockPtr, + ) void { + const block_header = schema.header_from_block(block.*); + assert(grid.superblock.opened); + assert(grid.callback == .none or grid.callback == .checkpoint); + assert((grid.callback == .checkpoint) == (block_header.block_type == .free_set)); + assert(grid.writing(block_header.address, block.*) == .not_writing); + assert(!grid.blocks_missing.block_waiting( + block_header.address, + block_header.checksum, + )); + assert(!grid.free_set.is_free(block_header.address)); + grid.assert_not_reading(block_header.address, block.*); + + grid.write_block(callback, write, block, .create); + } + + /// NOTE: This will consume `block` and replace it with a fresh block. + fn write_block( + grid: *Grid, + callback: *const fn (*Grid.Write) void, + write: *Grid.Write, + block: *BlockPtr, + trigger: enum { create, repair }, + ) void { + const header = schema.header_from_block(block.*); + assert(header.cluster == grid.superblock.working.cluster); + assert(header.release.value <= + grid.superblock.working.vsr_state.checkpoint.release.value); + + assert(grid.superblock.opened); + assert(grid.callback != .cancel); + assert(grid.writing(header.address, block.*) == .not_writing); + assert(!grid.free_set.is_free(header.address)); + grid.assert_coherent(header.address, header.checksum); + + const block_location = grid.location_from_block(block.*); + assert(grid.stash_used.contains(block_location)); + assert(grid.blocks_references[block_location] > 0); + if (grid.blocks_references[block_location] > 1) { + // Extra references are due to fulfill_block(). + assert(trigger == .repair); + } + + // Zero sector padding. + @memset(block.*[header.size..vsr.sector_ceil(header.size)], 0); + + write.* = .{ + .callback = callback, + .address = header.address, + .repair = trigger == .repair, + .block = block, + .checkpoint_id = grid.superblock.working.checkpoint_id(), + }; + + const iop = grid.write_iops.acquire() orelse { + grid.write_queue.push(write); + return; + }; + + grid.write_block_with(iop, write); + } + + fn write_block_with(grid: *Grid, iop: *WriteIOP, write: *Write) void { + assert(!grid.free_set.is_free(write.address)); + + grid.trace.start(.{ .grid_write = .{ .iop = grid.write_iops.index(iop) } }); + + iop.* = .{ + .grid = grid, + .completion = undefined, + .write = write, + }; + + const write_header = schema.header_from_block(write.block.*); + assert(write_header.size > @sizeOf(vsr.Header)); + assert(write_header.size <= constants.block_size); + assert(stdx.zeroed( + write.block.*[write_header.size..vsr.sector_ceil(write_header.size)], + )); + + grid.superblock.storage.write_sectors( + write_block_callback, + &iop.completion, + write.block.*[0..vsr.sector_ceil(write_header.size)], + .grid, + block_offset(write.address), + ); + } + + fn write_block_callback(completion: *Storage.Write) void { + const iop: *WriteIOP = @fieldParentPtr("completion", completion); + + // We must copy these values to the stack as they will be overwritten + // when we release the iop and potentially start a queued write. + const grid = iop.grid; + const completed_write = iop.write; + + // We can only update the cache if the Grid is not resolving callbacks with a cache + // block. + assert(!grid.read_resolving); + assert(!grid.free_set.is_free(completed_write.address)); + + if (!completed_write.repair) { + assert(grid.superblock.working.checkpoint_id() == completed_write.checkpoint_id); + } + + // Insert the write block into the cache. + grid.cache_upsert(completed_write.address, completed_write.block.*); + + // Usually references=1, but since reading from the write queue is possible, it may be + // higher. + const block_written_location = grid.location_from_block(completed_write.block.*); + assert(grid.blocks_references[block_written_location] > 0); + + const cache_block = completed_write.block.*; + grid.block_unref(cache_block); + completed_write.block.* = grid.get_block(); + + const cache_block_header = schema.header_from_block(cache_block); + assert(cache_block_header.address == completed_write.address); + grid.assert_coherent(completed_write.address, cache_block_header.checksum); + + grid.trace.stop(.{ .grid_write = .{ .iop = grid.write_iops.index(iop) } }); + + if (grid.callback == .cancel) { + assert(grid.write_queue.empty()); + + grid.write_iops.release(iop); + grid.cancel_join_callback(); + return; + } + + // Start a queued write if possible *before* calling the completed + // write's callback. This ensures that if the callback calls + // Grid.write_block() it doesn't preempt the queue. + // + // (Don't pop from the write queue until after the read-repairs are resolved. + // Otherwise their resolution might complete grid cancellation, but the replica has + // not released its own write iop (via callback).) + if (grid.write_queue.pop()) |queued_write| { + grid.write_block_with(iop, queued_write); + } else { + grid.write_iops.release(iop); + } + + // Precede the write's callback, since the callback takes back ownership of the block. + if (completed_write.repair) grid.blocks_missing.write_complete(cache_block); + // This call must come after (logically) releasing the IOP. Otherwise we risk tripping + // assertions forbidding concurrent writes using the same block/address + // if the callback calls write_block(). + completed_write.callback(completed_write); + + // We start awaiting pending repairs when the checkpoint becomes durable. + if (grid.callback == .checkpoint_durable) grid.checkpoint_durable_join(); + } + + /// Fetch the block synchronously from the write queues, if possible. + /// Note that we allow the creation of a block (and hence a write to + /// an address) to span the entirety of the checkpoint when the write + /// was initiated. This is safe to do as this address can only be + /// freed and overwritten in the next checkpoint. + /// + /// It is possible to read an address while it's being written to, in the + /// following scenarios: + /// * Reading a block that is currenly being repaired. + /// * Reading a block that is currently being created, if it is requested + /// by another replica, or by the replica itself. + fn read_block_from_write_queues( + grid: *const Grid, + address: u64, + checksum: u128, + ) ?BlockPtrConst { + assert(grid.superblock.opened); + assert(grid.callback != .cancel); + assert(address > 0); + + var block_found_count: u64 = 0; + var block: ?BlockPtrConst = null; + + var write_queue_iterator = grid.write_queue.iterate(); + while (write_queue_iterator.next()) |queued_write| { + const queued_write_header = mem.bytesAsValue( + vsr.Header.Block, + queued_write.block.*[0..@sizeOf(vsr.Header)], + ); + + if (address == queued_write_header.address and + checksum == queued_write_header.checksum) + { + block_found_count += 1; + block = queued_write.block.*; + } + } + + var write_iops_iterator = grid.write_iops.iterate_const(); + while (write_iops_iterator.next()) |iop| { + const queued_write_header = mem.bytesAsValue( + vsr.Header.Block, + iop.write.block.*[0..@sizeOf(vsr.Header)], + ); + + if (address == queued_write_header.address and + checksum == queued_write_header.checksum) + { + block_found_count += 1; + block = iop.write.block.*; + } + } + + assert(block_found_count <= 1); + return block; + } + + /// Fetch the block synchronously from the write queues or grid cache, if possible. + /// The returned block pointer is only valid until the next Grid write. + pub fn read_block_from_cache( + grid: *Grid, + address: u64, + checksum: u128, + options: struct { coherent: bool }, + ) ?BlockPtrConst { + assert(grid.superblock.opened); + assert(grid.callback != .cancel); + assert(address > 0); + + if (options.coherent) { + assert(!grid.free_set.is_free(address)); + grid.assert_coherent(address, checksum); + } + + const cache_index = grid.cache.get_index(address) orelse { + if (grid.read_block_from_write_queues(address, checksum)) |block| { + grid.assert_coherent(address, checksum); + return block; + } + return null; + }; + const cache_location = grid.cache_locations[cache_index]; + const cache_block = &grid.blocks[cache_location]; + const header = schema.header_from_block(cache_block); + assert(header.address == address); + assert(header.cluster == grid.superblock.working.cluster); + assert(header.release.value <= + grid.superblock.working.vsr_state.checkpoint.release.value); + + if (header.checksum == checksum) { + if (constants.verify and + options.coherent and + grid.superblock.working.vsr_state.sync_op_max == 0) + { + grid.verify_read_from_cache(address, cache_block); + } + + return cache_block; + } else { + const write_queue_block = grid.read_block_from_write_queues(address, checksum); + + // For coherent reads, we can only find an old version of the + // block in the cache if we either: + // * Learnt about a new version of the block via state sync, or + // * A new version of that block is currently being written + // + // This is because we evict the old version of the block from the + // cache, as soon as it is written (see `write_block_callback`). + if (options.coherent) { + assert(grid.superblock.working.vsr_state.sync_op_max > 0 or + write_queue_block != null); + } + + if (write_queue_block) |block| { + grid.assert_coherent(address, checksum); + return block; + } + + return null; + } + } + + pub fn read_block( + grid: *Grid, + callback: ReadBlockCallback, + read: *Grid.Read, + address: u64, + checksum: u128, + options: struct { + cache_read: bool, + cache_write: bool, + }, + ) void { + assert(grid.superblock.opened); + assert(grid.callback != .cancel); + assert(address > 0); + + // It is possible to read an address while it's being written to + // (see `read_block_from_write_queues`). + maybe(grid.writing(address, null) == .create); + + switch (callback) { + .from_local_storage => { + maybe(grid.callback == .checkpoint); + // We try to read the block even when it is free. If we + // recently released it, it might be found on disk anyway. + maybe(grid.free_set.is_free(address)); + }, + .from_local_or_global_storage => { + assert(grid.callback != .checkpoint); + assert(!grid.free_set.is_free(address)); + grid.assert_coherent(address, checksum); + }, + } + + read.* = .{ + .callback = callback, + .address = address, + .checksum = checksum, + .coherent = callback == .from_local_or_global_storage, + .cache_read = options.cache_read, + .cache_write = options.cache_write, + .checkpoint_id = grid.superblock.working.checkpoint_id(), + .checkpoint_durable = grid.free_set.checkpoint_durable, + .grid = grid, + }; + + if (options.cache_read) { + grid.on_next_tick(read_block_tick_callback, &read.next_tick); + } else { + read_block_tick_callback(&read.next_tick); + } + } + + fn read_block_tick_callback(next_tick: *Storage.NextTick) void { + const read: *Grid.Read = @alignCast(@fieldParentPtr("next_tick", next_tick)); + const grid = read.grid; + assert(grid.superblock.opened); + assert(grid.callback != .cancel); + if (read.coherent) { + assert(!grid.free_set.is_free(read.address)); + maybe(grid.writing(read.address, null) == .create); + } + + assert(read.address > 0); + + // Check the write queue before checking the read queue, since otherwise: + // 1. Read block. (coherent=false, i.e. via repair) + // 2. Create block. (start) + // 3. Read block again. (coherent=true) + // We must ensure that the second read succeeds, so that it doesn't just queue up behind + // the first read. + if (grid.read_block_from_write_queues(read.address, read.checksum)) |block| { + grid.assert_coherent(read.address, read.checksum); + grid.read_block_resolve(read, .{ .valid = block }); + return; + } + + // Check if a read is already processing/recovering and merge with it. + for ([_]*const QueueType(Read){ + &grid.read_queue, + &grid.read_global_queue, + }) |queue| { + // Don't remote-repair repairs – the block may not belong in our current checkpoint. + if (read.callback == .from_local_storage) { + if (queue == &grid.read_global_queue) continue; + } + + var it = queue.iterate(); + while (it.next()) |queued_read| { + if (queued_read.address == read.address) { + // TODO check all read options match + if (queued_read.checksum == read.checksum) { + queued_read.resolves.push(&read.pending); + return; + } else { + assert(!queued_read.coherent or !read.coherent); + } + } + } + } + + // When Read.cache_read is set, the caller of read_block() + // is responsible for calling us via next_tick(). + if (read.cache_read) { + if (grid.read_block_from_cache( + read.address, + read.checksum, + .{ .coherent = read.coherent }, + )) |cache_block| { + grid.read_block_resolve(read, .{ .valid = cache_block }); + return; + } + } + + // Become the "root" read that's fetching the block for the + // given address. The fetch happens asynchronously to avoid + // stack-overflow and nested cache invalidation. + grid.read_queue.push(read); + + // Grab an IOP to resolve the block from storage. + // Failure to do so means the read is queued to receive an + // IOP when one finishes. + const iop = grid.read_iops.acquire() orelse { + grid.read_pending_queue.push(&read.pending); + return; + }; + + grid.read_block_with(iop, read); + } + + fn read_block_with(grid: *Grid, iop: *Grid.ReadIOP, read: *Grid.Read) void { + const address = read.address; + assert(address > 0); + + // We can only update the cache if the Grid is not resolving + // callbacks with a cache block. + assert(!grid.read_resolving); + + grid.trace.start(.{ .grid_read = .{ .iop = grid.read_iops.index(iop) } }); + + iop.* = .{ + .completion = undefined, + .read = read, + }; + const iop_block = grid.read_iop_blocks[grid.read_iops.index(iop)]; + + grid.superblock.storage.read_sectors( + read_block_callback, + &iop.completion, + iop_block, + .grid, + block_offset(address), + ); + } + + fn read_block_callback(completion: *Storage.Read) void { + const iop: *ReadIOP = @fieldParentPtr("completion", completion); + const read = iop.read; + const grid = read.grid; + const iop_index = grid.read_iops.index(iop); + const block = grid.read_iop_blocks[iop_index]; + const block_location = grid.location_from_block(block); + assert(grid.blocks_references[block_location] == 1); + + grid.trace.stop(.{ .grid_read = .{ .iop = grid.read_iops.index(iop) } }); + + if (grid.callback == .cancel) { + grid.read_iops.release(iop); + grid.cancel_join_callback(); + return; + } + + // This is the block reference "burst", since we hold the current read block while still + // acquiring a new read block for the iop. + grid.read_iop_blocks[iop_index] = grid.get_block(); + defer grid.block_unref(block); + + // Handoff the iop to a pending read or release it before resolving the callbacks below. + if (grid.read_pending_queue.pop()) |pending| { + const queued_read: *Read = @alignCast(@fieldParentPtr("pending", pending)); + grid.read_block_with(iop, queued_read); + } else { + grid.read_iops.release(iop); + } + + const result = read_block_validate(block, .{ + .address = read.address, + .checksum = read.checksum, + }); + + // Remove the "root" read so that the address is no longer actively reading / locked. + grid.read_queue.remove(read); + + if (result == .valid) { + if (read.cache_write) { + grid.cache_upsert(read.address, block); + } + } else { + const header = mem.bytesAsValue(vsr.Header.Block, block[0..@sizeOf(vsr.Header)]); + log.warn( + "{}: {s}: expected address={} checksum={x:0>32}, " ++ + "found address={} checksum={x:0>32}", + .{ + grid.superblock.replica_index.?, + @tagName(result), + read.address, + read.checksum, + header.address, + header.checksum, + }, + ); + + if (constants.verify) grid.verify_read_fault(read); + } + + grid.read_block_resolve(read, result); + } + + fn read_block_validate(block: BlockPtrConst, expect: struct { + address: u64, + checksum: u128, + }) ReadBlockResult { + const header = mem.bytesAsValue(vsr.Header.Block, block[0..@sizeOf(vsr.Header)]); + + if (!header.valid_checksum()) return .invalid_checksum; + if (header.command != .block) return .unexpected_command; + + assert(header.size >= @sizeOf(vsr.Header)); + assert(header.size <= constants.block_size); + + const block_body = block[@sizeOf(vsr.Header)..header.size]; + if (!header.valid_checksum_body(block_body)) { + return .invalid_checksum_body; + } + + if (header.checksum != expect.checksum) return .unexpected_checksum; + + if (!stdx.zeroed(block[header.size..vsr.sector_ceil(header.size)])) { + return .invalid_padding; + } + + assert(header.address == expect.address); + return .{ .valid = block }; + } + + fn read_block_resolve(grid: *Grid, read: *Grid.Read, result: ReadBlockResult) void { + assert(grid.callback != .cancel); + + // Guard to make sure the cache cannot be updated by any read.callbacks() below. + assert(!grid.read_resolving); + grid.read_resolving = true; + defer { + assert(grid.read_resolving); + grid.read_resolving = false; + } + + if (read.coherent) { + assert(!grid.free_set.is_free(read.address)); + assert(read.checkpoint_id == grid.superblock.working.checkpoint_id()); + grid.assert_coherent(read.address, read.checksum); + } + + if (result == .valid) { + const header = schema.header_from_block(result.valid); + assert(header.cluster == grid.superblock.working.cluster); + assert(header.release.value <= + grid.superblock.working.vsr_state.checkpoint.release.value); + assert(header.address == read.address); + assert(header.checksum == read.checksum); + } + + var read_remote_resolves: QueueType(ReadPending) = QueueType(ReadPending).init(.{ + .name = read.resolves.any.name, + }); + + // Resolve all reads queued to the address with the block. + while (read.resolves.pop()) |pending| { + const pending_read: *Read = @alignCast(@fieldParentPtr("pending", pending)); + assert(pending_read.address == read.address); + assert(pending_read.checksum == read.checksum); + if (pending_read.coherent) { + assert(pending_read.checkpoint_id == grid.superblock.working.checkpoint_id()); + } + + switch (pending_read.callback) { + .from_local_storage => |callback| callback(pending_read, result), + .from_local_or_global_storage => |callback| { + if (result == .valid) { + callback(pending_read, result.valid); + } else { + read_remote_resolves.push(&pending_read.pending); + } + }, + } + } + + // Then invoke the callback with the cache block (which should be valid for the duration + // of the callback as any nested Grid calls cannot synchronously update the cache). + switch (read.callback) { + .from_local_storage => |callback| callback(read, result), + .from_local_or_global_storage => |callback| { + if (result == .valid) { + callback(read, result.valid); + } else { + read_remote_resolves.push(&read.pending); + } + }, + } + + // On the result of an invalid block, move the "root" read (and all others it + // resolves) to recovery queue. Future reads on the same address will see the "root" + // read in the recovery queue and enqueue to it. + if (read_remote_resolves.pop()) |read_remote_head_pending| { + const read_remote_head: *Read = @alignCast( + @fieldParentPtr("pending", read_remote_head_pending), + ); + assert(read_remote_head.callback == .from_local_or_global_storage); + assert(read_remote_head.coherent); + + log.debug("{}: read_block: fault: address={} checksum={x:0>32}", .{ + grid.superblock.replica_index.?, + read_remote_head.address, + read_remote_head.checksum, + }); + + read_remote_head.resolves = read_remote_resolves; + grid.read_global_queue.push(read_remote_head); + + if (grid.blocks_missing.repair_blocks_available() > 0) { + grid.blocks_missing.repair_block( + read_remote_head.address, + read_remote_head.checksum, + ); + } + } + } + + /// Insert the address into the cache, and swap the evicted block into the stash. + fn cache_upsert(grid: *Grid, block_address: u64, block_save: BlockPtr) void { + assert(block_address != 0); + + // The location/block that is being moved from stash to cache. + const block_save_location = grid.location_from_block(block_save); + assert(grid.blocks_references[block_save_location] > 0); + + const block_save_header = schema.header_from_block(block_save); + assert(block_save_header.address == block_address); + + const cache_index = grid.cache.upsert(&block_address).index; + assert(cache_index < grid.cache_locations.len); + + // The location/block being moved from cache to stash. + const block_drop_location = grid.cache_locations[cache_index]; + assert(block_drop_location != block_save_location); + + const block_drop_removed = grid.stash_used.swapRemove(block_save_location); + assert(block_drop_removed); + + if (grid.blocks_references[block_drop_location] == 0) { + grid.stash_free.putAssumeCapacityNoClobber(block_drop_location, {}); + } else { + grid.stash_used.putAssumeCapacityNoClobber(block_drop_location, {}); + } + grid.cache_locations[cache_index] = block_save_location; + + if (grid.blocks_references[block_drop_location] == 0) { + // This block content won't be used again. + // We could overwrite the entire thing, but that would be more expensive. + const block_drop = &grid.blocks[block_drop_location]; + @memset(block_drop[0..@sizeOf(vsr.Header)], 0); + } + } + + fn location_from_block(grid: *const Grid, block: BlockPtrConst) u32 { + assert(@intFromPtr(block.ptr) >= @intFromPtr(grid.blocks.ptr)); + assert(@intFromPtr(block.ptr) < + @intFromPtr(grid.blocks.ptr) + grid.blocks.len * constants.block_size); + + const offset = @intFromPtr(block.ptr) - @intFromPtr(grid.blocks.ptr); + return @intCast(@divExact(offset, constants.block_size)); + } + + fn block_offset(address: u64) u64 { + assert(address > 0); + + return (address - 1) * block_size; + } + + /// Verify that the storage: + /// - contains the given index block + /// - contains every value block referenced by the index block + pub fn verify_table(grid: *Grid, index_address: u64, index_checksum: u128) void { + assert(index_address > 0); + + const TestStorage = @import("../testing/storage.zig").Storage; + if (Storage != TestStorage) return; + + const index_block = blk: { + if (grid.read_block_from_write_queues(index_address, index_checksum)) |block| { + break :blk block; + } else { + break :blk grid.superblock.storage.grid_block(index_address).?; + } + }; + const index_schema = schema.TableIndex.from_block_without_schema(index_block); + const index_block_header = schema.header_from_block(index_block); + + assert(index_block_header.address == index_address); + assert(index_block_header.checksum == index_checksum); + assert(index_block_header.block_type == .index); + + for ( + index_schema.value_addresses_used(index_block), + index_schema.value_checksums_used(index_block), + ) |value_address, value_checksum| { + const value_block = blk: { + if (grid.read_block_from_write_queues( + value_address, + value_checksum.value, + )) |block| { + break :blk block; + } else { + break :blk grid.superblock.storage.grid_block(value_address).?; + } + }; + const value_block_header = schema.header_from_block(value_block); + + assert(value_block_header.address == value_address); + assert(value_block_header.checksum == value_checksum.value); + assert(value_block_header.block_type == .value); + } + } + + fn assert_coherent(grid: *const Grid, address: u64, checksum: u128) void { + assert(!grid.free_set.is_free(address)); + + const TestStorage = @import("../testing/storage.zig").Storage; + if (Storage != TestStorage) return; + + if (grid.superblock.storage.options.grid_checker) |checker| { + checker.assert_coherent( + &grid.superblock.working.vsr_state.checkpoint, + grid.free_set.checkpoint_durable, + address, + checksum, + ); + + checker.assert_coherent( + &grid.superblock.staging.vsr_state.checkpoint, + checkpoint_durable: { + if (grid.superblock.working.checkpoint_id() == + grid.superblock.staging.checkpoint_id()) + { + break :checkpoint_durable grid.free_set.checkpoint_durable; + } else { + // Checkpoint is currently being written to the superblock. Pass + // checkpoint_durable=False as we update free_set.checkpoint_durable + // only *after* the checkpoint is written to the superblock. + assert(grid.superblock.staging.parent_checkpoint_id() == + grid.superblock.working.checkpoint_id()); + assert(grid.free_set.checkpoint_durable); + break :checkpoint_durable false; + } + }, + address, + checksum, + ); + } + } + + fn verify_read_from_cache( + grid: *const Grid, + address: u64, + cached_block: BlockPtrConst, + ) void { + comptime assert(constants.verify); + + const TestStorage = @import("../testing/storage.zig").Storage; + if (Storage != TestStorage) return; + + const actual_block = grid.superblock.storage.grid_block(address).?; + const actual_header = schema.header_from_block(actual_block); + const cached_header = schema.header_from_block(cached_block); + assert(cached_header.checksum == actual_header.checksum); + + assert(std.mem.eql( + u8, + cached_block[0..cached_header.size], + actual_block[0..actual_header.size], + )); + } + + /// Called when we fail to read a block. + fn verify_read_fault(grid: *const Grid, read: *const Read) void { + comptime assert(constants.verify); + + const TestStorage = @import("../testing/storage.zig").Storage; + if (Storage != TestStorage) return; + + // Only check coherent reads -- i.e., when we know for certain that the read's + // address/checksum belongs in our current checkpoint. + if (!read.coherent) return; + + // Check our storage (bypassing faults). + if (grid.superblock.storage.grid_block(read.address)) |actual_block| { + const actual_header = schema.header_from_block(actual_block); + if (actual_header.checksum == read.checksum) { + // Exact block found. Since the read failed anyway, it must have been a + // simulated read fault. + assert(grid.superblock.storage.area_faulty(.{ + .grid = .{ .address = read.address }, + })); + } else { + // Different block found -- since this is a coherent read, we must be syncing. + assert(grid.superblock.working.vsr_state.sync_op_max > 0); + } + } else { + // No block found -- since this is a coherent read, we must by syncing. + assert(grid.superblock.working.vsr_state.sync_op_max > 0); + } + } + + /// Mark all blocks in the grid cache as MADV_DONTDUMP. Must be done after transitioning + /// to static, as the combination of madvise() + mremap() can cause an EFAULT. + /// + /// It's OK that some blocks, such as the blocks used by compaction escape this -- this is + /// not to stop sensitive data from appearing in core dumps, but rather to keep the core + /// dump size manageable even with a large grid cache. + pub fn madv_dont_dump(grid: *const Grid) !void { + if (builtin.target.os.tag != .linux) return; + + assert(grid.blocks.len > 0); + + try std.posix.madvise( + @ptrFromInt(@intFromPtr(grid.blocks.ptr)), + grid.blocks.len * constants.block_size, + std.posix.MADV.DONTDUMP, + ); + + log.debug("marked {} bytes as MADV_DONTDUMP", .{ + grid.blocks.len * constants.block_size, + }); + } + }; +} diff --git a/ocam/src/vsr/grid_blocks_missing.zig b/ocam/src/vsr/grid_blocks_missing.zig new file mode 100644 index 00000000..2b4d5942 --- /dev/null +++ b/ocam/src/vsr/grid_blocks_missing.zig @@ -0,0 +1,740 @@ +//! Track corrupt/missing grid blocks. +//! +//! - The GridBlocksMissing is LSM-aware: it can repair entire tables. +//! - The GridBlocksMissing is shared by all Trees. +//! - The GridBlocksMissing is "coherent" – that is, all of the blocks in the queue belong in the +//! replica's current checkpoint: +//! - The GridBlocksMissing will not repair freed blocks. +//! - The GridBlocksMissing will repair released blocks, until they are freed at the checkpoint. +//! - GridBlocksMissing.sync_table() is called immediately after superblock sync. +//! - GridBlocksMissing.repair_block() is called by the grid when non-repair reads encounter +//! corrupt blocks. +const std = @import("std"); +const assert = std.debug.assert; +const maybe = stdx.maybe; + +const constants = @import("../constants.zig"); +const stdx = @import("stdx"); +const schema = @import("../lsm/schema.zig"); +const vsr = @import("../vsr.zig"); + +const QueueType = @import("../queue.zig").QueueType; +const BlockPtrConst = *align(constants.sector_size) const [constants.block_size]u8; + +pub const GridBlocksMissing = struct { + /// A block is removed from the collection when: + /// - the block's write completes, or + /// - the block is released and the release is checkpointed, or + /// - the grid is canceled. + /// + /// The map is keyed by block address. + const FaultyBlocks = std.AutoArrayHashMapUnmanaged(u64, FaultyBlock); + + const FaultyBlock = struct { + checksum: u128, + cause: Cause, + /// Transitions: + /// - Initial state is `waiting`. + /// - `waiting → writing` when the block arrives and begins to repair. + /// - `writing → aborting` when checkpoint becomes durable and the (writing) block is to be + /// freed. + state: enum { waiting, writing, aborting } = .waiting, + + const Cause = union(enum) { + /// Repair a single block. + /// + /// Originates from one of: + /// - the grid scrubber + /// - a grid read during prefetch/compaction + /// - a grid read while opening the grid/forest + repair, + /// State syncing the index or a value block of a table. + /// NB: when a replica decides to sync a block, it might already be repairing. + sync: struct { + table: *RepairTable, + block: union(enum) { + table_index, + /// The index of the value block within the index block. + table_value: u32, + }, + }, + }; + }; + + pub const RepairTable = struct { + table_info: schema.ManifestNode.TableInfo, + /// Invariants: + /// - value_blocks_received.count < table_blocks_total + /// - value_blocks_received.capacity = constants.lsm_table_value_blocks_max + /// TODO(Congestion control): This bitset is currently used only for extra validation. + /// Eventually we should request tables using this + EWAH encoding, instead of + /// block-by-block. + value_blocks_received: *std.DynamicBitSetUnmanaged, + /// This count includes the index block. + /// Invariants: + /// - table_blocks_written ≤ table_blocks_total + table_blocks_written: u32 = 0, + /// When null, the table is awaiting an index block. + /// When non-null, the table is awaiting value blocks. + /// This count includes the index block. + table_blocks_total: ?u32 = null, + /// For `faulty_tables`/`faulty_tables_free` queues. + link: QueueType(RepairTable).Link = .{}, + }; + + pub const Options = struct { + /// Lower-bound for the limit of concurrent repair_block()'s available. + blocks_max: usize, + /// Maximum number of concurrent sync_table()'s. + tables_max: usize, + }; + + options: Options, + + /// Invariants: + /// - For every block address in faulty_blocks, ¬free_set.is_free(address). + faulty_blocks: FaultyBlocks, + + /// On `sync_jump_commence()` and `sync_complete()`, swap this with `faulty_blocks` so that the + /// (possibly invalid) table blocks don't interfere. + /// + /// See state.sync_jump for more information. + syncing_faulty_blocks: FaultyBlocks, + + /// Invariants: + /// - enqueued_blocks_sync + enqueued_blocks_repair = + /// faulty_blocks.count() + syncing_faulty_blocks.count() + /// - enqueued_blocks_sync ≤ options.tables_max * lsm_table_content_blocks_max + enqueued_blocks_repair: usize = 0, + enqueued_blocks_sync: usize = 0, + + /// Invariants: + /// - For every index address in faulty_tables: ¬free_set.is_free(address). + /// - A given RepairTable is never in both `faulty_tables` and `faulty_tables_free`. + /// - `faulty_tables` does not contain multiple items with the same underlying table + /// (address/checksum). + faulty_tables: QueueType(RepairTable) = QueueType(RepairTable).init(.{ + .name = "grid_missing_blocks_tables", + }), + faulty_tables_free: QueueType(RepairTable) = QueueType(RepairTable).init(.{ + .name = "grid_missing_blocks_tables_free", + }), + + state: union(enum) { + repairing, + /// Set while the replica is syncing its superblock and opening its grid/forest. + /// + /// While `state=sync_jump`, only repair single blocks, not tables. Table blocks are + /// temporarily relegated to syncing_faulty_blocks: + /// - When state≠sync_jump, faulty_blocks=big and syncing_faulty_blocks=small/unused. + /// - When state=sync_jump, faulty_blocks=small and syncing_faulty_blocks=big. + /// + /// When we finish with state=sync_jump: + /// - For any table belonging in the new checkpoint: pick up repair where we left off. + /// - For any table not belonging in the new checkpoint: cancel. + sync_jump, + checkpoint_durable: struct { + /// The number of faulty_blocks with state=aborting. + aborting: u64, + }, + } = .repairing, + + pub fn init( + allocator: std.mem.Allocator, + options: Options, + ) error{OutOfMemory}!GridBlocksMissing { + var faulty_blocks = FaultyBlocks{}; + errdefer faulty_blocks.deinit(allocator); + + var syncing_faulty_blocks = FaultyBlocks{}; + errdefer syncing_faulty_blocks.deinit(allocator); + + try faulty_blocks.ensureTotalCapacity( + allocator, + options.blocks_max + options.tables_max * constants.lsm_table_value_blocks_max, + ); + // During state=sync_jump, we only need to sync single blocks, not full tables. + // (This sounds backwards! But the reason is that state=sync_jump corresponds to grid + // cancellation + checkpoint replacement, not table/content sync. We repair missing blocks + // from the free set and checkpoint trailers.) + try syncing_faulty_blocks.ensureTotalCapacity(allocator, options.blocks_max); + + return GridBlocksMissing{ + .options = options, + .faulty_blocks = faulty_blocks, + .syncing_faulty_blocks = syncing_faulty_blocks, + }; + } + + pub fn deinit(queue: *GridBlocksMissing, allocator: std.mem.Allocator) void { + queue.syncing_faulty_blocks.deinit(allocator); + queue.faulty_blocks.deinit(allocator); + + queue.* = undefined; + } + + pub fn verify(queue: *const GridBlocksMissing) void { + assert(queue.faulty_blocks.count() + queue.syncing_faulty_blocks.count() == + queue.enqueued_blocks_repair + queue.enqueued_blocks_sync); + + var enqueued_blocks_repair: u32 = 0; + var enqueued_blocks_sync: u32 = 0; + var enqueued_blocks_aborting: u32 = 0; + for ([_]FaultyBlocks{ + queue.faulty_blocks, + queue.syncing_faulty_blocks, + }) |faulty_blocks| { + for (faulty_blocks.values()) |fault| { + switch (fault.cause) { + .sync => enqueued_blocks_sync += 1, + .repair => enqueued_blocks_repair += 1, + } + enqueued_blocks_aborting += @intFromBool(fault.state == .aborting); + + switch (fault.cause) { + .repair => {}, + .sync => |sync| { + // These are not exclusive because the replica may reuse a RepairTable while + // we are still aborting the old blocks. + assert(queue.faulty_tables.contains(sync.table) or + (fault.state == .aborting)); + }, + } + } + } + assert(queue.enqueued_blocks_repair == enqueued_blocks_repair); + assert(queue.enqueued_blocks_sync == enqueued_blocks_sync); + if (enqueued_blocks_sync == 0) assert(queue.faulty_tables.empty()); + + if (queue.state == .checkpoint_durable) { + assert(enqueued_blocks_aborting == queue.state.checkpoint_durable.aborting); + } else { + assert(enqueued_blocks_aborting == 0); + } + + assert(queue.syncing_faulty_blocks.capacity() != queue.faulty_blocks.capacity()); + if (queue.state == .sync_jump) { + assert(queue.syncing_faulty_blocks.capacity() > queue.faulty_blocks.capacity()); + } else { + assert(queue.syncing_faulty_blocks.capacity() < queue.faulty_blocks.capacity()); + assert(queue.syncing_faulty_blocks.count() == 0); + } + + var faulty_tables_free = queue.faulty_tables_free.iterate(); + while (faulty_tables_free.next()) |table_free| { + assert(!queue.faulty_tables.contains(table_free)); + } + } + + /// Note that returning `null` doesn't necessarily indicate that there are no more blocks. + pub fn fault_at_index(queue: *const GridBlocksMissing, fault_index: usize) ?vsr.BlockRequest { + assert(queue.faulty_blocks.count() > 0); + assert(fault_index < queue.faulty_blocks.count()); + + const fault_addresses = queue.faulty_blocks.keys(); + const fault_data = queue.faulty_blocks.values(); + + return switch (fault_data[fault_index].state) { + .waiting => .{ + .block_address = fault_addresses[fault_index], + .block_checksum = fault_data[fault_index].checksum, + }, + .writing => null, + .aborting => null, + }; + } + + pub fn reclaim_table(queue: *GridBlocksMissing) ?*RepairTable { + const table = queue.faulty_tables_free.pop() orelse return null; + table.value_blocks_received.unsetAll(); + return table; + } + + /// Count the number of *non-table* block repairs available. + pub fn repair_blocks_available(queue: *const GridBlocksMissing) usize { + assert(queue.faulty_tables.count() <= queue.options.tables_max); + assert(queue.faulty_blocks.count() + queue.syncing_faulty_blocks.count() == + queue.enqueued_blocks_repair + queue.enqueued_blocks_sync); + assert(queue.enqueued_blocks_sync <= + queue.options.tables_max * constants.lsm_table_value_blocks_max); + + if (queue.state == .sync_jump) { + const faulty_blocks_free = + queue.faulty_blocks.capacity() - + queue.enqueued_blocks_repair; + return faulty_blocks_free; + } else { + const faulty_blocks_free = + queue.faulty_blocks.capacity() - + queue.enqueued_blocks_repair - + queue.options.tables_max * constants.lsm_table_value_blocks_max; + return faulty_blocks_free; + } + } + + /// Queue a faulty block to request from the cluster and repair. + pub fn repair_block(queue: *GridBlocksMissing, address: u64, checksum: u128) void { + assert(queue.repair_blocks_available() > 0); + assert(queue.faulty_tables.count() <= queue.options.tables_max); + assert(queue.faulty_blocks.count() + queue.syncing_faulty_blocks.count() == + queue.enqueued_blocks_repair + queue.enqueued_blocks_sync); + + const enqueue = queue.enqueue_faulty_block(address, checksum, .repair); + switch (enqueue) { + .insert => {}, + .duplicate => {}, + .replace => assert(queue.state == .sync_jump), + } + } + + pub fn sync_table( + queue: *GridBlocksMissing, + table: *RepairTable, + table_bitset: *std.DynamicBitSetUnmanaged, + table_info: *const schema.ManifestNode.TableInfo, + ) enum { insert, duplicate } { + assert(queue.state == .repairing or queue.state == .checkpoint_durable); + assert(queue.faulty_tables.count() < queue.options.tables_max); + assert(queue.faulty_blocks.count() + queue.syncing_faulty_blocks.count() == + queue.enqueued_blocks_repair + queue.enqueued_blocks_sync); + assert(table_bitset.capacity() == constants.lsm_table_value_blocks_max); + assert(table_bitset.count() == 0); + + const address = table_info.address; + const checksum = table_info.checksum; + + var tables = queue.faulty_tables.iterate(); + while (tables.next()) |queue_table| { + assert(queue_table != table); + assert(queue_table.value_blocks_received != table_bitset); + + if (queue_table.table_info.address == address) { + // The ForestTableIterator does not repeat tables *except* when the table was first + // encountered at level L, and then it was re-encountered having moved to a deeper + // level (L+1, etc). + assert(queue_table.table_info.checksum == checksum); + return .duplicate; + } + } + + table.* = .{ + .table_info = table_info.*, + .value_blocks_received = table_bitset, + }; + queue.faulty_tables.push(table); + + const enqueue = queue.enqueue_faulty_block(address, checksum, .{ + .sync = .{ .table = table, .block = .table_index }, + }); + assert(enqueue == .insert or enqueue == .replace); + + return .insert; + } + + fn enqueue_faulty_block( + queue: *GridBlocksMissing, + address: u64, + checksum: u128, + cause: FaultyBlock.Cause, + ) union(enum) { + insert, + replace: *FaultyBlock, + duplicate, + } { + assert(queue.faulty_tables.count() <= queue.options.tables_max); + assert(queue.faulty_blocks.count() + queue.syncing_faulty_blocks.count() == + queue.enqueued_blocks_repair + queue.enqueued_blocks_sync); + + defer { + assert(queue.faulty_blocks.count() + queue.syncing_faulty_blocks.count() == + queue.enqueued_blocks_repair + queue.enqueued_blocks_sync); + } + + const fault_result = queue.faulty_blocks.getOrPutAssumeCapacity(address); + if (fault_result.found_existing) { + const fault = fault_result.value_ptr; + assert(fault.checksum == checksum); + assert(fault.state != .aborting); + + switch (cause) { + .repair => return .duplicate, + .sync => { + // The value block may already have been queued by either the scrubber or a + // commit/compaction grid read. + assert(fault.cause == .repair); + + queue.enqueued_blocks_repair -= 1; + queue.enqueued_blocks_sync += 1; + fault.cause = cause; + return .{ .replace = fault }; + }, + } + } else { + switch (cause) { + .repair => queue.enqueued_blocks_repair += 1, + .sync => queue.enqueued_blocks_sync += 1, + } + + fault_result.value_ptr.* = .{ + .checksum = checksum, + .cause = cause, + }; + return .insert; + } + } + + pub fn repairing_blocks(queue: *const GridBlocksMissing) bool { + return queue.repairing_tables() or queue.enqueued_blocks_repair > 0; + } + + pub fn repairing_tables(queue: *const GridBlocksMissing) bool { + return queue.state != .sync_jump and queue.enqueued_blocks_sync > 0; + } + + pub fn block_waiting(queue: *const GridBlocksMissing, address: u64, checksum: u128) bool { + const fault_index = queue.faulty_blocks.getIndex(address) orelse return false; + const fault = &queue.faulty_blocks.values()[fault_index]; + return fault.checksum == checksum and fault.state == .waiting; + } + + pub fn write_commence(queue: *GridBlocksMissing, address: u64, checksum: u128) void { + assert(queue.block_waiting(address, checksum)); + maybe(queue.state == .checkpoint_durable); + maybe(queue.state == .sync_jump); + + const fault_index = queue.faulty_blocks.getIndex(address).?; + const fault = &queue.faulty_blocks.values()[fault_index]; + assert(fault.checksum == checksum); + assert(fault.state == .waiting); + if (queue.state == .sync_jump) assert(fault.cause == .repair); + + if (fault.cause == .sync and + fault.cause.sync.block == .table_value) + { + const cause = &fault.cause.sync; + assert(cause.table.table_blocks_written < cause.table.table_blocks_total.?); + assert(!cause.table.value_blocks_received.isSet(cause.block.table_value)); + + cause.table.value_blocks_received.set(cause.block.table_value); + } + + fault.state = .writing; + } + + pub fn write_complete(queue: *GridBlocksMissing, block: BlockPtrConst) void { + const block_header = schema.header_from_block(block); + const fault_index = queue.faulty_blocks.getIndex(block_header.address).?; + const fault_address = queue.faulty_blocks.keys()[fault_index]; + const fault: FaultyBlock = queue.faulty_blocks.values()[fault_index]; + + assert(fault_address == block_header.address); + assert(fault.checksum == block_header.checksum); + assert(fault.state == .aborting or fault.state == .writing); + if (queue.state == .sync_jump) assert(fault.cause == .repair); + + queue.release_fault(fault_index); + + if (fault.state == .aborting) { + queue.state.checkpoint_durable.aborting -= 1; + return; + } + + switch (fault.cause) { + .repair => { + maybe(queue.state == .sync_jump); + }, + .sync => |sync| { + switch (sync.block) { + .table_index => { + assert(queue.state != .sync_jump); + assert(sync.table.value_blocks_received.count() == 0); + + // The reason that the value blocks are queued here (when the write ends) + // rather than when the write begins is so that a `repair_block()` can be + // converted to a `sync_table()` after the former's write is already in + // progress. + queue.enqueue_table_value(fault.cause.sync.table, block); + }, + .table_value => |value_index| { + assert(queue.state != .sync_jump); + assert(sync.table.value_blocks_received.isSet(value_index)); + }, + } + + // We already received the index block. + assert(sync.table.table_blocks_total != null); + assert(sync.table.table_blocks_written < sync.table.table_blocks_total.?); + assert(sync.table.value_blocks_received.count() <= + sync.table.table_blocks_total.? - 1); + + sync.table.table_blocks_written += 1; + if (sync.table.table_blocks_written == sync.table.table_blocks_total.?) { + queue.faulty_tables.remove(sync.table); + queue.faulty_tables_free.push(sync.table); + } + }, + } + } + + fn enqueue_table_value( + queue: *GridBlocksMissing, + table: *RepairTable, + index_block: BlockPtrConst, + ) void { + assert(queue.state != .sync_jump); + assert(queue.faulty_blocks.count() == + queue.enqueued_blocks_repair + queue.enqueued_blocks_sync); + assert(table.table_blocks_total == null); + assert(table.table_blocks_written == 0); + assert(table.value_blocks_received.count() == 0); + + const index_schema = schema.TableIndex.from_block_without_schema(index_block); + const index_block_header = schema.header_from_block(index_block); + assert(index_block_header.address == table.table_info.address); + assert(index_block_header.checksum == table.table_info.checksum); + assert(index_block_header.block_type == .index); + + table.table_blocks_total = index_schema.value_blocks_used(index_block) + 1; + + for ( + index_schema.value_addresses_used(index_block), + index_schema.value_checksums_used(index_block), + 0.., + ) |address, checksum, index| { + const enqueue = queue.enqueue_faulty_block( + address, + checksum.value, + .{ .sync = .{ .table = table, .block = .{ .table_value = @intCast(index) } } }, + ); + + if (enqueue == .replace) { + if (enqueue.replace.state == .writing) { + table.value_blocks_received.set(index); + } + } else { + assert(enqueue == .insert); + } + } + } + + fn release_fault(queue: *GridBlocksMissing, fault_index: usize) void { + switch (queue.faulty_blocks.values()[fault_index].cause) { + .repair => queue.enqueued_blocks_repair -= 1, + .sync => queue.enqueued_blocks_sync -= 1, + } + + queue.faulty_blocks.swapRemoveAt(fault_index); + } + + pub fn cancel(queue: *GridBlocksMissing) void { + queue.verify(); + defer queue.verify(); + + for (queue.faulty_blocks.values()) |*fault| { + switch (fault.state) { + .aborting => unreachable, + .waiting => {}, + .writing => { + // Due to Grid.cancel() this write may not actually take place. + fault.state = .waiting; + + if (fault.cause == .sync and + fault.cause.sync.block == .table_value) + { + const sync = &fault.cause.sync; + assert(sync.table.value_blocks_received.isSet(sync.block.table_value)); + sync.table.value_blocks_received.unset(sync.block.table_value); + } + }, + } + } + } + + /// When we state sync, cancellation of our already-queued missing blocks happens in two stages: + /// 1. First (in this function, called immediately after grid.cancel()) we clean up single-block + /// faults. + /// 2. Later (in sync_complete()), after the state machine is opened with the new checkpoint, we + /// clean up any tables which did not survive into the new checkpoint. + pub fn sync_jump_commence(queue: *GridBlocksMissing) void { + queue.verify(); + defer if (constants.verify) queue.verify(); + // The replica may call sync_jump_commence() without ever calling sync_complete() if it + // syncs multiple checkpoints without successfully opening the state machine. + assert(queue.state == .repairing or queue.state == .sync_jump); + + // Release the "single" blocks since when we finish syncing we have no easy way of checking + // whether they will still be valid. + var faulty_blocks = queue.faulty_blocks.iterator(); + while (faulty_blocks.next()) |fault_entry| { + assert(fault_entry.value_ptr.state == .waiting); + if (fault_entry.value_ptr.cause == .repair) { + faulty_blocks.index -= 1; + faulty_blocks.len -= 1; + queue.release_fault(faulty_blocks.index); + } else { + assert(queue.state == .repairing); + } + } + assert(queue.enqueued_blocks_repair == 0); + + if (queue.state == .repairing) { + queue.state = .sync_jump; + + assert(queue.syncing_faulty_blocks.count() == 0); + std.mem.swap(FaultyBlocks, &queue.faulty_blocks, &queue.syncing_faulty_blocks); + } + assert(queue.faulty_blocks.count() == 0); + assert(queue.syncing_faulty_blocks.count() == queue.enqueued_blocks_sync); + } + + /// Cancel repair for tables that don't belong in the new (sync target) checkpoint. + /// (Unlike checkpoint, we can't just use the free set to determine which blocks to discard.) + pub fn sync_tables_cancel( + queue: *GridBlocksMissing, + tables: []const *RepairTable, + free_set: *const vsr.FreeSet, + ) void { + queue.verify(); + defer if (constants.verify) queue.verify(); + + assert(queue.state == .sync_jump); + + for (tables) |table| { + assert(queue.faulty_tables.contains(table) != queue.faulty_tables_free.contains(table)); + + // The table was already cancelled/completed, it just hasn't been reclaimed yet. + if (queue.faulty_tables_free.contains(table)) continue; + + var faulty_blocks_removed: u32 = 0; + var faulty_blocks = queue.syncing_faulty_blocks.iterator(); + while (faulty_blocks.next()) |fault_entry| { + const fault = fault_entry.value_ptr; + assert(fault.state != .aborting); + + switch (fault.cause) { + .repair => {}, + .sync => |sync| { + assert(fault.state == .waiting); + if (sync.table == table) { + faulty_blocks_removed += 1; + faulty_blocks.index -= 1; + faulty_blocks.len -= 1; + queue.enqueued_blocks_sync -= 1; + queue.syncing_faulty_blocks.swapRemoveAt(faulty_blocks.index); + } + }, + } + } + assert(faulty_blocks_removed == + (table.table_blocks_total orelse 1) - table.table_blocks_written); + assert(queue.faulty_blocks.count() + queue.syncing_faulty_blocks.count() == + queue.enqueued_blocks_sync + queue.enqueued_blocks_repair); + + queue.faulty_tables.remove(table); + queue.faulty_tables_free.push(table); + } + queue.sync_complete(free_set); + } + + fn sync_complete(queue: *GridBlocksMissing, free_set: *const vsr.FreeSet) void { + queue.verify(); + defer if (constants.verify) queue.verify(); + + assert(queue.state == .sync_jump); + assert(free_set.opened); + + queue.state = .repairing; + std.mem.swap(FaultyBlocks, &queue.faulty_blocks, &queue.syncing_faulty_blocks); + + // Move any leftover block repairs (from faults incurred during since + // `sync_jump_commence()`) back to `faulty_blocks`. + while (queue.syncing_faulty_blocks.pop()) |fault_entry| { + assert(fault_entry.value.cause == .repair); + + const fault_address = fault_entry.key; + const fault_result = queue.faulty_blocks.getOrPutAssumeCapacity(fault_address); + assert(!fault_result.found_existing); + fault_result.value_ptr.* = fault_entry.value; + } + + for (queue.faulty_blocks.keys()) |fault_address| { + assert(!free_set.is_free(fault_address)); + } + } + + /// Aborts queued repairs to blocks to be freed, now that the current checkpoint is durable. + pub fn checkpoint_durable_commence( + queue: *GridBlocksMissing, + free_set: *const vsr.FreeSet, + ) void { + queue.verify(); + defer if (constants.verify) queue.verify(); + + assert(queue.state == .repairing); + assert(queue.faulty_blocks.count() == + queue.enqueued_blocks_repair + queue.enqueued_blocks_sync); + assert(free_set.opened); + + var aborting: usize = 0; + + var faulty_blocks = queue.faulty_blocks.iterator(); + while (faulty_blocks.next()) |fault_entry| { + const fault_address = fault_entry.key_ptr.*; + assert(!free_set.is_free(fault_address)); + assert(fault_entry.value_ptr.state != .aborting); + // Use `to_be_freed_at_checkpoint_durability` instead of `is_released`; the latter also + // contains the blocks that will be released when the *next* checkpoint becomes durable. + if (free_set.to_be_freed_at_checkpoint_durability(fault_address)) { + switch (fault_entry.value_ptr.state) { + .waiting => { + faulty_blocks.index -= 1; + faulty_blocks.len -= 1; + queue.release_fault(faulty_blocks.index); + }, + .writing => { + fault_entry.value_ptr.state = .aborting; + aborting += 1; + }, + .aborting => unreachable, + } + } + } + + var tables: QueueType(RepairTable) = QueueType(RepairTable).init(.{ + .name = queue.faulty_tables.any.name, + }); + while (queue.faulty_tables.pop()) |table| { + assert(!free_set.is_free(table.table_info.address)); + + if (free_set.to_be_freed_at_checkpoint_durability(table.table_info.address)) { + queue.faulty_tables_free.push(table); + } else { + tables.push(table); + } + } + queue.faulty_tables = tables; + + queue.state = .{ .checkpoint_durable = .{ .aborting = aborting } }; + } + + /// Returns `true` when the `state≠waiting` faults for blocks that are staged to be + /// released have finished. (All other writes can safely complete after the checkpoint.) + pub fn checkpoint_durable_complete(queue: *GridBlocksMissing) bool { + queue.verify(); + assert(queue.state == .checkpoint_durable); + assert(queue.faulty_blocks.count() == + queue.enqueued_blocks_repair + queue.enqueued_blocks_sync); + + if (queue.state.checkpoint_durable.aborting == 0) { + queue.state = .repairing; + + for (queue.faulty_blocks.values()) |*faulty_block| { + assert(faulty_block.state != .aborting); + } + + return true; + } else { + return false; + } + } +}; diff --git a/ocam/src/vsr/grid_scrubber.zig b/ocam/src/vsr/grid_scrubber.zig new file mode 100644 index 00000000..d12dc0fd --- /dev/null +++ b/ocam/src/vsr/grid_scrubber.zig @@ -0,0 +1,798 @@ +//! Scrub grid blocks. +//! +//! A "data scrubber" is a background task that gradually/incrementally reads the disk and validates +//! what it finds. Its purpose is to discover faults proactively – as early as possibly – rather +//! than waiting for them to be discovered by normal database operation (e.g. during compaction). +//! +//! The most common type of disk fault is a latent sector error: +//! +//! - A "latent sector error" is the temporary or permanent inability to access the data of a +//! particular sector. That is, the disk as a whole continues to function, but a small section of +//! data is unavailable. +//! - "Latent" refers to: the error is not discoverable until the sector is actually read. +//! - "An Analysis of Latent Sector Errors in Disk Drives" (2007) found that >60% of latent sector +//! errors were discovered by a scrubber that cycles every 2 weeks. +//! +//! Finding and repairing errors proactively minimizes the risk of cluster data loss due to multiple +//! intersecting faults (analogous to a "double-fault") – a scenario where we fail to read a block, +//! and try to repair the block from another replica, only to discover that the copy of the block on +//! the remote replica's disk is *also* faulty. +//! +//! TODO Accelerate scrubbing rate (at runtime) if faults are detected frequently. +const std = @import("std"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const KiB = stdx.KiB; +const TiB = stdx.TiB; +const log = std.log.scoped(.grid_scrubber); + +const stdx = @import("stdx"); +const vsr = @import("../vsr.zig"); +const constants = @import("../constants.zig"); +const schema = @import("../lsm/schema.zig"); +const QueueType = @import("../queue.zig").QueueType; +const IOPSType = stdx.IOPSType; + +const GridType = @import("./grid.zig").GridType; +const BlockPtrConst = @import("./grid.zig").BlockPtrConst; +const ForestTableIteratorType = @import("../lsm/forest_table_iterator.zig").ForestTableIteratorType; + +pub fn GridScrubberType(comptime Forest: type, grid_scrubber_reads_max: comptime_int) type { + return struct { + const GridScrubber = @This(); + const Grid = GridType(Forest.Storage); + const WrappingForestTableIterator = WrappingForestTableIteratorType(Forest); + const SuperBlock = vsr.SuperBlockType(Forest.Storage); + const ManifestBlockIterator = ManifestBlockIteratorType(Forest.ManifestLog); + const CheckpointTrailer = vsr.CheckpointTrailerType(Forest.Storage); + + pub const BlockId = struct { + block_checksum: u128, + block_address: u64, + block_type: schema.BlockType, + }; + + pub const BlockStatus = enum { + /// If `read.done`: The scrub failed – the block must be repaired. + /// If `!read.done`: The scrub is still in progress. (This is the initial state). + repair, + /// The scrub succeeded. + /// Don't repair the block. + ok, + /// The scrub was aborted (the replica is about to state-sync). + /// Don't repair the block. + canceled, + /// The block was freed by a checkpoint in the time that the read was in progress. + /// Don't repair the block. + /// + /// (At checkpoint, the FreeSet frees blocks released during the preceding + /// checkpoint. We can scrub released blocks, but not free blocks. Setting this flag + /// ensures that GridScrubber doesn't require a read-barrier at checkpoint.) + released, + }; + + const Read = struct { + scrubber: *GridScrubber, + read: Grid.Read = undefined, + block_type: schema.BlockType, + + status: BlockStatus, + + /// Whether the read is ready to be released. + done: bool, + + /// For `reads_busy`/`reads_done` queues. + link: QueueType(Read).Link = .{}, + }; + + superblock: *SuperBlock, + forest: *Forest, + client_sessions_checkpoint: *const CheckpointTrailer, + + reads: IOPSType(Read, grid_scrubber_reads_max) = .{}, + + /// A list of reads that are in progress. + reads_busy: QueueType(Read) = QueueType(Read).init(.{ .name = "grid_scrubber_reads_busy" }), + /// A list of reads that are ready to be released. + reads_done: QueueType(Read) = QueueType(Read).init(.{ .name = "grid_scrubber_reads_done" }), + + /// Track the progress through the grid. + /// + /// Every full tour... + /// - ...on an idle replica (i.e. not committing) scrubs every acquired block in the grid. + /// - ...on a non-idle replica scrubs all blocks that survived the entire span of the tour + /// without moving to a different level, but may not scrub blocks that were added during + /// the tour or which moved. + tour: union(enum) { + init, + done, + table_index, + table_value: struct { + index_checksum: u128, + index_address: u64, + /// Points to `tour_index_block` once the index block has been read. + index_block: ?BlockPtrConst = null, + value_block_index: u32 = 0, + }, + /// The manifest log tour iterates manifest blocks in reverse order. + /// (To ensure that manifest compaction doesn't lead to missed blocks.) + manifest_log: struct { iterator: ManifestBlockIterator = .init }, + free_set_blocks_acquired: struct { index: u32 = 0 }, + free_set_blocks_released: struct { index: u32 = 0 }, + client_sessions: struct { index: u32 = 0 }, + }, + + /// When tour == .init, tour_tables == .{} + /// When tour == .done, tour_tables.next() == null. + tour_tables: ?WrappingForestTableIterator, + /// The "offset" within the LSM from which scrubber table iteration cycles begin/end. + /// This varies between replicas to minimize risk of data loss. + tour_tables_origin: ?WrappingForestTableIterator.Origin, + + /// Contains a table index block when tour=table_value. + tour_index_block: BlockPtrConst, + + /// These counters reset after every tour cycle. + /// NB: tour_blocks_scrubbed_count will include repeat index blocks reads. + /// (See read_next_callback() for more detail.) + tour_blocks_scrubbed_count: u64, + + pub fn init( + allocator: std.mem.Allocator, + forest: *Forest, + client_sessions_checkpoint: *const CheckpointTrailer, + ) error{OutOfMemory}!GridScrubber { + _ = allocator; + + const tour_index_block = forest.grid.get_block(); + errdefer forest.grid.block_unref(tour_index_block); + + return .{ + .superblock = forest.grid.superblock, + .forest = forest, + .client_sessions_checkpoint = client_sessions_checkpoint, + .tour = .init, + .tour_tables = null, + .tour_tables_origin = null, + .tour_index_block = tour_index_block, + .tour_blocks_scrubbed_count = 0, + }; + } + + pub fn deinit(scrubber: *GridScrubber, allocator: std.mem.Allocator) void { + _ = allocator; + + scrubber.forest.grid.block_unref(scrubber.tour_index_block); + scrubber.* = undefined; + } + + pub fn open(scrubber: *GridScrubber, prng: *stdx.PRNG) void { + // Compute the tour origin exactly once. + if (scrubber.tour_tables_origin != null) { + return; + } + + // Each replica's scrub origin is chosen independently. + // This reduces the chance that the same block across multiple replicas can bitrot + // without being discovered and repaired by a scrubber. + // + // To accomplish this, try to select an origin uniformly across all blocks: + // - Bias towards levels with more tables. + // - Bias towards trees with more blocks per table. + // - (Though, for ease of implementation, the origin is always at the beginning of a + // tree's level, never in the middle.) + assert(scrubber.tour == .init); + + scrubber.tour_tables_origin = .{ + .level = 0, + .tree_id = Forest.tree_infos[0].tree_id, + }; + + var reservoir = stdx.PRNG.Reservoir.init(); + + for (0..constants.lsm_levels) |level| { + inline for (Forest.tree_infos) |tree_info| { + const tree_id = comptime Forest.tree_id_cast(tree_info.tree_id); + const tree = scrubber.forest.tree_for_id_const(tree_id); + const levels = &tree.manifest.levels; + const tree_level_weight = @as(u64, levels[level].tables.len()) * + tree_info.Tree.Table.index.value_block_count_max; + if (tree_level_weight > 0 and reservoir.replace(prng, tree_level_weight)) { + scrubber.tour_tables_origin = .{ + .level = @intCast(level), + .tree_id = tree_info.tree_id, + }; + } + } + } + + scrubber.tour_tables = WrappingForestTableIterator.init(scrubber.tour_tables_origin.?); + + log.debug("{}: open: tour_tables_origin.level={} tour_tables_origin.tree_id={}", .{ + scrubber.superblock.replica_index.?, + scrubber.tour_tables_origin.?.level, + scrubber.tour_tables_origin.?.tree_id, + }); + } + + pub fn cancel(scrubber: *GridScrubber) void { + for ([_]QueueType(Read){ scrubber.reads_busy, scrubber.reads_done }) |reads_fifo| { + var reads_iterator = reads_fifo.iterate(); + while (reads_iterator.next()) |read| { + read.status = .canceled; + } + } + + if (scrubber.tour == .table_value) { + // Skip scrubbing the table data; the table may not exist when state sync finishes. + scrubber.tour = .table_index; + } + } + + /// Cancel queued reads to blocks that will be freed, now that the current checkpoint is + /// durable. (The read still runs, but the results will be ignored.) + pub fn checkpoint_durable(scrubber: *GridScrubber) void { + assert(scrubber.superblock.opened); + // GridScrubber.checkpoint_durable() is called immediately before + // FreeSet.mark_checkpoint_durable(). All released blocks are about to be freed. + assert(scrubber.forest.grid.callback == .none); + + for ([_]QueueType(Read){ scrubber.reads_busy, scrubber.reads_done }) |reads_fifo| { + var reads_iterator = reads_fifo.iterate(); + while (reads_iterator.next()) |read| { + if (read.status == .repair) { + assert(!scrubber.forest.grid.free_set.is_free(read.read.address)); + // Use `to_be_freed_at_checkpoint_durability` instead of `is_released`; + // the latter also contains the blocks that will be released when the + // *next* checkpoint becomes durable. We only need to abort scrubbing for + // blocks that are just about to be freed. + if (scrubber.forest.grid.free_set + .to_be_freed_at_checkpoint_durability(read.read.address)) + { + read.status = .released; + } + } + } + } + + if (scrubber.tour == .table_value) { + const index_address = scrubber.tour.table_value.index_address; + assert(!scrubber.forest.grid.free_set.is_free(index_address)); + + if (scrubber.forest.grid.free_set + .to_be_freed_at_checkpoint_durability(index_address)) + { + // Skip scrubbing the table data, since the table is about to be released. + scrubber.tour = .table_index; + } + } + } + + /// Returns whether or not a new Read was started. + pub fn read_next(scrubber: *GridScrubber) bool { + assert(scrubber.superblock.opened); + assert(scrubber.forest.grid.callback != .cancel); + assert(scrubber.reads_busy.count() + scrubber.reads_done.count() == + scrubber.reads.executing()); + defer assert(scrubber.reads_busy.count() + scrubber.reads_done.count() == + scrubber.reads.executing()); + + if (scrubber.reads.available() == 0) return false; + const block_id = scrubber.tour_next() orelse return false; + scrubber.tour_blocks_scrubbed_count += 1; + + const read = scrubber.reads.acquire().?; + assert(!scrubber.reads_busy.contains(read)); + assert(!scrubber.reads_done.contains(read)); + + log.debug("{}: read_next: address={} checksum={x:0>32} type={s}", .{ + scrubber.superblock.replica_index.?, + block_id.block_address, + block_id.block_checksum, + @tagName(block_id.block_type), + }); + + read.* = .{ + .scrubber = scrubber, + .block_type = block_id.block_type, + .status = .repair, + .done = false, + }; + scrubber.reads_busy.push(read); + + scrubber.forest.grid.read_block( + .{ .from_local_storage = read_next_callback }, + &read.read, + block_id.block_address, + block_id.block_checksum, + .{ .cache_read = false, .cache_write = false }, + ); + return true; + } + + fn read_next_callback(grid_read: *Grid.Read, result: Grid.ReadBlockResult) void { + const read: *Read = @fieldParentPtr("read", grid_read); + const scrubber = read.scrubber; + assert(scrubber.reads_busy.contains(read)); + assert(!scrubber.reads_done.contains(read)); + assert(!read.done); + maybe(read.status != .repair); + + log.debug("{}: read_next_callback: result={s} " ++ + "(address={} checksum={x:0>32} type={s} status={?})", .{ + scrubber.superblock.replica_index.?, + @tagName(result), + read.read.address, + read.read.checksum, + @tagName(read.block_type), + read.status, + }); + + if (read.status == .repair and + scrubber.tour == .table_value and + scrubber.tour.table_value.index_block == null and + scrubber.tour.table_value.index_checksum == read.read.checksum and + scrubber.tour.table_value.index_address == read.read.address) + { + assert(scrubber.tour.table_value.value_block_index == 0); + + if (result == .valid) { + scrubber.forest.grid.block_unref(scrubber.tour_index_block); + scrubber.tour_index_block = + scrubber.forest.grid.block_ref(result.valid); + scrubber.tour.table_value.index_block = scrubber.tour_index_block; + } else { + // The scrubber can't scrub the table value blocks until it has the + // corresponding index block. We will wait for the index block, and keep + // re-scrubbing it until it is repaired (or until the block is released by + // a checkpoint). + // + // (Alternatively, we could just skip past the table value blocks, and we will + // come across them again during the next cycle. But waiting for them makes for + // nicer invariants + tests.) + log.debug("{}: read_next_callback: waiting for index repair " ++ + "(address={} checksum={x:0>32})", .{ + scrubber.superblock.replica_index.?, + read.read.address, + read.read.checksum, + }); + } + } + + if (result == .valid) { + if (read.status == .repair) { + read.status = .ok; + } + } + + read.done = true; + scrubber.reads_busy.remove(read); + scrubber.reads_done.push(read); + } + + pub fn read_result_next(scrubber: *GridScrubber) ?struct { + block: BlockId, + status: BlockStatus, + } { + assert(scrubber.reads_busy.count() + scrubber.reads_done.count() == + scrubber.reads.executing()); + defer assert(scrubber.reads_busy.count() + scrubber.reads_done.count() == + scrubber.reads.executing()); + + const read = scrubber.reads_done.pop() orelse return null; + defer scrubber.reads.release(read); + + assert(read.done); + + const block: BlockId = .{ + .block_address = read.read.address, + .block_checksum = read.read.checksum, + .block_type = read.block_type, + }; + return .{ .block = block, .status = read.status }; + } + + fn tour_next(scrubber: *GridScrubber) ?BlockId { + assert(scrubber.superblock.opened); + assert(scrubber.forest.manifest_log.opened); + assert(scrubber.tour_tables_origin != null); + + const tour = &scrubber.tour; + if (tour.* == .init) { + tour.* = .table_index; + } + + if (tour.* == .table_value) { + const index_block = tour.table_value.index_block orelse { + // The table index is `null` if: + // - It was corrupt when we just scrubbed it. + // - Or `grid_scrubber_reads > 1`. + // Keep trying until either we find it, or a checkpoint removes it. + // (See read_next_callback() for more detail.) + return .{ + .block_checksum = tour.table_value.index_checksum, + .block_address = tour.table_value.index_address, + .block_type = .index, + }; + }; + + const index_schema = schema.TableIndex.from_block_without_schema(index_block); + const value_block_index = tour.table_value.value_block_index; + if (value_block_index < + index_schema.value_blocks_used(scrubber.tour_index_block)) + { + tour.table_value.value_block_index += 1; + + const value_block_addresses = + index_schema.value_addresses_used(scrubber.tour_index_block); + const value_block_checksums = + index_schema.value_checksums_used(scrubber.tour_index_block); + return .{ + .block_checksum = value_block_checksums[value_block_index].value, + .block_address = value_block_addresses[value_block_index], + .block_type = .value, + }; + } else { + assert(value_block_index == + index_schema.value_blocks_used(scrubber.tour_index_block)); + tour.* = .table_index; + } + } + + if (tour.* == .table_index) { + if (scrubber.tour_tables.?.next(scrubber.forest)) |table_info| { + scrubber.forest.grid.verify_table( + table_info.address, + table_info.checksum, + ); + + tour.* = .{ .table_value = .{ + .index_checksum = table_info.checksum, + .index_address = table_info.address, + } }; + + return .{ + .block_checksum = table_info.checksum, + .block_address = table_info.address, + .block_type = .index, + }; + } else { + tour.* = .{ .manifest_log = .{} }; + } + } + + if (tour.* == .manifest_log) { + if (tour.manifest_log.iterator.next( + &scrubber.forest.manifest_log, + )) |block_reference| { + return .{ + .block_checksum = block_reference.checksum, + .block_address = block_reference.address, + .block_type = .manifest, + }; + } else { + tour.* = .{ .free_set_blocks_acquired = .{} }; + } + } + + if (tour.* == .free_set_blocks_acquired) { + const free_set_trailer = &scrubber.forest.grid.free_set_checkpoint_blocks_acquired; + if (free_set_trailer.callback != .none) return null; + if (tour.free_set_blocks_acquired.index < free_set_trailer.block_count()) { + const index = tour.free_set_blocks_acquired.index; + tour.free_set_blocks_acquired.index += 1; + return .{ + .block_checksum = free_set_trailer.block_checksums[index], + .block_address = free_set_trailer.block_addresses[index], + .block_type = .free_set, + }; + } else { + // A checkpoint can reduce the number of trailer blocks while we are scrubbing + // the trailer. + maybe(tour.free_set_blocks_acquired.index > free_set_trailer.block_count()); + tour.* = .{ .free_set_blocks_released = .{} }; + } + } + + if (tour.* == .free_set_blocks_released) { + const free_set_trailer = &scrubber.forest.grid.free_set_checkpoint_blocks_released; + if (free_set_trailer.callback != .none) return null; + if (tour.free_set_blocks_released.index < free_set_trailer.block_count()) { + const index = tour.free_set_blocks_released.index; + tour.free_set_blocks_released.index += 1; + return .{ + .block_checksum = free_set_trailer.block_checksums[index], + .block_address = free_set_trailer.block_addresses[index], + .block_type = .free_set, + }; + } else { + // A checkpoint can reduce the number of trailer blocks while we are scrubbing + // the trailer. + maybe(tour.free_set_blocks_released.index > free_set_trailer.block_count()); + tour.* = .{ .client_sessions = .{} }; + } + } + + if (tour.* == .client_sessions) { + const client_sessions = scrubber.client_sessions_checkpoint; + if (client_sessions.callback != .none) return null; + if (tour.client_sessions.index < client_sessions.block_count()) { + const index = tour.client_sessions.index; + tour.client_sessions.index += 1; + return .{ + .block_checksum = client_sessions.block_checksums[index], + .block_address = client_sessions.block_addresses[index], + .block_type = .client_sessions, + }; + } else { + // A checkpoint can reduce the number of trailer blocks while we are scrubbing + // the trailer. + maybe(tour.client_sessions.index > client_sessions.block_count()); + tour.* = .done; + } + } + + // Note that this is just the end of the tour. + // (Some of the cycle's reads may still be in progress). + log.debug("{}: tour_next: cycle done (toured_blocks={})", .{ + scrubber.superblock.replica_index.?, + scrubber.tour_blocks_scrubbed_count, + }); + + assert(tour.* == .done); + return null; + } + + pub fn wrap(scrubber: *GridScrubber) void { + assert(scrubber.tour == .done); + + scrubber.tour = .init; + + scrubber.tour_tables = WrappingForestTableIterator.init(scrubber.tour_tables_origin.?); + scrubber.tour_blocks_scrubbed_count = 0; + } + }; +} + +fn WrappingForestTableIteratorType(comptime Forest: type) type { + return struct { + const WrappingForestTableIterator = @This(); + const ForestTableIterator = ForestTableIteratorType(Forest); + + origin: Origin, + tables: ForestTableIterator, + wrapped: bool, + + pub const Origin = struct { + level: u6, + tree_id: u16, + }; + + pub fn init(origin: Origin) WrappingForestTableIterator { + return .{ + .origin = origin, + .tables = .{ + .level = origin.level, + .tree_id = origin.tree_id, + }, + .wrapped = false, + }; + } + + pub fn next( + iterator: *WrappingForestTableIterator, + forest: *const Forest, + ) ?schema.ManifestNode.TableInfo { + const table = iterator.tables.next(forest) orelse { + if (iterator.wrapped) { + return null; + } else { + iterator.wrapped = true; + iterator.tables = .{}; + return iterator.tables.next(forest); + } + }; + + if (iterator.wrapped and + iterator.origin.level <= table.label.level and + iterator.origin.tree_id <= table.tree_id) + { + return null; + } + return table; + } + }; +} + +/// Iterate over every manifest block address/checksum in the manifest log. +/// +/// This iterator is stable across ManifestLog mutation – that is, it is guaranteed to iterate over +/// every manifest block that survives the entire iteration. +fn ManifestBlockIteratorType(comptime ManifestLog: type) type { + return union(enum) { + const ManifestBlockIterator = @This(); + + init, + done, + state: struct { + /// The last-known index (within the manifest blocks) of the address/checksum. + index: u32, + /// The address/checksum of the most-recently iterated manifest block. + address: u64, + checksum: u128, + }, + + fn next( + iterator: *ManifestBlockIterator, + manifest_log: *const ManifestLog, + ) ?vsr.BlockReference { + // Don't scrub the trailing `blocks_closed`; they are not yet flushed to disk. + const log_block_count: u32 = + @intCast(manifest_log.log_block_addresses.count - manifest_log.blocks_closed); + + const position: ?u32 = switch (iterator.*) { + .done => null, + .init => if (log_block_count == 0) null else log_block_count - 1, + .state => |state| position: { + // `index` may be beyond the limit due to blocks removed by manifest compaction. + maybe(state.index >= log_block_count); + + // The block that we most recently scrubbed may: + // - be in the same position, or + // - have shifted earlier in the list (due to manifest compaction), or + // - have been removed from the list (due to manifest compaction). + // Use the block's old position to find its current position. + var position: u32 = @min(state.index, log_block_count -| 1); + while (position > 0) : (position -= 1) { + if (manifest_log.log_block_addresses.get(position).? == state.address and + manifest_log.log_block_checksums.get(position).? == state.checksum) + { + break :position if (position == 0) null else position - 1; + } + } else { + break :position null; + } + }, + }; + + if (position) |index| { + iterator.* = .{ .state = .{ + .index = index, + .address = manifest_log.log_block_addresses.get(index).?, + .checksum = manifest_log.log_block_checksums.get(index).?, + } }; + + return .{ + .address = iterator.state.address, + .checksum = iterator.state.checksum, + }; + } else { + iterator.* = .done; + return null; + } + } + }; +} + +// Model the probability that the cluster experiences data loss due to bitrot. +// Specifically, that *every* copy of *any* block is corrupted before the scrubber can repair it. +// +// Optimistic assumptions (see below): +// - Faults are independent between replicas. ¹ +// - Faults are independent (i.e. uncorrelated) in space and time. ² +// +// Pessimistic assumptions: +// - There are only 3 (quorum_replication) copies of each sector. +// - Scrub randomization is ignored. +// - The simulated fault rate is much greater than a real disk's. (See `sector_faults_per_year`). +// - Reads, writes, and repairs due to other workloads (besides the scrubber) are not modeled. +// - All blocks are always full (512KiB). +// +// ¹: To mitigate the risk of correlated errors in production, replicas could use different SSD +// (hardware) models. +// +// ²: SSD faults are not independent (in either time or space). +// See, for example: +// - "An In-Depth Study of Correlated Failures in Production SSD-Based Data Centers" +// (https://www.usenix.org/system/files/fast21-han.pdf) +// - "Flash Reliability in Production: The Expected and the Unexpected" +// (https://www.usenix.org/system/files/conference/fast16/fast16-papers-schroeder.pdf) +// That being said, for the purposes of modeling scrubbing, it is a decent approximation because +// blocks are large relative to sectors. (Additionally, blocks that are written together are often +// scrubbed together). +test "GridScrubber cycle interval" { + // Parameters: + + // The number of years that the test is "running". As the test runs longer, the probability that + // the cluster will experience data loss increases. + const test_duration_years = 20; + + // The number of days between scrubs of a particular sector. + // Equivalently, the number of days to scrub the entire data file. + const cycle_interval_days = 180; + + // The total size of the data file. + // Note that since this parameter is separate from the faults/year rate, increasing + // `storage_size` actually reduces the likelihood of data loss. + const storage_size = 16 * TiB; + + // The expected (average) number of sector faults per year. + // I can't find any good, recent statistics for faults on SSDs. + // + // Most papers express the fault rate as "UBER" (uncorrectable bit errors per total bits read). + // But "Flash Reliability in Production: The Expected and the Unexpected" §5.1 finds that + // UBER's underlying assumption ­ that the uncorrectable errors is correlated to the number of + // bytes read ­ is false. (That paper only shares "fraction of drives affected by an error", + // which is too coarse for this model's purposes.) + // + // Instead, the parameter is chosen conservatively ­ greater than the "true" number by at least + // an order of magnitude. + const sector_faults_per_year = 10_000; + + // A block has multiple sectors. If any of a block's sectors are corrupt, then the block is + // corrupt. + // + // Increasing this parameter increases the likelihood of eventual data loss. + // (Intuitively, a single bitrot within 1GiB is more likely than a single bitrot within 1KiB.) + const block_size = 512 * KiB; + + // The total number of copies of each sector. + // The cluster is recoverable if a sector's number of faults is less than `replicas_total`. + // Set to 3 rather than 6 since 3 is the quorum_replication. + const replicas_total = 3; + + const sector_size = constants.sector_size; + + // Computation: + + const block_sectors = @divExact(block_size, sector_size); + const storage_sectors = @divExact(storage_size, sector_size); + const storage_blocks = @divExact(storage_size, block_size); + const test_duration_days = test_duration_years * 365; + const test_duration_cycles = stdx.div_ceil(test_duration_days, cycle_interval_days); + const sector_faults_per_cycle = + stdx.div_ceil(sector_faults_per_year * cycle_interval_days, 365); + + // P(a specific block is uncorrupted for an entire cycle) + // If any of the block's sectors is corrupted, then the whole block is corrupted. + const p_block_healthy_per_cycle = std.math.pow( + f64, + @as(f64, @floatFromInt(storage_sectors - block_sectors)) / + @as(f64, @floatFromInt(storage_sectors)), + @as(f64, @floatFromInt(sector_faults_per_cycle)), + ); + + const p_block_corrupt_per_cycle = 1.0 - p_block_healthy_per_cycle; + // P(a specific block is corrupted on all replicas during a single cycle) + const p_cluster_block_corrupt_per_cycle = + std.math.pow(f64, p_block_corrupt_per_cycle, @as(f64, @floatFromInt(replicas_total))); + // P(a specific block is uncorrupted on at least one replica during a single cycle) + const p_cluster_block_healthy_per_cycle = 1.0 - p_cluster_block_corrupt_per_cycle; + + // P(a specific block is uncorrupted on at least one replica for all cycles) + // Note that each cycle can be considered independently because we assume that if is at the end + // of the cycle there is at least one healthy copy, then all of the corrupt copies are repaired. + const p_cluster_block_healthy_per_span = std.math.pow( + f64, + p_cluster_block_healthy_per_cycle, + @as(f64, @floatFromInt(test_duration_cycles)), + ); + + // P(each block is uncorrupted on at least one replica for all cycles) + const p_cluster_blocks_healthy_per_span = std.math.pow( + f64, + p_cluster_block_healthy_per_span, + @as(f64, @floatFromInt(storage_blocks)), + ); + + // P(at some point during all cycles, at least one block is corrupt across all replicas) + // In other words, P(eventual data loss). + const p_cluster_blocks_corrupt_per_span = 1.0 - p_cluster_blocks_healthy_per_span; + + const Snap = stdx.Snap; + const snap = Snap.snap_fn("src"); + + try snap(@src(), + \\4.3582921528e-3 + ).diff_fmt("{e:.10}", .{p_cluster_blocks_corrupt_per_span}); +} diff --git a/ocam/src/vsr/journal.zig b/ocam/src/vsr/journal.zig new file mode 100644 index 00000000..0cd910be --- /dev/null +++ b/ocam/src/vsr/journal.zig @@ -0,0 +1,2586 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const maybe = stdx.maybe; + +const constants = @import("../constants.zig"); + +const Message = @import("../message_pool.zig").MessagePool.Message; +const stdx = @import("stdx"); +const vsr = @import("../vsr.zig"); +const Header = vsr.Header; +const IOPSType = stdx.IOPSType; + +const log = std.log.scoped(.journal); + +/// The WAL consists of two contiguous circular buffers on disk: +/// - `vsr.Zone.wal_headers` +/// - `vsr.Zone.wal_prepares` +/// +/// In each ring, the `op` for reserved headers is set to the corresponding slot index. +/// This helps WAL recovery detect misdirected reads/writes. +const Ring = enum { + /// A circular buffer of (redundant) prepare message headers. + headers, + /// A circular buffer of prepare messages. Each slot is padded to `constants.message_size_max`. + prepares, + + /// Returns the slot's offset relative to the start of the ring. + inline fn offset(comptime ring: Ring, slot: Slot) u64 { + assert(slot.index < slot_count); + switch (ring) { + .headers => { + comptime assert(constants.sector_size % @sizeOf(Header) == 0); + const ring_offset = vsr.sector_floor(slot.index * @sizeOf(Header)); + assert(ring_offset < headers_size); + return ring_offset; + }, + .prepares => { + const ring_offset = constants.message_size_max * slot.index; + assert(ring_offset < prepares_size); + return ring_offset; + }, + } + } +}; + +const headers_per_sector = @divExact(constants.sector_size, @sizeOf(Header)); +const headers_per_message = @divExact(constants.message_size_max, @sizeOf(Header)); +comptime { + assert(headers_per_sector > 0); + assert(headers_per_message > 0); +} + +/// A slot is an index within: +/// +/// - the on-disk headers ring +/// - the on-disk prepares ring +/// - `journal.headers` +/// - `journal.headers_redundant` +/// - `journal.dirty` +/// - `journal.faulty` +/// +/// A header's slot is `header.op % constants.journal_slot_count`. +const Slot = struct { index: usize }; + +/// An inclusive, non-empty range of slots. +pub const SlotRange = struct { + head: Slot, + tail: Slot, + + /// Returns whether this range (inclusive) includes the specified slot. + /// + /// Cases (`·`=included, ` `=excluded): + /// + /// * `head < tail` → ` head··tail ` + /// * `head > tail` → `··tail head··` (The range wraps around). + /// * `head = tail` → panic (Caller must handle this case separately). + pub fn contains(range: *const SlotRange, slot: Slot) bool { + // To avoid confusion, the empty range must be checked separately by the caller. + assert(range.head.index != range.tail.index); + + if (range.head.index < range.tail.index) { + return range.head.index <= slot.index and slot.index <= range.tail.index; + } + if (range.head.index > range.tail.index) { + return slot.index <= range.tail.index or range.head.index <= slot.index; + } + unreachable; + } +}; + +const slot_count = constants.journal_slot_count; +const headers_size = constants.journal_size_headers; +const prepares_size = constants.journal_size_prepares; + +pub const write_ahead_log_zone_size = headers_size + prepares_size; + +/// Limit on the number of repair reads. +/// This keeps some reads available for commit path, so that an asymmetrically +/// partitioned replica cannot starve the cluster with get_prepare messages. +const reads_repair_count_max: u6 = constants.journal_iops_read_max - reads_commit_count_max; +/// We need at most two reads on commit path: one for commit_journal, and one for +/// primary_repair_pipeline_read. +const reads_commit_count_max: u6 = 2; + +comptime { + assert(slot_count > 0); + assert(slot_count % 2 == 0); + assert(slot_count % headers_per_sector == 0); + assert(slot_count >= headers_per_sector); + // The length of the prepare pipeline is the upper bound on how many ops can be + // reordered during a view change. See `recover_prepares_callback()` for more detail. + assert(slot_count > constants.pipeline_prepare_queue_max); + + assert(headers_size > 0); + assert(headers_size % constants.sector_size == 0); + // It's important that the replica doesn't write all redundant headers simultaneously. + // Otherwise, a crash could lead to a series of torn writes making the entire journal faulty. + // Normally, this guarantee falls out naturally out of the fact that there are fewer journal + // writes available than there are sectors. This is not the case for the simulator, which only + // has two sectors worth of headers. Rather than adding simulator-only locking to the journal, + // the simulator itself prevents correlated torn writes at runtime, and we just exclude the + // simulator from the assert: + assert( + @divExact(headers_size, constants.sector_size) > constants.journal_iops_write_max or + !constants.config.is_production(), + ); + + assert(prepares_size > 0); + assert(prepares_size % constants.sector_size == 0); + assert(prepares_size % constants.message_size_max == 0); + + assert(reads_repair_count_max > 0); + assert(reads_repair_count_max + reads_commit_count_max == constants.journal_iops_read_max); +} + +pub fn JournalType(comptime Replica: type, comptime Storage: type) type { + return struct { + const Journal = @This(); + const Sector = *align(constants.sector_size) [constants.sector_size]u8; + + const Status = union(enum) { + init: void, + recovering: *const fn (journal: *Journal) void, + recovered: void, + }; + + pub const Read = struct { + journal: *Journal, + completion: Storage.Read, + message: *Message.Prepare, + options: Options, + callback: Callback, + + pub const Options = struct { + op: u64, + checksum: u128, + destination_replica: ?u8 = null, + }; + + const Callback = *const fn ( + replica: *Replica, + prepare: ?*Message.Prepare, + options: Options, + ) void; + }; + + pub const Write = struct { + journal: *Journal, + callback: *const fn ( + replica: *Replica, + wrote: ?*Message.Prepare, + ) void, + + message: *Message.Prepare, + + /// This is reset to undefined and reused for each Storage.write_sectors() call. + range: Range, + }; + + /// State that needs to be persisted while waiting for an overlapping + /// concurrent write to complete. This is a range on the physical disk. + const Range = struct { + completion: Storage.Write, + callback: *const fn (write: *Journal.Write) void, + buffer: []const u8, + ring: Ring, + /// Offset within the ring. + offset: u64, + + /// If other writes are waiting on this write to proceed, they will + /// be queued up in this linked list. + next: ?*Range = null, + /// True if a Storage.write_sectors() operation is in progress for this buffer/offset. + locked: bool, + + fn overlaps(journal: *const Range, other: *const Range) bool { + if (journal.ring != other.ring) return false; + + if (journal.offset < other.offset) { + return journal.offset + journal.buffer.len > other.offset; + } else { + return other.offset + other.buffer.len > journal.offset; + } + } + }; + + const HeaderChunks = stdx.BitSetType(stdx.div_ceil(slot_count, headers_per_message)); + + storage: *Storage, + replica: u8, + + /// A header is located at `slot == header.op % headers.len`. + /// + /// Each slot's `header.command` is either `prepare` or `reserved`. + /// When the slot's header is `reserved`, the header's `op` is the slot index. + /// + /// During recovery, store the (unvalidated) headers of the prepare ring. + headers: []align(constants.sector_size) Header.Prepare, + + /// Store headers whose prepares are on disk. + /// Redundant headers are updated after the corresponding prepare(s) are written, + /// whereas `headers` are updated beforehand. + /// + /// Consider this example: + /// 1. Ops 6 and 7 arrive. + /// 2. The write of prepare 7 finishes (before prepare 6). + /// 3. Op 7 continues on to write the redundant headers. + /// Because prepare 6 is not yet written, header 6 is written as reserved. + /// 4. If at this point the replica crashes & restarts, slot 6 is in case `@L` + /// (decision=nil) which can be locally repaired. + /// In contrast, if op 6's prepare header was written in step 3, it would be case `@K`, + /// which requires remote repair. + /// + /// During recovery, store the redundant (unvalidated) headers. + headers_redundant: []align(constants.sector_size) Header.Prepare, + + /// We copy-on-write to these buffers, as the in-memory headers may change while writing. + /// The buffers belong to the IOP at the corresponding index in IOPS. + write_headers_sectors: *align(constants.sector_size) [ + constants.journal_iops_write_max + ][constants.sector_size]u8, + + /// A set bit indicates a chunk of redundant headers for which a read has been issued. + header_chunks_requested: HeaderChunks = .{}, + /// A set bit indicates a chunk of redundant headers that has been recovered. + header_chunks_recovered: HeaderChunks = .{}, + + /// Statically allocated read IO operation context data. + reads: IOPSType(Read, constants.journal_iops_read_max) = .{}, + /// Count of reads currently acquired on the repair path. + reads_repair_count: u6 = 0, + /// Count of reads currently acquired on the commit path. + reads_commit_count: u6 = 0, + + /// Statically allocated write IO operation context data. + /// + /// Each acquired write in this list is either: + /// - executing (`write.range.locked`), or + /// - queued (`!write.range.locked`). + /// + /// Invariants: + /// - When there are multiple Writes to the same location, only one of them is executing at + /// any time -- the others are queued behind it. + /// - There is at most one Write to a given slot at any time. + writes: IOPSType(Write, constants.journal_iops_write_max) = .{}, + + /// Whether an entry is in memory only and needs to be written or is being written: + /// We use this in the same sense as a dirty bit in the kernel page cache. + /// A dirty bit means that we have not prepared the entry, or need to repair a faulty entry. + dirty: BitSet, + + /// Whether an entry was written to disk and this write was subsequently lost due to: + /// * corruption, + /// * a misdirected write (or a misdirected read, we do not distinguish), or else + /// * a latent sector error, where the sector can no longer be read. + /// A faulty bit means that we prepared and then lost the entry. + /// A faulty bit requires the dirty bit to also be set so that callers need not check both. + /// A faulty bit is used then only to qualify the severity of the dirty bit. + faulty: BitSet, + + /// The checksum of the prepare in the corresponding slot. + /// This is used to respond to `get_prepare` messages even when the slot is faulty. + /// For example, the slot may be faulty because the redundant header is faulty. + /// + /// The checksum will missing (`prepare_checksums[i]=0`, `prepare_inhabited[i]=false`) when: + /// * the message in the slot is reserved, + /// * the message in the slot is being written, or when + /// * the message in the slot is corrupt. + // TODO: `prepare_checksums` and `prepare_inhabited` should be combined into a []?u128, + // but that type is currently unusable (as of Zig 0.9.1). + // See: https://github.com/ziglang/zig/issues/9871 + prepare_checksums: []u128, + /// When prepare_inhabited[i]==false, prepare_checksums[i]==0. + /// (`undefined` would may more sense than `0`, but `0` allows it to be asserted). + prepare_inhabited: []bool, + + status: Status = .init, + + pub fn init(allocator: Allocator, storage: *Storage, replica: u8) !Journal { + // TODO Fix this assertion: + // assert(write_ahead_log_zone_size <= storage.size); + + const headers = try allocator.alignedAlloc( + Header.Prepare, + constants.sector_size, + slot_count, + ); + errdefer allocator.free(headers); + for (headers) |*header| header.* = undefined; + + const headers_redundant = try allocator.alignedAlloc( + Header.Prepare, + constants.sector_size, + slot_count, + ); + errdefer allocator.free(headers_redundant); + for (headers_redundant) |*header| header.* = undefined; + + var dirty = try BitSet.init_full(allocator, slot_count); + errdefer dirty.deinit(allocator); + + var faulty = try BitSet.init_full(allocator, slot_count); + errdefer faulty.deinit(allocator); + + const prepare_checksums = try allocator.alloc(u128, slot_count); + errdefer allocator.free(prepare_checksums); + @memset(prepare_checksums, 0); + + const prepare_inhabited = try allocator.alloc(bool, slot_count); + errdefer allocator.free(prepare_inhabited); + @memset(prepare_inhabited, false); + + const write_headers_sectors = (try allocator.alignedAlloc( + [constants.sector_size]u8, + constants.sector_size, + constants.journal_iops_write_max, + ))[0..constants.journal_iops_write_max]; + errdefer allocator.free(write_headers_sectors); + + log.info("{}: slot_count={} size={} headers_size={} prepares_size={}", .{ + replica, + slot_count, + std.fmt.fmtIntSizeBin(write_ahead_log_zone_size), + std.fmt.fmtIntSizeBin(headers_size), + std.fmt.fmtIntSizeBin(prepares_size), + }); + + var journal = Journal{ + .storage = storage, + .replica = replica, + .headers = headers, + .headers_redundant = headers_redundant, + .dirty = dirty, + .faulty = faulty, + .prepare_checksums = prepare_checksums, + .prepare_inhabited = prepare_inhabited, + .write_headers_sectors = write_headers_sectors, + }; + + assert(@mod(@intFromPtr(&journal.headers[0]), constants.sector_size) == 0); + assert(journal.dirty.bits.bit_length == slot_count); + assert(journal.faulty.bits.bit_length == slot_count); + assert(journal.dirty.count == slot_count); + assert(journal.faulty.count == slot_count); + assert(journal.prepare_checksums.len == slot_count); + assert(journal.prepare_inhabited.len == slot_count); + + for (journal.headers) |*h| assert(!h.valid_checksum()); + for (journal.headers_redundant) |*h| assert(!h.valid_checksum()); + + return journal; + } + + pub fn deinit(journal: *Journal, allocator: Allocator) void { + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + + journal.dirty.deinit(allocator); + journal.faulty.deinit(allocator); + allocator.free(journal.headers); + allocator.free(journal.headers_redundant); + allocator.free(journal.write_headers_sectors); + allocator.free(journal.prepare_checksums); + allocator.free(journal.prepare_inhabited); + + { + var it = journal.reads.iterate(); + while (it.next()) |read| replica.message_bus.unref(read.message); + } + { + var it = journal.writes.iterate(); + while (it.next()) |write| replica.message_bus.unref(write.message); + } + } + + pub fn slot_for_op(_: *const Journal, op: u64) Slot { + return Slot{ .index = op % slot_count }; + } + + pub fn slot_with_op(journal: *const Journal, op: u64) ?Slot { + if (journal.header_with_op(op)) |_| { + return journal.slot_for_op(op); + } else { + return null; + } + } + + pub fn slot_with_op_and_checksum(journal: *const Journal, op: u64, checksum: u128) ?Slot { + if (journal.header_with_op_and_checksum(op, checksum)) |_| { + return journal.slot_for_op(op); + } else { + return null; + } + } + + pub fn slot_for_header(journal: *const Journal, header: *const Header.Prepare) Slot { + assert(header.command == .prepare); + assert(header.operation != .reserved); + return journal.slot_for_op(header.op); + } + + pub fn slot_with_header( + journal: *const Journal, + header: *const Header.Prepare, + ) ?Slot { + assert(header.command == .prepare); + assert(header.operation != .reserved); + return journal.slot_with_op_and_checksum(header.op, header.checksum); + } + + /// Returns any existing header at the location indicated by header.op. + /// The existing header may have an older or newer op number. + pub fn header_for_prepare( + journal: *const Journal, + header: *const Header.Prepare, + ) ?*const Header.Prepare { + assert(header.command == .prepare); + assert(header.operation != .reserved); + return journal.header_for_op(header.op); + } + + /// We use `op` directly to index into the headers array and locate ops without a scan. + /// The existing header may have an older or newer op number. + pub fn header_for_op(journal: *const Journal, op: u64) ?*const Header.Prepare { + const slot = journal.slot_for_op(op); + const existing = &journal.headers[slot.index]; + assert(existing.command == .prepare); + + if (existing.operation == .reserved) { + assert(existing.op == slot.index); + return null; + } else { + assert(journal.slot_for_op(existing.op).index == slot.index); + return existing; + } + } + + /// Returns the entry at `@mod(op)` location, but only if `entry.op == op`, else `null`. + /// Be careful of using this without considering that there may still be an existing op. + pub fn header_with_op(journal: *const Journal, op: u64) ?*const Header.Prepare { + if (journal.header_for_op(op)) |existing| { + if (existing.op == op) return existing; + } + return null; + } + + /// As per `header_with_op()`, but only if there is a checksum match. + pub fn header_with_op_and_checksum( + journal: *const Journal, + op: u64, + checksum: u128, + ) ?*const Header.Prepare { + if (journal.header_with_op(op)) |existing| { + assert(existing.op == op); + if (existing.checksum == checksum) return existing; + } + return null; + } + + pub fn previous_entry( + journal: *const Journal, + header: *const Header.Prepare, + ) ?*const Header.Prepare { + if (header.op == 0) { + return null; + } else { + return journal.header_for_op(header.op - 1); + } + } + + pub fn next_entry( + journal: *const Journal, + header: *const Header.Prepare, + ) ?*const Header.Prepare { + return journal.header_for_op(header.op + 1); + } + + /// Returns the highest op number prepared, in any slot without reference to the checkpoint. + pub fn op_maximum(journal: *const Journal) u64 { + assert(journal.status == .recovered); + + var op: u64 = 0; + for (journal.headers) |*header| { + if (header.operation != .reserved) { + if (header.op > op) op = header.op; + } + } + return op; + } + + /// Returns the highest op number prepared, as per `header_ok()` in the untrusted headers. + fn op_maximum_headers_untrusted( + cluster: u128, + headers_untrusted: []const Header.Prepare, + ) u64 { + var op: u64 = 0; + for (headers_untrusted, 0..) |*header_untrusted, slot_index| { + const slot = Slot{ .index = slot_index }; + if (header_ok(cluster, slot, header_untrusted)) |header| { + if (header.operation != .reserved) { + if (header.op > op) op = header.op; + } + } + } + return op; + } + + pub fn has_header(journal: *const Journal, header: *const Header.Prepare) bool { + assert(journal.status == .recovered); + assert(header.command == .prepare); + assert(header.operation != .reserved); + + if (journal.header_with_op_and_checksum(header.op, header.checksum)) |_| { + return true; + } else { + return false; + } + } + + pub fn has_prepare(journal: *const Journal, header: *const Header.Prepare) bool { + if (journal.slot_with_op_and_checksum(header.op, header.checksum)) |slot| { + if (!journal.dirty.bit(slot)) { + assert(journal.prepare_inhabited[slot.index]); + assert(journal.prepare_checksums[slot.index] == header.checksum); + return true; + } + } + return false; + } + + pub fn has_dirty(journal: *const Journal, header: *const Header.Prepare) bool { + return journal.has_header(header) and journal.dirty.bit( + journal.slot_with_header(header).?, + ); + } + + /// Copies latest headers between `op_min` and `op_max` (both inclusive) as fit in `dest`. + /// Reverses the order when copying so that latest headers are copied first, which protects + /// against the callsite slicing the buffer the wrong way and incorrectly, and which is + /// required by message handlers that use the hash chain for repairs. + /// Skips .reserved headers (gaps between headers). + /// Zeroes the `dest` buffer in case the copy would underflow and leave a buffer bleed. + /// Returns the number of headers actually copied. + pub fn copy_latest_headers_between( + journal: *const Journal, + op_min: u64, + op_max: u64, + dest: []Header.Prepare, + ) usize { + assert(journal.status == .recovered); + assert(op_min <= op_max); + assert(dest.len > 0); + + var copied: usize = 0; + // Poison all slots; only slots less than `copied` are used. + @memset(dest, undefined); + + // Start at op_max + 1 and do the decrement upfront to avoid overflow when op_min == 0: + var op = op_max + 1; + while (op > op_min) { + op -= 1; + + if (journal.header_with_op(op)) |header| { + dest[copied] = header.*; + assert(dest[copied].invalid() == null); + copied += 1; + if (copied == dest.len) break; + } + } + + log.debug( + "{}: copy_latest_headers_between: op_min={} op_max={} dest.len={} copied={}", + .{ + journal.replica, + op_min, + op_max, + dest.len, + copied, + }, + ); + + return copied; + } + + const HeaderRange = struct { op_min: u64, op_max: u64 }; + + /// Finds the latest break in headers between `op_min` and `op_max` (both inclusive). + /// A break is a missing header or a header not connected to the next header by hash chain. + /// On finding the highest break, extends the range downwards to cover as much as possible. + /// + /// We expect that `op_max` (`replica.op`) must exist. + /// `op_min` may exist or not. + /// + /// A range will never include `op_max` because this must be up to date as the latest op. + /// A range may include `op_min`. + /// We must therefore first resolve any op uncertainty so that we can trust `op_max` here. + /// + /// For example: If ops 3, 9 and 10 are missing, returns: `{ .op_min = 9, .op_max = 10 }`. + /// + /// Another example: If op 17 is disconnected from op 18, 16 is connected to 17, and 12-15 + /// are missing, returns: `{ .op_min = 12, .op_max = 17 }`. + pub fn find_latest_headers_break_between( + journal: *const Journal, + op_min: u64, + op_max: u64, + ) ?HeaderRange { + assert(journal.status == .recovered); + assert(journal.header_with_op(op_max) != null); + assert(op_max >= op_min); + assert(op_max - op_min + 1 <= slot_count); + var range: ?HeaderRange = null; + + // We set B, the op after op_max, to null because we only examine breaks < op_max: + var B: ?*const Header.Prepare = null; + + var op = op_max + 1; + while (op > op_min) { + op -= 1; + + // Get the entry at @mod(op) location, but only if entry.op == op, else null: + const A = journal.header_with_op(op); + if (A) |a| { + if (B) |b| { + // If A was reordered then A may have a newer op than B (but an older view). + // However, here we use header_with_op() to assert a.op + 1 == b.op: + assert(a.op + 1 == b.op); + + // We do not assert a.view <= b.view here unless the chain is intact because + // repair_header() may put a newer view to the left of an older view. + + // A exists and B exists: + if (range) |*r| { + assert(b.op == r.op_min); + if (a.op == op_min) { + // A is committed, because we pass `commit_min` as `op_min`: + // Do not add A to range because A cannot be a break if committed. + break; + } else if (a.checksum == b.parent) { + // A is connected to B, but B is disconnected, add A to range: + assert(a.view <= b.view); + r.op_min = a.op; + } else if (a.view < b.view) { + // A is not connected to B, and A is older than B, add A to range: + r.op_min = a.op; + } else if (a.view > b.view) { + // A is not connected to B, but A is newer than B, close range: + break; + } else { + // Op numbers in the same view must be connected. + unreachable; + } + } else if (a.checksum == b.parent) { + // A is connected to B, and B is connected or B is op_max. + assert(a.view <= b.view); + } else if (a.view != b.view) { + // A is not connected to B, open range: + assert(b.op <= op_max); + range = .{ .op_min = a.op, .op_max = a.op }; + } else { + // Op numbers in the same view must be connected. + unreachable; + } + } else { + // A exists and B does not exist (or B has a older/newer op number): + if (range) |r| { + // We cannot compare A to B, A may be older/newer, close range: + assert(r.op_min == op + 1); + break; + } else { + // We expect a range if B does not exist, unless: + assert(a.op == op_max); + } + } + } else { + assert(op < op_max); + + // A does not exist, or A has an older (or newer if reordered) op number: + if (range) |*r| { + // Add A to range: + assert(r.op_min == op + 1); + r.op_min = op; + } else { + // Open range: + assert(B != null); + range = .{ .op_min = op, .op_max = op }; + } + } + + B = A; + } + + if (range) |r| { + assert(r.op_min >= op_min); + // We can never repair op_max (replica.op) since that is the latest op: + // We can assume this because any existing view jump barrier must first be resolved. + assert(r.op_max < op_max); + } + + return range; + } + + /// Read a prepare from disk. There must be a matching in-memory header. + pub fn read_prepare( + journal: *Journal, + callback: Read.Callback, + options: Read.Options, + ) void { + assert(journal.status == .recovered); + assert(options.checksum != 0); + assert(journal.reads.available() > 0); + + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + if (options.op > replica.op) { + journal.read_prepare_log(options.op, options.checksum, "beyond replica.op"); + callback(replica, null, options); + return; + } + + const slot = journal.slot_with_op_and_checksum(options.op, options.checksum) orelse { + journal.read_prepare_log(options.op, options.checksum, "no entry exactly"); + callback(replica, null, options); + return; + }; + + if (journal.prepare_inhabited[slot.index] and + journal.prepare_checksums[slot.index] == options.checksum) + { + journal.read_prepare_with_op_and_checksum(callback, options); + } else { + journal.read_prepare_log(options.op, options.checksum, "no matching prepare"); + callback(replica, null, options); + } + } + + /// Read a prepare from disk. There may or may not be an in-memory header. + pub fn read_prepare_with_op_and_checksum( + journal: *Journal, + callback: Read.Callback, + options: Read.Options, + ) void { + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + const slot = journal.slot_for_op(options.op); + + assert(journal.status == .recovered); + assert(journal.prepare_inhabited[slot.index]); + assert(journal.prepare_checksums[slot.index] == options.checksum); + + if (options.destination_replica == null) { + assert(journal.reads.available() > 0); + } + + const message = replica.message_bus.get_message(.prepare); + defer replica.message_bus.unref(message); + + var message_size: usize = constants.message_size_max; + + // If the header is in-memory, we can skip the read from the disk. + if (journal.header_with_op_and_checksum(options.op, options.checksum)) |exact| { + if (exact.size == @sizeOf(Header)) { + message.header.* = exact.*; + // Normally the message's padding would have been zeroed by the MessageBus, + // but we are copying (only) a message header into a new buffer. + @memset(message.buffer[@sizeOf(Header)..constants.sector_size], 0); + callback(replica, message, options); + return; + } else { + // As an optimization, we can read fewer than `message_size_max` bytes because + // we know the message's exact size. + message_size = vsr.sector_ceil(exact.size); + assert(message_size <= constants.message_size_max); + } + } + + if (options.destination_replica == null) { + journal.reads_commit_count += 1; + } else { + if (journal.reads_repair_count == reads_repair_count_max) { + journal.read_prepare_log(options.op, options.checksum, "waiting for IOP"); + callback(replica, null, options); + return; + } + journal.reads_repair_count += 1; + } + + assert(journal.reads_repair_count <= reads_repair_count_max); + assert(journal.reads_commit_count <= reads_commit_count_max); + + const read = journal.reads.acquire().?; + + read.* = .{ + .journal = journal, + .completion = undefined, + .message = message.ref(), + .options = options, + .callback = callback, + }; + + const buffer: []u8 = message.buffer[0..message_size]; + + // Memory must not be owned by `journal.headers` as these may be modified concurrently: + assert(stdx.disjoint_slices(u8, vsr.Header.Prepare, buffer, journal.headers)); + + journal.storage.read_sectors( + read_prepare_with_op_and_checksum_callback, + &read.completion, + buffer, + .wal_prepares, + Ring.prepares.offset(slot), + ); + } + + fn read_prepare_with_op_and_checksum_callback(completion: *Storage.Read) void { + const read: *Journal.Read = @alignCast(@fieldParentPtr("completion", completion)); + const journal = read.journal; + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + + const callback = read.callback; + const message = read.message; + const options = read.options; + + defer replica.message_bus.unref(message); + + assert(journal.status == .recovered); + + if (options.destination_replica == null) { + journal.reads_commit_count -= 1; + } else { + journal.reads_repair_count -= 1; + } + journal.reads.release(read); + + if (options.op > replica.op) { + journal.read_prepare_log(options.op, options.checksum, "beyond replica.op"); + callback(replica, null, options); + return; + } + + const slot = journal.slot_for_op(options.op); + const checksum_inhabited = journal.prepare_inhabited[slot.index]; + const checksum_match = journal.prepare_checksums[slot.index] == options.checksum; + if (!checksum_inhabited or !checksum_match) { + journal.read_prepare_log( + options.op, + options.checksum, + "prepare changed during read", + ); + callback(replica, null, options); + return; + } + + const error_reason: ?[]const u8 = reason: { + if (!message.header.valid_checksum()) { + break :reason "corrupt header after read"; + } + assert(message.header.invalid() == null); + + if (message.header.cluster != replica.cluster) { + // This could be caused by a misdirected read or write. + // Though when a prepare spans multiple sectors, a misdirected read/write will + // likely manifest as a checksum failure instead. + break :reason "wrong cluster"; + } + + if (message.header.op != options.op) { + // Possible causes: + // * The prepare was rewritten since the read began. + // * Misdirected read/write. + // * The combination of: + // * The primary is responding to a `get_prepare`. + // * The `get_prepare` did not include a checksum. + // * The requested op's slot is faulty, but the prepare is valid. Since the + // prepare is valid, WAL recovery set `prepare_checksums[slot]`. But on + // reading this entry it turns out not to have the right op. + // (This case (and the accompanying unnecessary read) could be prevented by + // storing the op along with the checksum in `prepare_checksums`.) + break :reason "op changed during read"; + } + + if (message.header.checksum != options.checksum) { + // This can also be caused by a misdirected read/write. + break :reason "checksum changed during read"; + } + + if (!message.header.valid_checksum_body(message.body_used())) { + break :reason "corrupt body after read"; + } + + const message_padding = + message.buffer[message.header.size..vsr.sector_ceil(message.header.size)]; + if (!stdx.zeroed(message_padding)) { + break :reason "corrupt sector padding"; + } + break :reason null; + }; + + if (error_reason) |reason| { + // Check that the `headers` slot belongs to the same op that it did when the read + // began. The slot may not match the Read's op/checksum due to either: + // * The in-memory header changed since the read began. + // * The in-memory header is reserved+faulty; the read was via `prepare_checksums` + if (journal.slot_with_op_and_checksum(options.op, options.checksum)) |s| { + journal.faulty.set(s); + journal.dirty.set(s); + } + + journal.read_prepare_log(options.op, options.checksum, reason); + callback(replica, null, options); + } else { + assert(message.header.checksum == options.checksum); + callback(replica, message, options); + } + } + + fn read_prepare_log(journal: *Journal, op: u64, checksum: ?u128, notice: []const u8) void { + log.info( + "{}: read_prepare: op={} checksum={x:0>32}: {s}", + .{ journal.replica, op, checksum orelse 0, notice }, + ); + } + + pub fn recover(journal: *Journal, callback: *const fn (journal: *Journal) void) void { + assert(journal.status == .init); + assert(journal.dirty.count == slot_count); + assert(journal.faulty.count == slot_count); + assert(journal.reads.executing() == 0); + assert(journal.writes.executing() == 0); + assert(journal.header_chunks_requested.empty()); + assert(journal.header_chunks_recovered.empty()); + + journal.status = .{ .recovering = callback }; + log.debug("{}: recover: recovering", .{journal.replica}); + + var available: usize = journal.reads.available(); + while (available > 0) : (available -= 1) journal.recover_headers(); + + assert(journal.header_chunks_recovered.empty()); + assert(journal.header_chunks_requested.count() == journal.reads.executing()); + } + + fn recover_headers(journal: *Journal) void { + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + assert(journal.status == .recovering); + assert(journal.reads.available() > 0); + assert( + journal.header_chunks_recovered.count() <= journal.header_chunks_requested.count(), + ); + + if (journal.header_chunks_recovered.full()) { + log.debug("{}: recover_headers: complete", .{journal.replica}); + journal.recover_prepares(); + return; + } + + const chunk_index = journal.header_chunks_requested.first_unset() orelse return; + assert(!journal.header_chunks_recovered.is_set(chunk_index)); + + const message = replica.message_bus.get_message(.prepare); + defer replica.message_bus.unref(message); + + const chunk_read = journal.reads.acquire().?; + chunk_read.* = .{ + .journal = journal, + .completion = undefined, + .message = message.ref(), + .options = .{ .op = chunk_index, .checksum = undefined }, + .callback = undefined, + }; + + const offset = constants.message_size_max * chunk_index; + assert(offset < headers_size); + + const buffer = recover_headers_buffer(message, offset); + assert(buffer.len > 0); + assert(buffer.len <= constants.message_size_max); + assert(buffer.len + offset <= headers_size); + + log.debug("{}: recover_headers: offset={} size={} recovering", .{ + journal.replica, + offset, + buffer.len, + }); + + journal.header_chunks_requested.set(chunk_index); + journal.storage.read_sectors( + recover_headers_callback, + &chunk_read.completion, + buffer, + .wal_headers, + offset, + ); + } + + fn recover_headers_callback(completion: *Storage.Read) void { + const chunk_read: *Journal.Read = @alignCast(@fieldParentPtr("completion", completion)); + const journal = chunk_read.journal; + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + assert(journal.status == .recovering); + assert(chunk_read.options.destination_replica == null); + + const chunk_index = chunk_read.options.op; + assert(journal.header_chunks_requested.is_set(chunk_index)); + assert(!journal.header_chunks_recovered.is_set(chunk_index)); + + const chunk_buffer = recover_headers_buffer( + chunk_read.message, + chunk_index * constants.message_size_max, + ); + assert(chunk_buffer.len >= @sizeOf(Header)); + assert(chunk_buffer.len % @sizeOf(Header) == 0); + + log.debug("{}: recover_headers: offset={} size={} recovered", .{ + journal.replica, + chunk_index * constants.message_size_max, + chunk_buffer.len, + }); + + // Directly store all the redundant headers in `journal.headers_redundant` (including + // any that are invalid or corrupt). As the prepares are recovered, these will be + // replaced or removed as necessary. + const chunk_headers = std.mem.bytesAsSlice(Header.Prepare, chunk_buffer); + stdx.copy_disjoint( + .exact, + Header.Prepare, + journal + .headers_redundant[chunk_index * headers_per_message ..][0..chunk_headers.len], + chunk_headers, + ); + + // We must release before we call `recover_headers()` in case Storage is synchronous. + // Otherwise, we would run out of messages and reads. + replica.message_bus.unref(chunk_read.message); + journal.reads.release(chunk_read); + + journal.header_chunks_recovered.set(chunk_index); + journal.recover_headers(); + } + + fn recover_headers_buffer( + message: *Message.Prepare, + offset: u64, + ) []align(@alignOf(Header)) u8 { + const max = @min(constants.message_size_max, headers_size - offset); + assert(max % constants.sector_size == 0); + assert(max % @sizeOf(Header) == 0); + return message.buffer[0..max]; + } + + /// Recover the prepares ring. Reads are issued concurrently. + /// - `dirty` is initially full. + /// Bits are cleared when a read is issued to the slot. + /// All bits are set again before recover_slots() is called. + /// - `faulty` is initially full. + /// Bits are cleared when the slot's read finishes. + /// All bits are set again before recover_slots() is called. + /// - The prepare's headers are loaded into `journal.headers`. + fn recover_prepares(journal: *Journal) void { + assert(journal.status == .recovering); + assert(journal.dirty.count == slot_count); + assert(journal.faulty.count == slot_count); + assert(journal.reads.executing() == 0); + assert(journal.writes.executing() == 0); + + var available: usize = journal.reads.available(); + while (available > 0) : (available -= 1) journal.recover_prepare(); + + assert(journal.writes.executing() == 0); + assert(journal.reads.executing() > 0); + assert(journal.reads.executing() + journal.dirty.count == slot_count); + assert(journal.faulty.count == slot_count); + } + + fn recover_prepare(journal: *Journal) void { + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + assert(journal.status == .recovering); + assert(journal.reads.available() > 0); + assert(journal.dirty.count <= journal.faulty.count); + + if (journal.faulty.count == 0) { + for (journal.headers, 0..) |_, index| journal.dirty.set(Slot{ .index = index }); + for (journal.headers, 0..) |_, index| journal.faulty.set(Slot{ .index = index }); + return journal.recover_slots(); + } + + const slot_index = journal.dirty.bits.findFirstSet() orelse return; + const slot = Slot{ .index = slot_index }; + const message = replica.message_bus.get_message(.prepare); + defer replica.message_bus.unref(message); + + const read = journal.reads.acquire().?; + read.* = .{ + .journal = journal, + .completion = undefined, + .message = message.ref(), + .options = .{ .op = slot.index, .checksum = undefined }, + .callback = undefined, + }; + + log.debug("{}: recover_prepare: recovering slot={}", .{ + journal.replica, + slot.index, + }); + + journal.dirty.clear(slot); + journal.storage.read_sectors( + recover_prepare_callback, + &read.completion, + // We load the entire message to verify that it isn't torn or corrupt. + // We don't know the message's size, so use the entire buffer. + message.buffer[0..constants.message_size_max], + .wal_prepares, + Ring.prepares.offset(slot), + ); + } + + fn recover_prepare_callback(completion: *Storage.Read) void { + const read: *Journal.Read = @alignCast(@fieldParentPtr("completion", completion)); + const journal = read.journal; + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + + assert(journal.status == .recovering); + assert(journal.dirty.count <= journal.faulty.count); + assert(read.options.destination_replica == null); + + const slot = Slot{ .index = @intCast(read.options.op) }; + assert(slot.index < slot_count); + assert(!journal.dirty.bit(slot)); + assert(journal.faulty.bit(slot)); + + // Check `valid_checksum_body` here rather than in `recover_done` so that we don't need + // to hold onto the whole message (just the header). + if (read.message.header.valid_checksum() and + read.message.header.valid_checksum_body(read.message.body_used())) + { + const message_size = read.message.header.size; + const message_padding = + read.message.buffer[message_size..vsr.sector_ceil(message_size)]; + + if (stdx.zeroed(message_padding)) { + journal.headers[slot.index] = read.message.header.*; + } + } + + replica.message_bus.unref(read.message); + journal.reads.release(read); + + journal.faulty.clear(slot); + journal.recover_prepare(); + } + + /// When in doubt about whether a particular message was received, it must be marked as + /// faulty to avoid nacking a prepare which was received then lost/misdirected/corrupted. + /// + /// + /// There are two special cases where faulty slots must be carefully handled: + /// + /// A) Redundant headers are written in batches. Slots that are marked faulty are written + /// as invalid (zeroed). This ensures that if the replica crashes and recovers, the + /// entries are still faulty rather than reserved. + /// The recovery process must be conservative about which headers are stored in + /// `journal.headers`. To understand why this is important, consider what happens if it did + /// load the faulty header into `journal.headers`, and then reads it back after a restart: + /// + /// 1. Suppose slot 8 is in case @D. Per the table below, mark slot 8 faulty. + /// 2. Suppose slot 9 is also loaded as faulty. + /// 3. Journal recovery finishes. The replica beings to repair its missing/broken messages. + /// 4. VSR recovery protocol fetches the true prepare for slot 9. + /// 5. The message from step 4 is written to slot 9 of the prepares. + /// 6. The header from step 4 is written to slot 9 of the redundant headers. + /// But writes to the redundant headers are done in batches of `headers_per_sector`! + /// So if step 1 loaded slot 8's prepare header into `journal.headers`, slot 8's + /// redundant header would be updated at the same time (in the same write) as slot 9. + /// 7! Immediately after step 6's write finishes, suppose the replica crashes (e.g. due to + /// power failure). + /// 8! Journal recovery again — but now slot 8 is loaded *without* being marked faulty. + /// So we may incorrectly nack slot 8's message. + /// + /// Therefore, recovery will never load a header into a slot *and* mark that slot faulty. + /// + /// + /// B) When replica_count=1, repairing broken/lost prepares over VSR is not an option, + /// so if a message is faulty the replica will abort. + /// + /// + /// Recovery decision table: + /// + /// label @A @B @C @D @E @F @G @H @I @J @K @L @M @N @O @P + /// header valid 0 1 1 0 0 0 1 _ 1 1 1 1 1 1 1 1 + /// header reserved _ 1 0 _ _ _ 1 _ 0 0 0 1 0 0 0 0 + /// prepare valid 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 + /// prepare reserved _ _ _ 1 0 0 0 0 0 1 1 1 0 0 0 0 + /// prepare.op is maximum _ _ _ _ 0 1 _ _ _ _ _ _ _ _ _ _ + /// prepare.op > prep_max !0 !0 !0 _ 0 0 0 1 0 _ _ _ 0 0 0 0 + /// header.op > prep_max !0 !0 !0 _ 0 0 0 1 1 1 0 _ 0 0 0 0 + /// match checksum _ _ _ _ _ _ _ _ _ _ _ !1 0 0 0 1 + /// match op _ _ _ _ _ _ _ _ !0 !0 _ !1 < > 1 !1 + /// match view _ _ _ _ _ _ _ _ _ _ _ !1 _ _ !0 !1 + /// decision (replicas>1) vsr vsr vsr vsr vsr fix fix cut cut cut vsr nil fix vsr vsr eql + /// decision (replicas=1) fix fix + /// + /// Legend: + /// + /// 0 false + /// 1 true + /// !0 assert false + /// !1 assert true + /// _ ignore + /// < header.op < prepare.op + /// > header.op > prepare.op + /// eql The header and prepare are identical; no repair necessary. + /// nil Reserved; dirty/faulty are clear, no repair necessary. + /// fix Repair header using local intact prepare. + /// vsr Repair with VSR `get_prepare`. + /// + /// A "valid" header/prepare: + /// 1. has a valid checksum + /// 2. has the correct cluster + /// 3. is in the correct slot (op % slot_count) + /// 4. has command=prepare + /// 5. may or may not have operation=reserved + fn recover_slots(journal: *Journal) void { + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + const log_view = replica.superblock.working.vsr_state.log_view; + const view_headers = replica.superblock.working.view_headers(); + + assert(journal.status == .recovering); + assert(journal.reads.executing() == 0); + assert(journal.writes.executing() == 0); + assert(journal.dirty.count == slot_count); + assert(journal.faulty.count == slot_count); + + var cases: [slot_count]*const Case = undefined; + + for (journal.headers, 0..) |_, index| { + const slot = Slot{ .index = index }; + const header = header_ok(replica.cluster, slot, &journal.headers_redundant[index]); + const prepare = header_ok(replica.cluster, slot, &journal.headers[index]); + + cases[index] = recovery_case(header, prepare, .{ + .op_prepare_max = replica.op_prepare_max(), + .op_max = @max( + op_maximum_headers_untrusted(replica.cluster, journal.headers_redundant), + op_maximum_headers_untrusted(replica.cluster, journal.headers), + ), + .op_checkpoint = replica.op_checkpoint(), + }); + + // `prepare_checksums` improves the availability of `get_prepare` by being more + // flexible than `headers` regarding the prepares it references. It may hold a + // prepare whose redundant header is broken, as long as the prepare itself is valid. + if (prepare != null and prepare.?.operation != .reserved) { + assert(!journal.prepare_inhabited[index]); + journal.prepare_inhabited[index] = true; + journal.prepare_checksums[index] = prepare.?.checksum; + } + } + assert(journal.headers.len == cases.len); + + const torn_prepares_ = journal.torn_prepares(&cases); + // Refine cases @B and @C: Repair (truncate) a prepare if it was torn during a crash. + for (torn_prepares_.const_slice()) |torn_prepare| { + assert(cases[torn_prepare.index].decision(replica.solo()) == .vsr); + cases[torn_prepare.index] = &case_cut_torn; + log.warn("{}: recover_slots: torn prepare in slot={}", .{ + journal.replica, + torn_prepare.index, + }); + } + + for (cases, 0..) |case, index| journal.recover_slot(Slot{ .index = index }, case); + assert(cases.len == slot_count); + + stdx.copy_disjoint( + .exact, + Header.Prepare, + journal.headers_redundant, + journal.headers, + ); + + // Discard headers which we are certain do not belong in the current log_view. + // - This ensures that we don't accidentally set our new head op to be a message + // which was truncated but not yet overwritten. + // - This is also necessary to ensure that generated JV's headers are complete. + // + // It is essential that this is performed: + // - after prepare_op_max is computed, + // - after the case decisions are made (to avoid @K:vsr arising from an + // artificially reserved prepare), + // - after torn_prepares(), which computes its own max ops. + // - before we repair the 'fix' cases. + // + // (These headers can originate if we join a view, write some prepares from the new + // view, and then crash before the view_durable_update() finished.) + for (journal.headers, 0..) |*header_untrusted, index| { + const slot = Slot{ .index = index }; + if (header_ok(replica.cluster, slot, header_untrusted)) |header| { + const view_range = view_headers.view_for_op(header.op, log_view); + assert(view_range.max <= log_view); + + if (header.operation != .reserved and !view_range.contains(header.view)) { + log.warn("{}: recover_slots: drop header " ++ + "view_range={}..{} view={} op={} checksum={x:0>32}", .{ + journal.replica, + view_range.min, + view_range.max, + header.view, + header.op, + header.checksum, + }); + journal.remove_entry(slot); + } + } + } + + log.debug("{}: recover_slots: dirty={} faulty={}", .{ + journal.replica, + journal.dirty.count, + journal.faulty.count, + }); + + journal.recover_fix(); + } + + /// Returns the slots that are safe to truncate. + /// + /// The goal of this function is to identify all prepares that were torn while being + /// appended to the log before a crash. These torn prepares must be truncated to ensure + /// that the replica doesn't start up in recovering_head. + /// + /// Conditions for torn prepares to be truncated: + /// * op_max, computed as the max of the prepare headers and redundant headers must be + /// certain. + /// * for certainty of op_max, there must be no faults between (op_max, op_prepare_max] + /// other than "torn prepares", which manifest as: + /// - the redundant header is valid, + /// - the redundant header's op is at least a log cycle behind, + /// - the prepare is corrupt + /// * faults may exist outside of (op_max, op_prepare_max]. They have no bearing on the + /// certainty of op_max as they lie between (op_checkpoint, op_max]. + fn torn_prepares( + journal: *const Journal, + cases: []const *const Case, + ) stdx.BoundedArrayType(Slot, constants.journal_iops_write_max) { + const replica: *const Replica = @alignCast(@fieldParentPtr("journal", journal)); + + assert(journal.status == .recovering); + assert(journal.dirty.count == slot_count); + assert(journal.faulty.count == slot_count); + + const op_max = @max( + op_maximum_headers_untrusted(replica.cluster, journal.headers_redundant), + op_maximum_headers_untrusted(replica.cluster, journal.headers), + ); + + const op_checkpoint = replica.op_checkpoint(); + const op_prepare_max = replica.op_prepare_max(); + + // Nothing to truncate - head op is not certain as it must be >= op_checkpoint. + if (op_max < op_checkpoint) return .{}; + + // Nothing to truncate - prepares beyond prepare_max are truncated via the cut decision. + if (op_max >= op_prepare_max) return .{}; + + const op_prepare_max_slot = journal.slot_for_op(op_prepare_max); + const op_checkpoint_slot = journal.slot_for_op(op_checkpoint); + + assert(op_max < op_prepare_max); + + // Range is constructed such that the op for all *valid* prepares or headers in it + // should be less than op_max. If a prepare/header within this range is corrupted, that + // makes our op_max uncertain. + const op_max_to_op_prepare_max = SlotRange{ + .head = journal.slot_for_op(op_max + 1), + .tail = prepare_max: { + if (op_checkpoint > 0 and op_max == op_checkpoint) { + assert(op_prepare_max_slot.index == op_checkpoint_slot.index); + assert(op_prepare_max > 0); + + break :prepare_max journal.slot_for_op(op_prepare_max - 1); + } else { + break :prepare_max op_prepare_max_slot; + } + }, + }; + + // We only consider journal_iops_write_max torn slots, as that is the maximum number of + // prepare writes that could be concurrently underway. If we find more (due to + // corruptions), we err on the side of caution and don't truncate any prepares. + var torn_slots: stdx.BoundedArrayType(Slot, constants.journal_iops_write_max) = .{}; + + // We now search for torn prepares between op_max and op_prepare_max. A torn prepare + // manifests as a prepare with an *invalid checksum* and a *valid* header from any + // previous wrap. If our op_max is certain, i.e. we are guaranteed to not find any + // op > op_max in our journal, then we can say with certainty that a torn prepare was + // being appended to the WAL. However, if we find a "non torn-prepare" fault outside of + // [op_max + 1, op_prepare_max], we return an empty slice. + // + // (fault [op_max+1..........op_prepare_max] fault) + // (...op_prepare_max] fault fault [op_max+1......) + // + // When there exists a "non torn-prepare" fault outside of [op_max + 1, op_prepare_max], + // op_max is not certain, as the faulty slot could be the true op_max. Consequently, we + // can't say if a torn prepare was truly torn (safe to truncate) or corrupted (not safe + // to truncate). + for (cases, 0..) |case, index| { + // Do not use `faulty.bit()` because the decisions have not been processed yet. + if (case.decision(replica.solo()) == .vsr) { + const slot = Slot{ .index = index }; + + // Checked separately as SlotRange.contains doesn't handle empty ranges. + const range_empty = op_max_to_op_prepare_max.head.index == + op_max_to_op_prepare_max.tail.index; + + if ((range_empty and index == op_prepare_max_slot.index) or + (!range_empty and op_max_to_op_prepare_max.contains(slot))) + { + const header_prepare_untrusted = &journal.headers[index]; + const header_redundant_ok = header_ok( + replica.cluster, + slot, + &journal.headers_redundant[index], + ); + + // We need our head op to be certain to reliably truncate torn prepares. + // Head op is uncertain if we encounter one of the below faults: + + // 1. Corrupt redundant header or a misdirected read to a redundant header. + if (header_redundant_ok == null) return .{}; + + // 2. Redundant header is set to .reserved. Could happen if: + // i. Slot was found corrupt on a previous startup, which set the header + // to reserved in memory. + // ii. Replica crashes *before* the corrupt slot was repaired, but + // *after* the reserved header was written to disk with a write to + // a nearby header (there are multiple headers in a single sector) + if (header_redundant_ok.?.operation == .reserved) return .{}; + + // 3. Prepare must be invalid for the slot to be eligible for truncation. A + // valid prepare could be faulty due to a misdirected read/write. + if (header_prepare_untrusted.valid_checksum()) return .{}; + + // Header is valid and from a previous wrap. + assert(header_redundant_ok != null); + assert(header_redundant_ok.?.op < op_max); + assert(header_redundant_ok.?.op <= op_checkpoint); + + assert(!header_prepare_untrusted.valid_checksum()); + assert(!journal.prepare_inhabited[index]); + + if (torn_slots.count() < constants.journal_iops_write_max) { + torn_slots.push(slot); + } else { + log.warn("{}: torn_prepares: not truncating, found >{} " ++ + "torn prepares!", .{ + journal.replica, + constants.journal_iops_write_max, + }); + return .{}; + } + } + } + } + return torn_slots; + } + + fn recover_slot(journal: *Journal, slot: Slot, case: *const Case) void { + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + const cluster = replica.cluster; + + assert(journal.status == .recovering); + assert(journal.dirty.bit(slot)); + assert(journal.faulty.bit(slot)); + + const header = header_ok(cluster, slot, &journal.headers_redundant[slot.index]); + const prepare = header_ok(cluster, slot, &journal.headers[slot.index]); + const decision = case.decision(replica.solo()); + switch (decision) { + .eql => { + assert(header.?.command == .prepare); + assert(prepare.?.command == .prepare); + assert(header.?.operation != .reserved); + assert(prepare.?.operation != .reserved); + assert(header.?.checksum == prepare.?.checksum); + assert(journal.prepare_inhabited[slot.index]); + assert(journal.prepare_checksums[slot.index] == prepare.?.checksum); + journal.headers[slot.index] = header.?; + journal.dirty.clear(slot); + journal.faulty.clear(slot); + }, + .nil => { + assert(header.?.command == .prepare); + assert(prepare.?.command == .prepare); + assert(header.?.operation == .reserved); + assert(prepare.?.operation == .reserved); + assert(header.?.checksum == prepare.?.checksum); + assert( + header.?.checksum == Header.Prepare.reserve(cluster, slot.index).checksum, + ); + assert(!journal.prepare_inhabited[slot.index]); + assert(journal.prepare_checksums[slot.index] == 0); + journal.headers[slot.index] = header.?; + journal.dirty.clear(slot); + journal.faulty.clear(slot); + }, + .fix => { + assert(prepare.?.command == .prepare); + journal.headers[slot.index] = prepare.?; + journal.faulty.clear(slot); + assert(journal.dirty.bit(slot)); + if (replica.solo()) { + // @D, @E, @F, @G, @M + } else { + assert(prepare.?.operation != .reserved); + assert(journal.prepare_inhabited[slot.index]); + assert(journal.prepare_checksums[slot.index] == prepare.?.checksum); + // @F, @G, @M + } + }, + .vsr => { + journal.headers[slot.index] = Header.Prepare.reserve(cluster, slot.index); + assert(journal.dirty.bit(slot)); + assert(journal.faulty.bit(slot)); + }, + .cut_torn => { + assert(header != null); + assert(prepare == null); + assert(!journal.prepare_inhabited[slot.index]); + assert(journal.prepare_checksums[slot.index] == 0); + journal.headers[slot.index] = Header.Prepare.reserve(cluster, slot.index); + journal.dirty.clear(slot); + journal.faulty.clear(slot); + }, + .cut => { + assert(prepare != null); + + if (prepare.?.op <= replica.op_prepare_max()) { + assert(header != null); + assert(header.?.operation != .reserved); + assert(header.?.op > replica.op_prepare_max()); + } else { + assert(prepare.?.operation != .reserved); + assert(journal.prepare_inhabited[slot.index]); + assert(journal.prepare_checksums[slot.index] == prepare.?.checksum); + } + + journal.headers[slot.index] = Header.Prepare.reserve(cluster, slot.index); + journal.dirty.clear(slot); + journal.faulty.clear(slot); + }, + .unr => unreachable, + } + + journal.headers_redundant[slot.index] = journal.headers[slot.index]; + if (journal.faulty.bit(slot)) { + journal.headers_redundant[slot.index].checksum = 0; // Invalidate the checksum. + } + assert(journal.faulty.bit(slot) != + journal.headers_redundant[slot.index].valid_checksum()); + + switch (decision) { + .eql, .nil => { + log.debug("{}: recover_slot: recovered " ++ + "slot={:0>4} label={s} decision={s} operation={} op={} view={}", .{ + journal.replica, + slot.index, + case.label, + @tagName(decision), + journal.headers[slot.index].operation, + journal.headers[slot.index].op, + journal.headers[slot.index].view, + }); + }, + .fix, .vsr, .cut, .cut_torn => { + log.warn("{}: recover_slot: recovered " ++ + "slot={:0>4} label={s} decision={s} operation={} op={} view={}", .{ + journal.replica, + slot.index, + case.label, + @tagName(decision), + journal.headers[slot.index].operation, + journal.headers[slot.index].op, + journal.headers[slot.index].view, + }); + }, + .unr => unreachable, + } + } + + /// Repair the redundant headers for slots with decision=fix, one sector at a time. + fn recover_fix(journal: *Journal) void { + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + assert(journal.status == .recovering); + assert(journal.writes.executing() == 0); + assert(journal.dirty.count >= journal.faulty.count); + assert(journal.dirty.count <= slot_count); + + var fix_sector: ?usize = null; + var dirty_iterator = journal.dirty.bits.iterator(.{ .kind = .set }); + while (dirty_iterator.next()) |dirty_slot| { + if (journal.faulty.bit(Slot{ .index = dirty_slot })) continue; + if (journal.prepare_inhabited[dirty_slot]) { + assert(journal.prepare_checksums[dirty_slot] == + journal.headers[dirty_slot].checksum); + assert(journal.prepare_checksums[dirty_slot] == + journal.headers_redundant[dirty_slot].checksum); + } else { + // Case @D for R=1. + assert(replica.solo()); + } + + const dirty_slot_sector = @divFloor(dirty_slot, headers_per_sector); + if (fix_sector) |fix_sector_| { + if (fix_sector_ != dirty_slot_sector) break; + } else { + fix_sector = dirty_slot_sector; + } + journal.dirty.clear(Slot{ .index = dirty_slot }); + } + + if (fix_sector == null) return journal.recover_done(); + + const write = journal.writes.acquire().?; + write.* = .{ + .journal = journal, + .callback = undefined, + .message = undefined, + .range = undefined, + }; + + const buffer: []u8 = journal.header_sector(fix_sector.?, write); + const buffer_headers = std.mem.bytesAsSlice(Header, buffer); + assert(buffer_headers.len == headers_per_sector); + + const offset = Ring.headers.offset(Slot{ .index = fix_sector.? * headers_per_sector }); + journal.write_sectors(recover_fix_callback, write, buffer, .headers, offset); + } + + fn recover_fix_callback(write: *Journal.Write) void { + const journal = write.journal; + assert(journal.status == .recovering); + + journal.writes.release(write); + journal.recover_fix(); + } + + fn recover_done(journal: *Journal) void { + assert(journal.status == .recovering); + assert(journal.reads.executing() == 0); + assert(journal.writes.executing() == 0); + assert(journal.dirty.count <= slot_count); + assert(journal.faulty.count <= slot_count); + assert(journal.faulty.count == journal.dirty.count); + assert(journal.header_chunks_requested.full()); + assert(journal.header_chunks_recovered.full()); + + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + const callback = journal.status.recovering; + journal.status = .recovered; + + if (journal.headers[0].op == 0 and journal.headers[0].operation != .reserved) { + assert( + journal.headers[0].checksum == Header.Prepare.root(replica.cluster).checksum, + ); + assert(!journal.faulty.bit(Slot{ .index = 0 })); + } + + for (journal.headers, 0..) |*header, index| { + assert(header.valid_checksum()); + assert(header.cluster == replica.cluster); + assert(header.command == .prepare); + assert(std.meta.eql(header.*, journal.headers_redundant[index])); + if (header.operation == .reserved) { + assert(header.op == index); + } else { + assert(header.op % slot_count == index); + assert(journal.prepare_inhabited[index]); + assert(journal.prepare_checksums[index] == header.checksum); + maybe(journal.faulty.bit(Slot{ .index = index })); + } + } + callback(journal); + } + + /// Removes entries from `op_min` (inclusive) onwards. + /// Used after a view change to remove uncommitted entries discarded by the new primary. + pub fn remove_entries_from(journal: *Journal, op_min: u64) void { + assert(journal.status == .recovered); + assert(op_min > 0); + + log.debug("{}: remove_entries_from: op_min={}", .{ journal.replica, op_min }); + + for (journal.headers, 0..) |*header, index| { + // We must remove the header regardless of whether it is a prepare or reserved, + // since a reserved header may have been marked faulty for case @K, and + // since the caller expects the WAL to be truncated, with clean slots. + if (header.op >= op_min) { + // TODO Explore scenarios where the data on disk may resurface after a crash. + const slot = journal.slot_for_op(header.op); + assert(slot.index == index); + journal.remove_entry(slot); + } + } + } + + pub fn remove_entry(journal: *Journal, slot: Slot) void { + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + + const reserved = Header.Prepare.reserve(replica.cluster, slot.index); + journal.headers[slot.index] = reserved; + journal.headers_redundant[slot.index] = reserved; + journal.dirty.clear(slot); + journal.faulty.clear(slot); + // Do not clear `prepare_inhabited`/`prepare_checksums`. The prepare is + // untouched on disk, and may be useful later. Consider this scenario: + // + // 1. Op 4 is received; start writing it. + // 2. Op 4's prepare is written (setting `prepare_checksums`), start writing + // the headers. + // 3. View change. Op 4 is discarded by `remove_entries_from`. + // 4. View change. Op 4 (the same one from before) is back, marked as dirty. But + // we don't start a write, because `journal.writing()` says it is already in + // progress. + // 5. Op 4's header write finishes (`write_prepare_on_write_header`). + // + // If `remove_entries_from` cleared `prepare_checksums`, + // `write_prepare_on_write_header` would clear `dirty`/`faulty` for a slot with + // `prepare_inhabited=false`. + } + + pub fn set_header_as_dirty(journal: *Journal, header: *const Header.Prepare) void { + assert(journal.status == .recovered); + assert(header.command == .prepare); + assert(header.operation != .reserved); + + log.debug("{}: set_header_as_dirty: op={} checksum={x:0>32}", .{ + journal.replica, + header.op, + header.checksum, + }); + + const slot = journal.slot_for_header(header); + + if (journal.has_header(header)) { + assert(journal.dirty.bit(slot)); + maybe(journal.faulty.bit(slot)); + // Do not clear any faulty bit for the same entry. + } else { + // Overwriting a new op with an old op would be a correctness bug; it could cause a + // message to be uncommitted. + assert(journal.headers[slot.index].op <= header.op); + + if (journal.headers[slot.index].operation == .reserved) { + // The WAL might have written/prepared this exact header before crashing — + // leave the entry marked faulty because we cannot safely nack it. + maybe(journal.faulty.bit(slot)); + } else { + // The WAL definitely did not hold this exact header, so it is safe to reset the + // faulty bit + nack this header. + journal.faulty.clear(slot); + journal.headers_redundant[slot.index] = + Header.Prepare.reserve(header.cluster, slot.index); + } + + journal.headers[slot.index] = header.*; + journal.dirty.set(slot); + } + } + + /// `write_prepare` uses `write_sectors` to prevent concurrent disk writes. + pub fn write_prepare( + journal: *Journal, + callback: *const fn (journal: *Replica, wrote: ?*Message.Prepare) void, + message: *Message.Prepare, + ) void { + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + + assert(journal.status == .recovered); + assert(message.header.command == .prepare); + assert(message.header.operation != .reserved); + assert(message.header.size >= @sizeOf(Header)); + assert(message.header.size <= message.buffer.len); + assert(journal.has_header(message.header)); + assert(journal.writing(message.header) == .none); + if (replica.solo()) assert(journal.writes.executing() == 0); + + // The underlying header memory must be owned by the buffer and not by journal.headers: + // Otherwise, concurrent writes may modify the memory of the pointer while we write. + assert(@intFromPtr(message.header) == @intFromPtr(message.buffer)); + + const slot = journal.slot_with_header(message.header).?; + + if (!journal.dirty.bit(slot)) { + // Any function that sets the faulty bit should also set the dirty bit: + assert(!journal.faulty.bit(slot)); + assert(journal.prepare_inhabited[slot.index]); + assert(journal.prepare_checksums[slot.index] == message.header.checksum); + assert(journal.headers_redundant[slot.index].checksum == message.header.checksum); + journal.write_prepare_debug(message.header, "skipping (clean)"); + callback(replica, message); + return; + } + + assert(journal.has_dirty(message.header)); + + const write = journal.writes.acquire() orelse { + assert(!replica.solo()); + + journal.write_prepare_warn(message.header, "waiting for IOP"); + callback(replica, null); + return; + }; + + journal.write_prepare_debug(message.header, "starting"); + + write.* = .{ + .journal = journal, + .callback = callback, + .message = message.ref(), + .range = undefined, + }; + + // Slice the message to the nearest sector, we don't want to write the whole buffer: + const buffer = message.buffer[0..vsr.sector_ceil(message.header.size)]; + const offset = Ring.prepares.offset(slot); + + // Assert that any sector padding has already been zeroed: + assert(stdx.zeroed(buffer[message.header.size..])); + + journal.prepare_inhabited[slot.index] = false; + journal.prepare_checksums[slot.index] = 0; + + journal.write_sectors(write_prepare_header, write, buffer, .prepares, offset); + } + + /// Attempt to lock the in-memory sector containing the header being written. + /// If the sector is already locked, add this write to the wait queue. + fn write_prepare_header(write: *Journal.Write) void { + const journal = write.journal; + const message = write.message; + assert(journal.status == .recovered); + assert(journal.writing(message.header) == .exact); + + // `prepare_inhabited[slot.index]` is usually false here, but may be true if two + // (or more) writes to the same slot were queued concurrently and this is not the + // first to finish writing its prepare. + const slot = journal.slot_for_header(message.header); + journal.prepare_inhabited[slot.index] = true; + journal.prepare_checksums[slot.index] = message.header.checksum; + + if (!journal.has_header(message.header)) { + journal.write_prepare_debug(message.header, "entry changed while writing sectors"); + journal.write_prepare_release(write, null); + // We just overwrote a (potentially-clean) prepare with the "wrong" header. + journal.dirty.set(slot); + return; + } + + if (journal.headers_redundant[slot.index].operation == .reserved and + journal.headers_redundant[slot.index].checksum == 0) + { + assert(journal.faulty.bit(slot)); + } + journal.headers_redundant[slot.index] = message.header.*; + + // TODO It's possible within this section that the header has since been replaced but we + // continue writing, even when the dirty bit is no longer set. This is not a problem + // but it would be good to stop writing as soon as we see we no longer need to. + // For this, we'll need to have a way to tweak write_prepare_release() to release locks. + // At present, we don't return early here simply because it doesn't yet do that. + + const offset = Ring.headers.offset(slot); + assert(offset % constants.sector_size == 0); + + const buffer: []u8 = journal.header_sector( + @divFloor(slot.index, headers_per_sector), + write, + ); + + log.debug("{}: write_header: op={} sectors[{}..{}]", .{ + journal.replica, + message.header.op, + offset, + offset + constants.sector_size, + }); + // Memory must not be owned by journal.headers as these may be modified concurrently: + assert(@intFromPtr(buffer.ptr) < @intFromPtr(journal.headers.ptr) or + @intFromPtr(buffer.ptr) > @intFromPtr(journal.headers.ptr) + headers_size); + + journal.write_sectors(write_prepare_on_write_header, write, buffer, .headers, offset); + } + + fn write_prepare_on_write_header(write: *Journal.Write) void { + const journal = write.journal; + const message = write.message; + + if (!journal.has_header(message.header)) { + journal.write_prepare_debug(message.header, "entry changed while writing headers"); + journal.write_prepare_release(write, null); + return; + } + + const slot = journal.slot_with_header(message.header).?; + if (journal.headers_redundant[slot.index].checksum != message.header.checksum) { + assert(journal.dirty.bit(slot)); + // Scenario: + // 1. write_prepare(h₁) + // 2. write_prepare_header(h₁) + // 3. remove_entry(h₁) + // 4. set_header_as_dirty(h₁) + // 5. write_prepare_on_write_header(h₁) + // `prepare_checksums` is still correct, but `remove_entry()` cleared the + // `headers_redundant`. + journal.write_prepare_debug( + message.header, + "entry removed then added while writing headers", + ); + journal.write_prepare_release(write, null); + return; + } + + if (!journal.prepare_inhabited[slot.index] or + journal.prepare_checksums[slot.index] != message.header.checksum) + { + journal.write_prepare_debug( + message.header, + "entry changed twice while writing headers", + ); + journal.write_prepare_release(write, null); + return; + } + + journal.write_prepare_debug(message.header, "complete, marking clean"); + + journal.dirty.clear(slot); + journal.faulty.clear(slot); + + journal.write_prepare_release(write, message); + } + + fn write_prepare_release( + journal: *Journal, + write: *Journal.Write, + wrote: ?*Message.Prepare, + ) void { + const replica: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + const write_callback = write.callback; + const write_message = write.message; + + // Release the write prior to returning control to the caller. + // This allows us to enforce journal.writes.len≤1 when replica_count=1, because the + // callback may immediately start the next write. + journal.writes.release(write); + assert(journal.writing(write_message.header) == .none); + + write_callback(replica, wrote); + replica.message_bus.unref(write_message); + } + + fn write_prepare_debug( + journal: *const Journal, + header: *const Header.Prepare, + status: []const u8, + ) void { + journal.write_prepare_fn(header, status, log.debug); + } + + fn write_prepare_warn( + journal: *const Journal, + header: *const Header.Prepare, + status: []const u8, + ) void { + journal.write_prepare_fn(header, status, log.warn); + } + + fn write_prepare_fn( + journal: *const Journal, + header: *const Header.Prepare, + status: []const u8, + comptime log_fn: anytype, + ) void { + assert(journal.status == .recovered); + assert(header.command == .prepare); + assert(header.operation != .reserved); + + log_fn("{}: write: view={} slot={} op={} len={}: {x:0>32} {s}", .{ + journal.replica, + header.view, + journal.slot_for_header(header).index, + header.op, + header.size, + header.checksum, + status, + }); + } + + fn write_sectors( + journal: *Journal, + callback: *const fn (write: *Journal.Write) void, + write: *Journal.Write, + buffer: []const u8, + ring: Ring, + offset: u64, // Offset within the Ring. + ) void { + write.range = .{ + .callback = callback, + .completion = undefined, + .buffer = buffer, + .ring = ring, + .offset = offset, + .locked = false, + }; + journal.lock_sectors(write); + } + + /// Start the write on the current range or add it to the proper queue + /// if an overlapping range is currently being written. + fn lock_sectors(journal: *Journal, write: *Journal.Write) void { + assert(!write.range.locked); + assert(write.range.next == null); + + var it = journal.writes.iterate(); + while (it.next()) |other| { + if (other == write) continue; + assert(journal.slot_for_header(write.message.header).index != + journal.slot_for_header(other.message.header).index); + + if (!other.range.locked) continue; + + if (other.range.overlaps(&write.range)) { + assert(other.range.offset == write.range.offset); + assert(other.range.buffer.len == write.range.buffer.len); + assert(other.range.ring == write.range.ring); + assert(other.range.ring == .headers); + + var tail = &other.range; + while (tail.next) |next| tail = next; + tail.next = &write.range; + return; + } + } + + log.debug("{}: write_sectors: ring={} offset={} len={} locked", .{ + journal.replica, + write.range.ring, + write.range.offset, + write.range.buffer.len, + }); + + write.range.locked = true; + journal.storage.write_sectors( + write_sectors_on_write, + &write.range.completion, + write.range.buffer, + switch (write.range.ring) { + .headers => .wal_headers, + .prepares => .wal_prepares, + }, + write.range.offset, + ); + // We rely on the Storage.write_sectors() implementation being always synchronous, + // in which case writes never actually need to be queued, or always asynchronous, + // in which case write_sectors_on_write() doesn't have to handle lock_sectors() + // synchronously completing a write and making a nested write_sectors_on_write() call. + // + // We don't currently allow Storage implementations that are sometimes synchronous and + // sometimes asynchronous as we don't have a use case for such a Storage implementation + // and doing so would require a significant complexity increase. + switch (Storage.synchronicity) { + .always_synchronous => assert(!write.range.locked), + .always_asynchronous => assert(write.range.locked), + } + } + + fn write_sectors_on_write(completion: *Storage.Write) void { + const range: *Range = @fieldParentPtr("completion", completion); + const write: *Journal.Write = @fieldParentPtr("range", range); + const journal = write.journal; + + assert(write.range.locked); + write.range.locked = false; + + log.debug("{}: write_sectors: ring={} offset={} len={} unlocked", .{ + journal.replica, + write.range.ring, + write.range.offset, + write.range.buffer.len, + }); + + // Drain the list of ranges that were waiting on this range to complete. + var current = range.next; + range.next = null; + while (current) |waiting| { + assert(waiting.locked == false); + current = waiting.next; + waiting.next = null; + journal.lock_sectors(@as(*Journal.Write, @fieldParentPtr("range", waiting))); + } + + range.callback(write); + } + + /// Returns a sector of redundant headers, ready to be written to the specified sector. + /// `sector_index` is relative to the start of the redundant header zone. + fn header_sector( + journal: *const Journal, + sector_index: usize, + write: *const Journal.Write, + ) Sector { + assert(journal.status != .init); + assert(journal.writes.items.len == journal.write_headers_sectors.len); + assert(sector_index < @divFloor(slot_count, headers_per_sector)); + + const sector_slot = Slot{ .index = sector_index * headers_per_sector }; + assert(sector_slot.index < slot_count); + + const write_index = @divExact( + @intFromPtr(write) - @intFromPtr(&journal.writes.items), + @sizeOf(Journal.Write), + ); + + const sector: Sector = &journal.write_headers_sectors[write_index]; + const sector_headers = std.mem.bytesAsSlice(Header.Prepare, sector); + assert(sector_headers.len == headers_per_sector); + + // Write headers from `headers_redundant` instead of `headers` — we need to avoid + // writing (leaking) a redundant header before its corresponding prepare is on disk. + stdx.copy_disjoint( + .exact, + Header.Prepare, + sector_headers, + journal.headers_redundant[sector_slot.index..][0..headers_per_sector], + ); + + for (sector_headers, 0..) |sector_header, i| { + const slot = Slot{ .index = sector_slot.index + i }; + if (sector_header.operation == .reserved and + sector_header.checksum == 0) + { + // Deliberately write an invalid header until the corresponding prepare is + // repaired. (See read_prepare_with_op_and_checksum_callback()). + assert(journal.faulty.bit(slot)); + } else { + maybe(journal.faulty.bit(slot)); + } + } + + return sector; + } + + const Writing = enum { + none, + /// Either the prepare or the redundant header of a message with the same slot as the + /// given op is being written. It may be a different version of the same op, or a + /// different op which shares the prepare slot. + slot, + /// Either the prepare or the redundant header of a message with the exact op/checksum + /// is being written. + exact, + }; + + pub fn writing(journal: *Journal, header: *const Header.Prepare) Writing { + const slot = journal.slot_for_header(header); + var found: Writing = .none; + var writes = journal.writes.iterate(); + while (writes.next()) |write| { + const write_slot = journal.slot_for_op(write.message.header.op); + if (write_slot.index == slot.index) { + assert(found == .none); + + if (write.message.header.checksum == header.checksum) { + assert(write.message.header.op == header.op); + found = .exact; + } else { + maybe(write.message.header.op == header.op); + found = .slot; + } + } else { + assert(write.message.header.op != header.op); + } + } + return found; + } + }; +} + +/// @B and @C: +/// This prepare is corrupt. +/// We may have a valid redundant header, but need to recover the full message. +/// +/// Case @B may be caused by crashing while writing the prepare (torn write). +/// +/// @D: +/// This is possibly a torn write to the redundant headers, so when replica_count=1 we must +/// repair this locally. The probability that this results in an incorrect recovery is: +/// P(crash during first WAL wrap) +/// × P(redundant header is corrupt) +/// × P(lost write to prepare covered by the corrupt redundant header) +/// which is negligible, and does not impact replica_count>1. +/// +/// @E: +/// Valid prepare, corrupt header. One of: +/// +/// 1. The replica crashed while writing the redundant header (torn write). +/// 2. The read to the header is corrupt or misdirected. +/// 3. Multiple faults, for example: the redundant header read is corrupt, and the latest prepare +/// write is misdirected. +/// +/// +/// @F and @G: +/// The replica is recovering from a crash after writing the prepare, but before writing the +/// redundant header. +/// +/// +/// @G: +/// One of: +/// +/// * The prepare was written, but then truncated, so the redundant header was written as reserved. +/// * A misdirected read to a reserved header. +/// * The redundant header's write was lost or misdirected. +/// +/// There is a risk of data loss in the case of 2 lost writes. +/// +/// +/// @H, @I, and @J: +/// The prepare/header is valid and is past the prepare_max for the replica's checkpoint. We allow +/// replicas to write to a slot past prepare_max when the replica has already committed the prepare +/// in that slot. +/// +/// On startup, we must truncate all these prepares so we can replay all prepares in the checkpoint. +/// +/// +/// @K: +/// The redundant header is present & valid, but the corresponding prepare was a lost or misdirected +/// read or write. +/// +/// +/// @L: +/// This slot is legitimately reserved — this may be the first fill of the log. +/// +/// +/// @M and @N: +/// When the redundant header & prepare header are both valid but distinct ops, always pick the +/// higher op. +/// +/// For example, consider slot_count=10, the op to the left is 12, the op to the right is 14, and +/// the tiebreak is between an op=3 and op=13. Choosing op=13 over op=3 is safe because the op=3 +/// must be from a previous wrap — it is too far back (>pipeline) to have been replaced by a view +/// change. +/// +/// The length of the prepare pipeline is the upper bound on how many ops can be reordered during a +/// view change. +/// +/// @M: +/// When the higher op belongs to the prepare, repair locally. +/// The most likely cause for this case is that the log wrapped, but the redundant header write was +/// lost. +/// +/// @N: +/// When the higher op belongs to the header, mark faulty. +/// +/// +/// @O: +/// Either: +/// - The message was rewritten due to a view change. +/// - The prepare write was lost, but the previous prepare had the same op (but a different view). +/// +/// The prepare and header have different views, but regardless of which is greater (and in both of +/// the above cases), recovery can't distinguish which is actually *newer*. Thus, we can't `fix`, +/// despite having a valid prepare. +/// +/// For example, if the header.view=2 and prepare.view=4, any of these scenarios are possible: +/// - Before crashing, we wrote the view=4 prepare, and then lost/misdirected the write for the +/// view=4 header. The view=2 header is left behind from view=2 or view=3. +/// - Before crashing, we wrote the view=2 prepare, and then lost/misdirected the write for the +/// view=2 header. The view=4 header is left behind from view=3. +/// - Before crashing, we wrote the view=4 prepare, and then crashed before we could write the +/// view=4 header. The view=2 header is left behind from view=2 or view=3. +/// (This last case is the most likely.) +/// +/// +/// @P: +/// The redundant header matches the message's header. +/// This is the usual case: both the prepare and header are correct and equivalent. +const recovery_cases = table: { + const __ = Matcher.any; + const _0 = Matcher.is_false; + const _1 = Matcher.is_true; + // The replica will abort if any of these checks fail: + const a0 = Matcher.assert_is_false; + const a1 = Matcher.assert_is_true; + + break :table [_]Case{ + // Legend: + // + // R>1 replica_count > 1 or standby + // R=1 replica_count = 1 and !standby + // ok valid checksum ∧ valid cluster ∧ valid slot ∧ valid command + // nil operation == reserved + // ✓∑ header.checksum == prepare.checksum + // op⌈ prepare.op is maximum of all prepare.ops + // op>₁ prepare.op > op_prepare_max + // op>₂ header.op > op_prepare_max + // op= header.op == prepare.op + // op< header.op < prepare.op + // view header.view == prepare.view + // + // Label Decision Header Prepare Compare + // R>1 R=1 ok nil ok nil op⌈ op> op> ✓∑ op= op< view + Case.init("@A", .vsr, .vsr, .{ _0, __, _0, __, __, a0, a0, __, __, __, __ }), + Case.init("@B", .vsr, .vsr, .{ _1, _1, _0, __, __, a0, __, __, __, __, __ }), + Case.init("@C", .vsr, .vsr, .{ _1, _0, _0, __, __, a0, __, __, __, __, __ }), + Case.init("@D", .vsr, .fix, .{ _0, __, _1, _1, __, __, a0, __, __, __, __ }), + Case.init("@E", .vsr, .fix, .{ _0, __, _1, _0, _0, _0, a0, __, __, __, __ }), + Case.init("@F", .fix, .fix, .{ _0, __, _1, _0, _1, _0, a0, __, __, __, __ }), + Case.init("@G", .fix, .fix, .{ _1, _1, _1, _0, __, _0, __, __, __, __, __ }), + Case.init("@H", .cut, .unr, .{ __, __, _1, _0, __, _1, __, __, __, __, __ }), // prepare.op > op_prepare_max + Case.init("@I", .cut, .unr, .{ _1, _0, _1, _0, __, _0, _1, __, __, a0, __ }), // header.op > op_prepare_max, prepare !reserved + Case.init("@J", .cut, .unr, .{ _1, _0, _1, _1, __, __, _1, __, __, a0, __ }), // header.op > op_prepare_max, prepare reserved + Case.init("@K", .vsr, .vsr, .{ _1, _0, _1, _1, __, __, _0, __, __, __, __ }), + Case.init("@L", .nil, .nil, .{ _1, _1, _1, _1, __, __, __, a1, a1, a0, a1 }), // normal path: reserved + Case.init("@M", .fix, .fix, .{ _1, _0, _1, _0, __, _0, _0, _0, _0, _1, __ }), // header.op < prepare.op + Case.init("@N", .vsr, .vsr, .{ _1, _0, _1, _0, __, _0, _0, _0, _0, _0, __ }), // header.op > prepare.op + Case.init("@O", .vsr, .vsr, .{ _1, _0, _1, _0, __, _0, _0, _0, _1, a0, a0 }), // header.view != prepare.view + Case.init("@P", .eql, .eql, .{ _1, _0, _1, _0, __, _0, _0, _1, a1, a0, a1 }), // normal path: prepare + }; +}; + +const case_cut_torn = Case{ + .label = "@TruncateTorn", + .decision_multiple = .cut_torn, + .decision_single = .cut_torn, + .pattern = undefined, +}; + +const RecoveryDecision = enum { + /// The header and prepare are identical; no repair necessary. + eql, + /// Reserved; dirty/faulty are clear, no repair necessary. + nil, + /// Use intact prepare to repair redundant header. Dirty/faulty are clear. + fix, + /// If replica_count>1 or standby: Repair with VSR `get_prepare`. Mark dirty, mark faulty. + /// If replica_count=1 and !standby: Fail; cannot recover safely. + vsr, + /// The prepare is from the next checkpoint. Truncate, set to reserved, clear dirty/faulty. + cut, + /// Truncate the op, setting it to reserved. Dirty/faulty are clear. + cut_torn, + /// Unreachable combination of header and prepare states. + unr, +}; + +const Matcher = enum { any, is_false, is_true, assert_is_false, assert_is_true }; + +const Case = struct { + label: []const u8, + /// Decision when replica_count>1. + decision_multiple: RecoveryDecision, + /// Decision when replica_count=1. + decision_single: RecoveryDecision, + /// 0: header_ok(header) + /// 1: header.operation == reserved + /// 2: header_ok(prepare) ∧ valid_checksum_body + /// 3: prepare.operation == reserved + /// 4: prepare.op is maximum of all prepare.ops + /// 5: prepare.op > op_prepare_max + /// 6: header.op > op_prepare_max + /// 7: header.checksum == prepare.checksum + /// 8: header.op == prepare.op + /// 9: header.op < prepare.op + /// 10: header.view == prepare.view + pattern: [pattern_size]Matcher, + + const pattern_size = 11; + + fn init( + label: []const u8, + decision_multiple: RecoveryDecision, + decision_single: RecoveryDecision, + pattern: [pattern_size]Matcher, + ) Case { + return .{ + .label = label, + .decision_multiple = decision_multiple, + .decision_single = decision_single, + .pattern = pattern, + }; + } + + fn check(case: *const Case, parameters: [pattern_size]bool) !bool { + for (case.pattern, parameters) |pattern, parameter| { + switch (pattern) { + .any => {}, + .is_false => if (parameter) return false, + .is_true => if (!parameter) return false, + .assert_is_false => if (parameter) return error.ExpectFalse, + .assert_is_true => if (!parameter) return error.ExpectTrue, + } + } + return true; + } + + fn decision(case: *const Case, solo: bool) RecoveryDecision { + if (solo) { + return case.decision_single; + } else { + return case.decision_multiple; + } + } +}; + +fn recovery_case( + header: ?Header.Prepare, + prepare: ?Header.Prepare, + data: struct { + op_max: u64, + op_prepare_max: u64, + op_checkpoint: u64, + }, +) *const Case { + const h_ok = header != null; + const p_ok = prepare != null; + + if (h_ok) assert(header.?.invalid() == null); + if (p_ok) assert(prepare.?.invalid() == null); + + const parameters: [Case.pattern_size]bool = .{ + h_ok, + if (h_ok) header.?.operation == .reserved else false, + p_ok, + if (p_ok) prepare.?.operation == .reserved else false, + if (p_ok) prepare.?.op == data.op_max else false, + if (p_ok) prepare.?.op > data.op_prepare_max else false, + if (h_ok) header.?.op > data.op_prepare_max else false, + if (h_ok and p_ok) header.?.checksum == prepare.?.checksum else false, + if (h_ok and p_ok) header.?.op == prepare.?.op else false, + if (h_ok and p_ok) header.?.op < prepare.?.op else false, + if (h_ok and p_ok) header.?.view == prepare.?.view else false, + }; + + var result: ?*const Case = null; + for (&recovery_cases) |*case| { + const match = case.check(parameters) catch { + log.err("recovery_case: impossible state: case={s} parameters={any}", .{ + case.label, + parameters, + }); + unreachable; + }; + if (match) { + assert(result == null); + result = case; + } + } + // The recovery table is exhaustive. + // Every combination of parameters matches exactly one case. + return result.?; +} + +/// Returns the header, only if the header: +/// * has a valid checksum, and +/// * has command=prepare +/// * has the expected cluster, and +/// * has an expected command, and +/// * resides in the correct slot. +fn header_ok( + cluster: u128, + slot: Slot, + header: *const Header.Prepare, +) ?Header.Prepare { + // We must first validate the header checksum before accessing any fields. + // Otherwise, we may hit undefined data or an out-of-bounds enum and cause a runtime crash. + if (!header.valid_checksum()) return null; + if (header.command != .prepare) return null; + + // A header with the wrong cluster, or in the wrong slot, may indicate a misdirected read/write. + // All journalled headers should be reserved or else prepares. + // A misdirected read/write to or from another storage zone may return the wrong message. + const valid_cluster_command_and_slot = switch (header.operation) { + .reserved => header.cluster == cluster and slot.index == header.op, + else => header.cluster == cluster and slot.index == header.op % slot_count, + }; + + // Do not check the checksum here, because that would run only after the other field accesses. + return if (valid_cluster_command_and_slot) header.* else null; +} + +test "recovery_cases" { + // Verify that every pattern matches exactly one case. + // + // Every possible combination of parameters must either: + // * have a matching case + // * have a case that fails (which would result in a panic). + var i: usize = 0; + while (i < (1 << Case.pattern_size)) : (i += 1) { + var parameters: [Case.pattern_size]bool = undefined; + comptime var j: usize = 0; + inline while (j < parameters.len) : (j += 1) { + parameters[j] = i & (1 << j) != 0; + } + + var case_fail: bool = false; + var case_match: ?*const Case = null; + for (&recovery_cases) |*case| { + // Assertion patterns (a0/a1) act as wildcards for the purpose of matching. + // Thus, it is possible for multiple cases to "match" a pattern iff they all fail an + // assertion. (For example, simultaneous op= and op<). + if (case.check(parameters) catch { + assert(case_match == null); + + case_fail = true; + continue; + }) { + assert(!case_fail); + + try std.testing.expectEqual(case_match, null); + case_match = case; + } + } + assert(case_fail == (case_match == null)); + } +} + +pub const BitSet = struct { + bits: std.DynamicBitSetUnmanaged, + + /// The number of bits set (updated incrementally as bits are set or cleared): + count: u64 = 0, + + fn init_full(allocator: Allocator, count: usize) !BitSet { + const bits = try std.DynamicBitSetUnmanaged.initFull(allocator, count); + errdefer bits.deinit(allocator); + + return BitSet{ + .bits = bits, + .count = count, + }; + } + + fn deinit(bit_set: *BitSet, allocator: Allocator) void { + assert(bit_set.count == bit_set.bits.count()); + + bit_set.bits.deinit(allocator); + } + + /// Clear the bit for a slot (idempotent): + pub fn clear(bit_set: *BitSet, slot: Slot) void { + if (bit_set.bits.isSet(slot.index)) { + bit_set.bits.unset(slot.index); + bit_set.count -= 1; + } + } + + /// Whether the bit for a slot is set: + pub fn bit(bit_set: *const BitSet, slot: Slot) bool { + return bit_set.bits.isSet(slot.index); + } + + /// Set the bit for a slot (idempotent): + pub fn set(bit_set: *BitSet, slot: Slot) void { + if (!bit_set.bits.isSet(slot.index)) { + bit_set.bits.set(slot.index); + bit_set.count += 1; + assert(bit_set.count <= bit_set.bits.bit_length); + } + } +}; diff --git a/ocam/src/vsr/marzullo.zig b/ocam/src/vsr/marzullo.zig new file mode 100644 index 00000000..95e81254 --- /dev/null +++ b/ocam/src/vsr/marzullo.zig @@ -0,0 +1,308 @@ +const std = @import("std"); +const assert = std.debug.assert; + +/// Marzullo's algorithm, invented by Keith Marzullo for his Ph.D. dissertation in 1984, is an +/// agreement algorithm used to select sources for estimating accurate time from a number of noisy +/// time sources. NTP uses a modified form of this called the Intersection algorithm, which returns +/// a larger interval for further statistical sampling. However, here we want the smallest interval. +pub const Marzullo = struct { + /// The smallest interval consistent with the largest number of sources. + pub const Interval = struct { + /// The lower bound on the minimum clock offset. + lower_bound: i64, + + /// The upper bound on the maximum clock offset. + upper_bound: i64, + + /// The number of "truechimers" consistent with the largest number of sources. + sources_true: u8, + + /// The number of "falsetickers" falling outside this interval. + /// Where `sources_false` plus `sources_true` always equals the total number of sources. + sources_false: u8, + }; + + /// A tuple represents either the lower or upper end of a bound, and is fed as input to the + /// Marzullo algorithm to compute the smallest interval across all tuples. + /// For example, given a clock offset to a remote replica of 3s, a round trip time of 1s, and + /// a maximum tolerance between clocks of 100ms on either side, we might create two tuples, the + /// lower bound having an offset of 2.4s and the upper bound having an offset of 3.6s, + /// to represent the error introduced by the round trip time and by the clocks themselves. + pub const Tuple = struct { + /// An identifier, the index of the clock source in the list of clock sources: + source: u8, + offset: i64, + bound: enum { + lower, + upper, + }, + }; + + /// Returns the smallest interval consistent with the largest number of sources. + pub fn smallest_interval(tuples: []Tuple) Interval { + // There are two bounds (lower and upper) per source clock offset sample. + const sources: u8 = @intCast(@divExact(tuples.len, 2)); + + if (sources == 0) { + return Interval{ + .lower_bound = 0, + .upper_bound = 0, + .sources_true = 0, + .sources_false = 0, + }; + } + + // Use a simpler sort implementation than the complexity of `std.mem.sort()` for safety: + std.sort.insertion(Tuple, tuples, {}, less_than); + + // Here is a description of the algorithm: + // https://en.wikipedia.org/wiki/Marzullo%27s_algorithm#Method + var best: i64 = 0; + var count: i64 = 0; + var previous: ?Tuple = null; + var interval: Interval = undefined; + + for (tuples, 0..) |tuple, i| { + // Verify that our sort implementation is correct: + if (previous) |p| { + assert(p.offset <= tuple.offset); + if (p.offset == tuple.offset) { + if (p.bound != tuple.bound) { + assert(p.bound == .lower and tuple.bound == .upper); + } else { + assert(p.source < tuple.source); + } + } + } + previous = tuple; + + // Update the current number of overlapping intervals: + switch (tuple.bound) { + .lower => count += 1, + .upper => count -= 1, + } + // The last upper bound tuple will have a count of one less than the lower bound. + // Therefore, we should never see count >= best for the last tuple: + if (count > best) { + best = count; + interval.lower_bound = tuple.offset; + interval.upper_bound = tuples[i + 1].offset; + } else if (count == best and tuples[i + 1].bound == .upper) { + // This is a tie for best overlap. Both intervals have the same number of sources. + // We want to choose the smaller of the two intervals: + const alternative = tuples[i + 1].offset - tuple.offset; + if (alternative < interval.upper_bound - interval.lower_bound) { + interval.lower_bound = tuple.offset; + interval.upper_bound = tuples[i + 1].offset; + } + } + } + assert(previous.?.bound == .upper); + + // The number of false sources (ones which do not overlap the optimal interval) is the + // number of sources minus the value of `best`: + assert(best <= sources); + interval.sources_true = @intCast(best); + interval.sources_false = @as(u8, @intCast(sources - @as(u8, @intCast(best)))); + assert(interval.sources_true + interval.sources_false == sources); + + return interval; + } + + /// Sorts the list of tuples by clock offset. If two tuples with the same offset but opposite + /// bounds exist, indicating that one interval ends just as another begins, then a method of + /// deciding which comes first is necessary. Such an occurrence can be considered an overlap + /// with no duration, which can be found by the algorithm by sorting the lower bound before the + /// upper bound. Alternatively, if such pathological overlaps are considered objectionable then + /// they can be avoided by sorting the upper bound before the lower bound. + fn less_than(context: void, a: Tuple, b: Tuple) bool { + _ = context; + + if (a.offset < b.offset) return true; + if (b.offset < a.offset) return false; + if (a.bound == .lower and b.bound == .upper) return true; + if (b.bound == .lower and a.bound == .upper) return false; + // Use the source index to break the tie and ensure the sort is fully specified and stable + // so that different sort algorithms sort the same way: + if (a.source < b.source) return true; + if (b.source < a.source) return false; + return false; + } +}; + +fn test_smallest_interval(bounds: []const i64, smallest_interval: Marzullo.Interval) !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + + const allocator = arena.allocator(); + + var tuples = try allocator.alloc(Marzullo.Tuple, bounds.len); + for (bounds, 0..) |bound, i| { + tuples[i] = .{ + .source = @intCast(@divTrunc(i, 2)), + .offset = bound, + .bound = if (i % 2 == 0) .lower else .upper, + }; + } + + const interval = Marzullo.smallest_interval(tuples); + try std.testing.expectEqual(smallest_interval, interval); +} + +test "marzullo" { + try test_smallest_interval( + &[_]i64{ + 11, 13, + 10, 12, + 8, 12, + }, + Marzullo.Interval{ + .lower_bound = 11, + .upper_bound = 12, + .sources_true = 3, + .sources_false = 0, + }, + ); + + try test_smallest_interval( + &[_]i64{ + 8, 12, + 11, 13, + 14, 15, + }, + Marzullo.Interval{ + .lower_bound = 11, + .upper_bound = 12, + .sources_true = 2, + .sources_false = 1, + }, + ); + + try test_smallest_interval( + &[_]i64{ + -10, 10, + -1, 1, + 0, 0, + }, + Marzullo.Interval{ + .lower_bound = 0, + .upper_bound = 0, + .sources_true = 3, + .sources_false = 0, + }, + ); + + // The upper bound of the first interval overlaps inclusively with the lower of the last. + try test_smallest_interval( + &[_]i64{ + 8, 12, + 10, 11, + 8, 10, + }, + Marzullo.Interval{ + .lower_bound = 10, + .upper_bound = 10, + .sources_true = 3, + .sources_false = 0, + }, + ); + + // The first smallest interval is selected. The alternative with equal overlap is 10..12. + // However, while this shares the same number of sources, it is not the smallest interval. + try test_smallest_interval( + &[_]i64{ + 8, 12, + 10, 12, + 8, 9, + }, + Marzullo.Interval{ + .lower_bound = 8, + .upper_bound = 9, + .sources_true = 2, + .sources_false = 1, + }, + ); + + // The last smallest interval is selected. The alternative with equal overlap is 7..9. + // However, while this shares the same number of sources, it is not the smallest interval. + try test_smallest_interval( + &[_]i64{ + 7, 9, + 7, 12, + 10, 11, + }, + Marzullo.Interval{ + .lower_bound = 10, + .upper_bound = 11, + .sources_true = 2, + .sources_false = 1, + }, + ); + + // The same idea as the previous test, but with negative offsets. + try test_smallest_interval( + &[_]i64{ + -9, -7, + -12, -7, + -11, -10, + }, + Marzullo.Interval{ + .lower_bound = -11, + .upper_bound = -10, + .sources_true = 2, + .sources_false = 1, + }, + ); + + // A cluster of one with no remote sources. + try test_smallest_interval( + &[_]i64{}, + Marzullo.Interval{ + .lower_bound = 0, + .upper_bound = 0, + .sources_true = 0, + .sources_false = 0, + }, + ); + + // A cluster of two with one remote source. + try test_smallest_interval( + &[_]i64{ + 1, 3, + }, + Marzullo.Interval{ + .lower_bound = 1, + .upper_bound = 3, + .sources_true = 1, + .sources_false = 0, + }, + ); + + // A cluster of three with agreement. + try test_smallest_interval( + &[_]i64{ + 1, 3, + 2, 2, + }, + Marzullo.Interval{ + .lower_bound = 2, + .upper_bound = 2, + .sources_true = 2, + .sources_false = 0, + }, + ); + + // A cluster of three with no agreement, still returns the smallest interval. + try test_smallest_interval( + &[_]i64{ + 1, 3, + 4, 5, + }, + Marzullo.Interval{ + .lower_bound = 4, + .upper_bound = 5, + .sources_true = 1, + .sources_false = 1, + }, + ); +} diff --git a/ocam/src/vsr/message_header.zig b/ocam/src/vsr/message_header.zig new file mode 100644 index 00000000..770c4b11 --- /dev/null +++ b/ocam/src/vsr/message_header.zig @@ -0,0 +1,1776 @@ +const std = @import("std"); +const assert = std.debug.assert; +const maybe = stdx.maybe; + +const constants = @import("../constants.zig"); +const stdx = @import("stdx"); +const vsr = @import("../vsr.zig"); +const Command = vsr.Command; +const Operation = vsr.Operation; +const schema = @import("../lsm/schema.zig"); + +const checksum_body_empty = vsr.checksum(&.{}); + +/// Network message, prepare, and grid block header: +/// We reuse the same header for both so that prepare messages from the primary can simply be +/// journalled as is by the backups without requiring any further modification. +pub const Header = extern struct { + /// A checksum covering only the remainder of this header. + /// This allows the header to be trusted without having to recv() or read() the associated body. + /// This checksum is enough to uniquely identify a network message or prepare. + checksum: u128, + + // TODO(zig): When Zig supports u256 in extern-structs, merge this into `checksum`. + checksum_padding: u128, + + /// A checksum covering only the associated body after this header. + checksum_body: u128, + + // TODO(zig): When Zig supports u256 in extern-structs, merge this into `checksum_body`. + checksum_body_padding: u128, + + /// Reserved for future use by AEAD. + nonce_reserved: u128, + + /// The cluster number binds intention into the header, so that a client or replica can indicate + /// the cluster it believes it is speaking to, instead of accidentally talking to the wrong + /// cluster (for example, staging vs production). + cluster: u128, + + /// The size of the Header structure (always), plus any associated body. + size: u32, + + /// The cluster reconfiguration epoch number (for future use). + epoch: u32, + + /// Every message sent from one replica to another contains the sending replica's current view. + /// A `u32` allows for a minimum lifetime of 136 years at a rate of one view change per second. + view: u32, + + /// The release version set by the state machine. + /// (This field is not set for all message types.) + release: vsr.Release, + + /// The version of the protocol implementation that originated this message. + protocol: u16, + + /// The Viewstamped Replication protocol command for this message. + command: Command, + + /// The index of the replica in the cluster configuration array that authored this message. + /// This identifies only the ultimate author because messages may be forwarded amongst replicas. + replica: u8, + + /// Reserved for future use by the header frame (i.e. to be shared by all message types). + reserved_frame: [12]u8, + + /// This data's schema is different depending on the `Header.command`. + /// (No default value – `Header`s should not be constructed directly.) + reserved_command: [128]u8, + + comptime { + assert(@sizeOf(Header) == 256); + assert(@alignOf(Header) == 16); + assert(stdx.no_padding(Header)); + assert(@offsetOf(Header, "reserved_command") % @sizeOf(u256) == 0); + } + + pub fn Type(comptime command: Command) type { + return switch (command) { + .reserved => Reserved, + .ping => Ping, + .pong => Pong, + .ping_client => PingClient, + .pong_client => PongClient, + .request => Request, + .prepare => Prepare, + .prepare_ok => PrepareOk, + .reply => Reply, + .commit => Commit, + .exit_view => ExitView, + .join_view => JoinView, + .view => View, + .get_view => GetView, + .get_headers => GetHeaders, + .get_prepare => GetPrepare, + .get_reply => GetReply, + .headers => Headers, + .eviction => Eviction, + .get_blocks => GetBlocks, + .block => Block, + .deprecated_12 => Deprecated, + .deprecated_21 => Deprecated, + .deprecated_22 => Deprecated, + .deprecated_23 => Deprecated, + }; + } + + pub fn calculate_checksum(self: *const Header) u128 { + const checksum_size = @sizeOf(@TypeOf(self.checksum)); + assert(checksum_size == 16); + const checksum_value = vsr.checksum(std.mem.asBytes(self)[checksum_size..]); + assert(@TypeOf(checksum_value) == @TypeOf(self.checksum)); + return checksum_value; + } + + pub fn calculate_checksum_body(self: *const Header, body: []const u8) u128 { + assert(self.size == @sizeOf(Header) + body.len); + const checksum_size = @sizeOf(@TypeOf(self.checksum_body)); + assert(checksum_size == 16); + const checksum_value = vsr.checksum(body); + assert(@TypeOf(checksum_value) == @TypeOf(self.checksum_body)); + return checksum_value; + } + + /// This must be called only after set_checksum_body() so that checksum_body is also covered: + pub fn set_checksum(self: *Header) void { + self.checksum = self.calculate_checksum(); + } + + pub fn set_checksum_body(self: *Header, body: []const u8) void { + self.checksum_body = self.calculate_checksum_body(body); + } + + pub fn valid_checksum(self: *const Header) bool { + return self.checksum == self.calculate_checksum(); + } + + pub fn valid_checksum_body(self: *const Header, body: []const u8) bool { + return self.checksum_body == self.calculate_checksum_body(body); + } + + pub const AnyHeaderPointer = stdx.EnumUnionType(Command, struct { + fn PointerForCommandType(comptime variant: Command) type { + return *const Type(variant); + } + }.PointerForCommandType); + + pub fn into_any(self: *const Header) AnyHeaderPointer { + switch (self.command) { + inline else => |command| { + return @unionInit(AnyHeaderPointer, @tagName(command), self.into_const(command).?); + }, + } + } + + pub fn into(self: *Header, comptime command: Command) ?*Type(command) { + if (self.command != command) return null; + return std.mem.bytesAsValue(Type(command), std.mem.asBytes(self)); + } + + pub fn into_const(self: *const Header, comptime command: Command) ?*const Type(command) { + if (self.command != command) return null; + return std.mem.bytesAsValue(Type(command), std.mem.asBytes(self)); + } + + /// Returns null if all fields are set correctly according to the command, or else a warning. + /// This does not verify that checksum is valid, and expects that this has already been done. + pub fn invalid(self: *const Header) ?[]const u8 { + if (self.checksum_padding != 0) return "checksum_padding != 0"; + if (self.checksum_body_padding != 0) return "checksum_body_padding != 0"; + if (self.nonce_reserved != 0) return "nonce_reserved != 0"; + if (self.size < @sizeOf(Header)) return "size < @sizeOf(Header)"; + if (self.size > constants.message_size_max) return "size > message_size_max"; + if (self.epoch != 0) return "epoch != 0"; + if (!stdx.zeroed(&self.reserved_frame)) return "reserved_frame != 0"; + + if (self.command == .block) { + if (self.protocol > vsr.Version) return "block: protocol > Version"; + } else { + if (self.protocol != vsr.Version) return "protocol != Version"; + } + + switch (self.into_any()) { + inline else => |command_header| return command_header.invalid_header(), + // The `Command` enum is exhaustive, so we can't write an "else" branch here. An unknown + // command is a possibility, but that means that someone has send us a message with + // matching cluster, matching version, correct checksum, and a command we don't know + // about. Ignoring unknown commands might be unsafe, so the replica intentionally + // crashes here, which is guaranteed by Zig's ReleaseSafe semantics. + // + // _ => unreachable + } + } + + /// Returns whether the immediate sender is a replica or client (if this can be determined). + /// Some commands such as .request or .prepare may be forwarded on to other replicas so that + /// Header.replica or Header.client only identifies the ultimate origin, not the latest peer. + pub fn peer_type(self: *const Header) vsr.Peer { + return switch (self.into_any()) { + .reserved => unreachable, + + .reply, + .prepare, + .block, + => .unknown, + + // The peer may be a replica or a client, since replicas forward request messages. + // However, we return the client ID, as it is useful for the MessageBus. Specifically, + // a replica that receives a request from a client can immediately cache the connection + // in its client map, instead of waiting for an infrequent PingClient message to do so. + .request => |request| .{ .client_likely = request.client }, + + // The peer is certainly a client: + .ping_client => |ping| .{ .client = ping.client }, + + // The peer is certainly a replica: + .ping, + .pong, + .pong_client, + .prepare_ok, + .commit, + .exit_view, + .join_view, + .view, + .get_view, + .get_headers, + .get_prepare, + .get_reply, + .headers, + .eviction, + .get_blocks, + => .{ .replica = self.replica }, + + .deprecated_12, + .deprecated_21, + .deprecated_22, + .deprecated_23, + => .unknown, + }; + } + + pub fn format( + self: *const Header, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + switch (self.into_any()) { + inline else => |header| return try header.format(fmt, options, writer), + } + } + + fn HeaderFunctionsType(comptime CommandHeader: type) type { + return struct { + pub fn frame(header: *CommandHeader) *Header { + return std.mem.bytesAsValue(Header, std.mem.asBytes(header)); + } + + pub fn frame_const(header: *const CommandHeader) *const Header { + return std.mem.bytesAsValue(Header, std.mem.asBytes(header)); + } + + pub fn invalid(self: *const CommandHeader) ?[]const u8 { + return self.frame_const().invalid(); + } + + pub fn calculate_checksum(self: *const CommandHeader) u128 { + return self.frame_const().calculate_checksum(); + } + + pub fn calculate_checksum_body(self: *const CommandHeader, body: []const u8) u128 { + return self.frame_const().calculate_checksum_body(body); + } + + pub fn set_checksum(self: *CommandHeader) void { + self.frame().set_checksum(); + } + + pub fn set_checksum_body(self: *CommandHeader, body: []const u8) void { + self.frame().set_checksum_body(body); + } + + pub fn valid_checksum(self: *const CommandHeader) bool { + return self.frame_const().valid_checksum(); + } + + pub fn valid_checksum_body(self: *const CommandHeader, body: []const u8) bool { + return self.frame_const().valid_checksum_body(body); + } + + pub fn format( + self: *const CommandHeader, + comptime _: []const u8, + _: std.fmt.FormatOptions, + writer: anytype, + ) !void { + return format_header(CommandHeader, self, writer); + } + }; + } + + /// This type isn't ever actually a constructed, but makes Type() simpler by providing a header + /// type for each command. + pub const Reserved = extern struct { + checksum: u128, + checksum_padding: u128 = 0, + checksum_body: u128, + checksum_body_padding: u128 = 0, + nonce_reserved: u128, + cluster: u128, + size: u32, + epoch: u32 = 0, + view: u32 = 0, + release: vsr.Release = vsr.Release.zero, // Always 0. + protocol: u16 = vsr.Version, + command: Command, + replica: u8 = 0, + reserved_frame: [12]u8, + + reserved: [128]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .reserved); + return "reserved is invalid"; + } + }; + + /// This type isn't ever actually a constructed, but makes Type() simpler by providing a header + /// type for each command. + pub const Deprecated = extern struct { + checksum: u128, + checksum_padding: u128 = 0, + checksum_body: u128, + checksum_body_padding: u128 = 0, + nonce_reserved: u128, + cluster: u128, + size: u32, + epoch: u32 = 0, + view: u32 = 0, + release: vsr.Release, + protocol: u16 = vsr.Version, + command: Command, + replica: u8 = 0, + reserved_frame: [12]u8, + + reserved: [128]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(_: *const @This()) ?[]const u8 { + return "deprecated message type"; + } + }; + + pub const Ping = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32, + epoch: u32 = 0, + // NB: unlike every other message, pings and pongs use on disk view, rather than in-memory + // view, to avoid disrupting clock synchronization while the view is being updated. + view: u32, + release: vsr.Release, + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + /// Current checkpoint id. + checkpoint_id: u128, + /// Current checkpoint op. + checkpoint_op: u64, + + ping_timestamp_monotonic: u64, + release_count: u16, + reserved: [94]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .ping); + if (self.size != @sizeOf(Header) + @sizeOf(vsr.Release) * constants.vsr_releases_max) { + return "size != @sizeOf(Header) + " ++ + "@sizeOf(vsr.Release) * constants.vsr_releases_max"; + } + if (self.release.value == 0) return "release == 0"; + if (!vsr.Checkpoint.valid(self.checkpoint_op)) return "checkpoint_op invalid"; + if (self.ping_timestamp_monotonic == 0) return "ping_timestamp_monotonic != expected"; + if (self.release_count == 0) return "release_count == 0"; + if (self.release_count > constants.vsr_releases_max) { + return "release_count > vsr_releases_max"; + } + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const Pong = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + // NB: unlike every other message, pings and pongs use on disk view, rather than in-memory + // view, to avoid disrupting clock synchronization while the view is being updated. + view: u32, + release: vsr.Release, + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + ping_timestamp_monotonic: u64, + pong_timestamp_wall: u64, + + reserved: [112]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .pong); + if (self.size != @sizeOf(Header)) return "size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) return "checksum_body != expected"; + if (self.release.value == 0) return "release == 0"; + if (self.ping_timestamp_monotonic == 0) return "ping_timestamp_monotonic == 0"; + if (self.pong_timestamp_wall == 0) return "pong_timestamp_wall == 0"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const PingClient = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32 = 0, // Always 0. + release: vsr.Release, + protocol: u16 = vsr.Version, + command: Command, + replica: u8 = 0, // Always 0. + reserved_frame: [12]u8 = @splat(0), + + client: u128, + ping_timestamp_monotonic: u64, + // NB: Introduced in 0.17.6, and was implicitly 0 before that. + session: u64, + reserved: [96]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .ping_client); + if (self.size != @sizeOf(Header)) return "size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) return "checksum_body != expected"; + if (self.release.value == 0) return "release == 0"; + if (self.replica != 0) return "replica != 0"; + if (self.view != 0) return "view != 0"; + if (self.client == 0) return "client == 0"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const PongClient = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32, + release: vsr.Release, + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + ping_timestamp_monotonic: u64, + reserved: [120]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .pong_client); + if (self.size != @sizeOf(Header)) return "size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) return "checksum_body != expected"; + if (self.release.value == 0) return "release == 0"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const Request = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32 = 0, + /// The client's release version. + release: vsr.Release, + protocol: u16 = vsr.Version, + command: Command, + replica: u8 = 0, // Always 0. + reserved_frame: [12]u8 = @splat(0), + + /// Clients hash-chain their requests to verify linearizability: + /// - A session's first request (operation=register) sets `parent=0`. + /// - A session's subsequent requests (operation≠register) set `parent` to the checksum of + /// the preceding reply. + parent: u128 = 0, + parent_padding: u128 = 0, + /// Each client process generates a unique, random and ephemeral client ID at + /// initialization. The client ID identifies connections made by the client to the cluster + /// for the sake of routing messages back to the client. + /// + /// With the client ID in hand, the client then registers a monotonically increasing session + /// number (committed through the cluster) to allow the client's session to be evicted + /// safely from the client table if too many concurrent clients cause the client table to + /// overflow. The monotonically increasing session number prevents duplicate client requests + /// from being replayed. + /// + /// The problem of routing is therefore solved by the 128-bit client ID, and the problem of + /// detecting whether a session has been evicted is solved by the session number. + client: u128, + /// When operation=register, this is zero. + /// When operation≠register, this is the commit number of register. + session: u64 = 0, + /// Only nonzero during AOF recovery. + /// TODO: Use this for bulk-import to state machine? + timestamp: u64 = 0, + /// Each request is given a number by the client and later requests must have larger numbers + /// than earlier ones. The request number is used by the replicas to avoid running requests + /// more than once; it is also used by the client to discard duplicate replies to its + /// requests. + /// + /// A client is allowed to have at most one request inflight at a time. + request: u32, + operation: Operation, + previous_request_latency_padding: [3]u8 = @splat(0), + /// Microsecond (0.17.0+) / Nanosecond interval measuring the time between when the client + /// first began to construct the previous request's body and the time that the client + /// received the corresponding reply. + previous_request_latency: u32, + reserved: [52]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .request); + if (self.release.value == 0) return "release == 0"; + if (self.parent_padding != 0) return "parent_padding != 0"; + switch (self.operation) { + .reserved => return "operation == .reserved", + .root => return "operation == .root", + .register => { + // The first request a client makes must be to register with the cluster: + if (self.replica != 0) return "register: replica != 0"; + if (self.client == 0) return "register: client == 0"; + if (self.parent != 0) return "register: parent != 0"; + if (self.session != 0) return "register: session != 0"; + if (self.request != 0) return "register: request != 0"; + // Support `register` requests without the body to correctly + // reply with `client_release_too_low` for clients <= v0.15.3. + if (self.size != @sizeOf(Header) and + self.size != @sizeOf(Header) + @sizeOf(vsr.RegisterRequest)) + { + return "register: size != @sizeOf(Header) [+ @sizeOf(vsr.RegisterRequest)]"; + } + }, + .pulse => { + // These requests don't originate from a real client or session. + if (self.client != 0) return "pulse: client != 0"; + if (self.parent != 0) return "pulse: parent != 0"; + if (self.session != 0) return "pulse: session != 0"; + if (self.request != 0) return "pulse: request != 0"; + if (self.size != @sizeOf(Header)) return "pulse: size != @sizeOf(Header)"; + }, + .upgrade => { + // These requests don't originate from a real client or session. + if (self.client != 0) return "upgrade: client != 0"; + if (self.parent != 0) return "upgrade: parent != 0"; + if (self.session != 0) return "upgrade: session != 0"; + if (self.request != 0) return "upgrade: request != 0"; + + if (self.size != @sizeOf(Header) + @sizeOf(vsr.UpgradeRequest)) { + return "upgrade: size != @sizeOf(Header) + @sizeOf(vsr.UpgradeRequest)"; + } + }, + else => { + if (self.operation == .reconfigure) { + if (self.size != @sizeOf(Header) + @sizeOf(vsr.ReconfigurationRequest)) { + return "size != @sizeOf(Header) + @sizeOf(ReconfigurationRequest)"; + } + } else if (self.operation == .noop) { + if (self.size != @sizeOf(Header)) return "size != @sizeOf(Header)"; + } else if (@intFromEnum(self.operation) < constants.vsr_operations_reserved) { + return "operation is reserved"; + } + if (self.replica != 0) return "replica != 0"; + if (self.client == 0) return "client == 0"; + // Thereafter, the client must provide the session number: + // These requests should set `parent` to the `checksum` of the previous reply. + if (self.session == 0) return "session == 0"; + if (self.request == 0) return "request == 0"; + // The Replica is responsible for checking the `Operation` is a valid variant – + // the check requires the StateMachine type. + }, + } + if (!stdx.zeroed(&self.previous_request_latency_padding)) return "padding != 0"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const Prepare = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32, + /// The corresponding Request's release version. + release: vsr.Release, + protocol: u16 = vsr.Version, + command: Command, + replica: u8 = 0, + reserved_frame: [12]u8 = @splat(0), + + /// A backpointer to the previous prepare checksum for hash chain verification. + /// This provides a strong guarantee for linearizability across our distributed log + /// of prepares. + /// + /// This may also be used as the initialization vector for AEAD encryption at rest, provided + /// that the primary ratchets the encryption key every view change to ensure that prepares + /// reordered through a view change never repeat the same IV for the same encryption key. + parent: u128, + parent_padding: u128 = 0, + /// The checksum of the client's request. + request_checksum: u128, + request_checksum_padding: u128 = 0, + /// The id of the checkpoint where: + /// + /// prepare.op > checkpoint_op + /// prepare.op ≤ checkpoint_after(checkpoint_op) + /// + /// The purpose of including the checkpoint id is to strictly bound the number of commits + /// that it may take to discover a divergent replica. If a replica diverges, then that + /// divergence will be discovered *at latest* when the divergent replica attempts to commit + /// the first op after the next checkpoint. + checkpoint_id: u128, + client: u128, + /// The op number of the latest prepare that may or may not yet be committed. Uncommitted + /// ops may be replaced by different ops if they do not survive through a view change. + op: u64, + /// The commit number of the latest committed prepare. Committed ops are immutable. + commit: u64, + /// The primary's state machine `prepare_timestamp`. + /// For `create_accounts` and `create_transfers` this is the batch's highest timestamp. + timestamp: u64, + request: u32, + /// The state machine operation to apply. + operation: Operation, + reserved: [3]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const Prepare) ?[]const u8 { + assert(self.command == .prepare); + if (self.parent_padding != 0) return "parent_padding != 0"; + if (self.request_checksum_padding != 0) return "request_checksum_padding != 0"; + switch (self.operation) { + .reserved => { + if (self.size != @sizeOf(Header)) return "reserved: size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) { + return "reserved: checksum_body != expected"; + } + if (self.view != 0) return "reserved: view != 0"; + if (self.release.value != 0) return "release != 0"; + if (self.replica != 0) return "reserved: replica != 0"; + if (self.parent != 0) return "reserved: parent != 0"; + if (self.client != 0) return "reserved: client != 0"; + if (self.request_checksum != 0) return "reserved: request_checksum != 0"; + if (self.checkpoint_id != 0) return "reserved: checkpoint_id != 0"; + maybe(self.op == 0); + if (self.commit != 0) return "reserved: commit != 0"; + if (self.request != 0) return "reserved: request != 0"; + if (self.timestamp != 0) return "reserved: timestamp != 0"; + }, + .root => { + if (self.size != @sizeOf(Header)) return "root: size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) { + return "root: checksum_body != expected"; + } + if (self.view != 0) return "root: view != 0"; + if (self.release.value != 0) return "release != 0"; + if (self.replica != 0) return "root: replica != 0"; + if (self.parent != 0) return "root: parent != 0"; + if (self.client != 0) return "root: client != 0"; + if (self.request_checksum != 0) return "root: request_checksum != 0"; + if (self.checkpoint_id != 0) return "root: checkpoint_id != 0"; + if (self.op != 0) return "root: op != 0"; + if (self.commit != 0) return "root: commit != 0"; + if (self.timestamp != 0) return "root: timestamp != 0"; + if (self.request != 0) return "root: request != 0"; + }, + else => { + if (self.release.value == 0) return "release == 0"; + if (self.operation == .pulse or + self.operation == .upgrade) + { + if (self.client != 0) return "client != 0"; + } else { + if (self.client == 0) return "client == 0"; + } + if (self.op == 0) return "op == 0"; + if (self.op <= self.commit) return "op <= commit"; + if (self.timestamp == 0) return "timestamp == 0"; + if (self.operation == .register or + self.operation == .pulse or + self.operation == .upgrade) + { + if (self.request != 0) return "request != 0"; + } else { + if (self.request == 0) return "request == 0"; + } + }, + } + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + + pub fn reserve(cluster: u128, slot: u64) Prepare { + assert(slot < constants.journal_slot_count); + + var header = Prepare{ + .command = .prepare, + .cluster = cluster, + .release = vsr.Release.zero, + .op = slot, + .operation = .reserved, + .view = 0, + .request_checksum = 0, + .checkpoint_id = 0, + .parent = 0, + .client = 0, + .commit = 0, + .timestamp = 0, + .request = 0, + }; + header.set_checksum_body(&[0]u8{}); + header.set_checksum(); + assert(header.invalid() == null); + return header; + } + + pub fn root(cluster: u128) Prepare { + var header = Prepare{ + .cluster = cluster, + .size = @sizeOf(Header), + .release = vsr.Release.zero, + .command = .prepare, + .operation = .root, + .op = 0, + .view = 0, + .request_checksum = 0, + .checkpoint_id = 0, + .parent = 0, + .client = 0, + .commit = 0, + .timestamp = 0, + .request = 0, + }; + header.set_checksum_body(&[0]u8{}); + header.set_checksum(); + assert(header.invalid() == null); + return header; + } + }; + + pub const PrepareOk = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32, + release: vsr.Release = vsr.Release.zero, // Always 0. + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + /// The previous prepare's checksum. + /// (Same as the corresponding Prepare's `parent`.) + parent: u128, + parent_padding: u128 = 0, + /// The corresponding prepare's checksum. + prepare_checksum: u128, + prepare_checksum_padding: u128 = 0, + /// The corresponding prepare's checkpoint_id. + checkpoint_id: u128, + client: u128, + op: u64, + commit_min: u64, + timestamp: u64, + request: u32, + operation: Operation = .reserved, + reserved: [3]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .prepare_ok); + if (self.size != @sizeOf(Header)) return "size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) return "checksum_body != expected"; + if (self.release.value != 0) return "release != 0"; + if (self.prepare_checksum_padding != 0) return "prepare_checksum_padding != 0"; + switch (self.operation) { + .reserved => return "operation == .reserved", + .root => { + const root_checksum = Header.Prepare.root(self.cluster).checksum; + if (self.parent != 0) return "root: parent != 0"; + if (self.client != 0) return "root: client != 0"; + if (self.prepare_checksum != root_checksum) { + return "root: prepare_checksum != expected"; + } + if (self.request != 0) return "root: request != 0"; + if (self.op != 0) return "root: op != 0"; + if (self.timestamp != 0) return "root: timestamp != 0"; + }, + else => { + if (self.operation == .upgrade or + self.operation == .pulse) + { + if (self.client != 0) return "client != 0"; + } else { + if (self.client == 0) return "client == 0"; + } + if (self.op == 0) return "op == 0"; + if (self.timestamp == 0) return "timestamp == 0"; + if (self.operation == .register or + self.operation == .upgrade) + { + if (self.request != 0) return "request != 0"; + } else if (self.client == 0) { + if (self.request != 0) return "request != 0"; + } else { + if (self.request == 0) return "request == 0"; + } + }, + } + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const Reply = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32, + /// The corresponding Request's (and Prepare's, and client's) release version. + /// `Reply.release` matches `Request.release` (rather than the cluster release): + /// - to serve as an escape hatch if state machines ever need to branch on client release. + /// - to emphasize that the reply's format must be compatible with the client's version – + /// which is potentially behind the cluster's version when the prepare commits. + release: vsr.Release, + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + /// The checksum of the corresponding Request. + request_checksum: u128, + request_checksum_padding: u128 = 0, + /// The checksum to be included with the next request as parent checksum. + /// It's almost exactly the same as entire header's checksum, except that it is computed + /// with a fixed view and remains stable if reply is retransmitted in a newer view. + /// This allows for strong guarantees beyond request, op, and commit numbers, which + /// have low entropy and may otherwise collide in the event of any correctness bugs. + context: u128 = 0, + context_padding: u128 = 0, + client: u128, + op: u64, + commit: u64, + /// The corresponding `prepare`'s timestamp. + /// This allows the test workload to verify transfer timeouts. + timestamp: u64, + request: u32, + operation: Operation = .reserved, + reserved: [19]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .reply); + if (self.release.value == 0) return "release == 0"; + // Initialization within `client.zig` asserts that client `id` is greater than zero: + if (self.client == 0) return "client == 0"; + if (self.request_checksum_padding != 0) return "request_checksum_padding != 0"; + if (self.context_padding != 0) return "context_padding != 0"; + if (self.op != self.commit) return "op != commit"; + if (self.timestamp == 0) return "timestamp == 0"; + if (self.operation == .register) { + if (self.size != @sizeOf(Header) + @sizeOf(vsr.RegisterResult)) { + return "register: size != @sizeOf(Header) + @sizeOf(vsr.RegisterResult)"; + } + // In this context, the commit number is the newly registered session number. + // The `0` commit number is reserved for cluster initialization. + if (self.commit == 0) return "commit == 0"; + if (self.request != 0) return "request != 0"; + } else { + if (self.commit == 0) return "commit == 0"; + if (self.request == 0) return "request == 0"; + } + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const Commit = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32, + release: vsr.Release = vsr.Release.zero, // Always 0. + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + /// The latest committed prepare's checksum. + commit_checksum: u128, + commit_checksum_padding: u128 = 0, + + /// Current checkpoint id. + checkpoint_id: u128, + + /// Current checkpoint op. + checkpoint_op: u64, + + /// The latest committed prepare's op. + commit: u64, + + timestamp_monotonic: u64, + + reserved: [56]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .commit); + if (self.size != @sizeOf(Header)) return "size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) return "checksum_body != expected"; + if (self.release.value != 0) return "release != 0"; + if (self.commit < self.checkpoint_op) return "commit < checkpoint_op"; + if (self.timestamp_monotonic == 0) return "timestamp_monotonic == 0"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const ExitView = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32, + release: vsr.Release = vsr.Release.zero, // Always 0. + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + reserved: [128]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .exit_view); + if (self.size != @sizeOf(Header)) return "size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) return "checksum_body != expected"; + if (self.release.value != 0) return "release != 0"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const JoinView = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32, + release: vsr.Release = vsr.Release.zero, // Always 0. + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + /// A bitset of "present" prepares. If a bit is set, then the corresponding header is not + /// "blank", the replica has the prepare, and the prepare is not known to be faulty. + present_bitset: u128, + /// A bitset, with set bits indicating headers in the message body which it has definitely + /// not prepared (i.e. "nack"). The corresponding header may be an actual prepare header, or + /// it may be a "blank" header. + nack_bitset: u128, + op: u64, + /// Set to `commit_min`, to indicate the sending replica's progress. + /// The sending replica may continue to commit after sending the JV. + commit_min: u64, + checkpoint_op: u64, + log_view: u32, + reserved: [68]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .join_view); + if ((self.size - @sizeOf(Header)) % @sizeOf(Header) != 0) { + return "size multiple invalid"; + } + if (self.release.value != 0) return "release != 0"; + if (self.op < self.commit_min) return "op < commit_min"; + if (self.commit_min < self.checkpoint_op) return "commit_min < checkpoint_op"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const View = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32, + release: vsr.Release = vsr.Release.zero, // Always 0. + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + /// Set to zero for a new view, and to a nonce from an RV when responding to the RV. + nonce: u128, + op: u64, + /// Equal to `commit_min` if the View message is being sent by a .normal primary, + /// but may not be equal if sent by potential primary in .view_change status. + commit_max: u64, + /// The replica's `op_checkpoint`. + checkpoint_op: u64, + reserved: [88]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .view); + const body_size = self.size - @sizeOf(Header); + if (body_size < @sizeOf(vsr.CheckpointState)) return "checkpointstate missing"; + const headers_size = body_size - @sizeOf(vsr.CheckpointState); + if (headers_size % @sizeOf(Header) != 0) { + return "headers size multiple invalid"; + } + if (self.release.value != 0) return "release != 0"; + if (self.op < self.commit_max) return "op < commit_max"; + if (self.commit_max < self.checkpoint_op) return "commit_max < checkpoint_op"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const GetView = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32, + release: vsr.Release = vsr.Release.zero, // Always 0. + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + nonce: u128, + reserved: [112]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .get_view); + if (self.size != @sizeOf(Header)) return "size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) return "checksum_body != expected"; + if (self.release.value != 0) return "release != 0"; + if (self.nonce == 0) return "nonce == 0"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const GetHeaders = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32 = 0, // Always 0. + release: vsr.Release = vsr.Release.zero, // Always 0. + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + /// The minimum op requested (inclusive). + op_min: u64, + /// The maximum op requested (inclusive). + op_max: u64, + reserved: [112]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .get_headers); + if (self.size != @sizeOf(Header)) return "size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) return "checksum_body != expected"; + if (self.view != 0) return "view == 0"; + if (self.release.value != 0) return "release != 0"; + if (self.op_min > self.op_max) return "op_min > op_max"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const GetPrepare = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32, + release: vsr.Release = vsr.Release.zero, // Always 0. + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + prepare_checksum: u128, + prepare_checksum_padding: u128 = 0, + prepare_op: u64, + reserved: [88]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .get_prepare); + if (self.size != @sizeOf(Header)) return "size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) return "checksum_body != expected"; + if (self.view != 0 and self.prepare_checksum != 0) return "view != 0 and checksum != 0"; + if (self.release.value != 0) return "release != 0"; + if (self.prepare_checksum_padding != 0) return "prepare_checksum_padding != 0"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const GetReply = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32 = 0, // Always 0. + release: vsr.Release = vsr.Release.zero, // Always 0. + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + reply_checksum: u128, + reply_checksum_padding: u128 = 0, + reply_client: u128, + reply_op: u64, + reserved: [72]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .get_reply); + if (self.size != @sizeOf(Header)) return "size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) return "checksum_body != expected"; + if (self.release.value != 0) return "release != 0"; + if (self.reply_checksum_padding != 0) return "reply_checksum_padding != 0"; + if (self.view != 0) return "view == 0"; + if (self.reply_client == 0) return "reply_client == 0"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const Headers = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32, + release: vsr.Release = vsr.Release.zero, // Always 0. + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + reserved: [128]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .headers); + if (self.size == @sizeOf(Header)) return "size == @sizeOf(Header)"; + if ((self.size - @sizeOf(Header)) % @sizeOf(Header) != 0) { + return "size multiple invalid"; + } + if (self.release.value != 0) return "release != 0"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const Eviction = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32, + release: vsr.Release, + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + client: u128, + reserved: [111]u8 = @splat(0), + reason: Reason, + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .eviction); + if (self.size != @sizeOf(Header)) return "size != @sizeOf(Header)"; + if (self.checksum_body != checksum_body_empty) return "checksum_body != expected"; + if (self.release.value == 0) return "release == 0"; + if (self.client == 0) return "client == 0"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + + const reasons = comptime std.enums.values(Reason); + inline for (reasons) |reason| { + if (@intFromEnum(self.reason) == @intFromEnum(reason)) break; + } else return "reason invalid"; + if (self.reason == .reserved) return "reason == reserved"; + return null; + } + + pub const Reason = enum(u8) { + reserved = 0, + no_session = 1, + client_release_too_low = 2, + client_release_too_high = 3, + invalid_request_operation = 4, + invalid_request_body = 5, + invalid_request_body_size = 6, + session_too_low = 7, + session_release_mismatch = 8, + + comptime { + for (std.enums.values(Reason), 0..) |reason, index| { + assert(@intFromEnum(reason) == index); + } + } + }; + }; + + pub const GetBlocks = extern struct { + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32 = 0, // Always 0. + release: vsr.Release = vsr.Release.zero, // Always 0. + protocol: u16 = vsr.Version, + command: Command, + replica: u8, + reserved_frame: [12]u8 = @splat(0), + + reserved: [128]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .get_blocks); + if (self.view != 0) return "view != 0"; + if (self.size == @sizeOf(Header)) return "size == @sizeOf(Header)"; + if ((self.size - @sizeOf(Header)) % @sizeOf(vsr.BlockRequest) != 0) { + return "size multiple invalid"; + } + if (self.release.value != 0) return "release != 0"; + if (!stdx.zeroed(&self.reserved)) return "reserved != 0"; + return null; + } + }; + + pub const Block = extern struct { + pub const metadata_size = 96; + + checksum: u128 = 0, + checksum_padding: u128 = 0, + checksum_body: u128 = 0, + checksum_body_padding: u128 = 0, + nonce_reserved: u128 = 0, + cluster: u128, + size: u32 = @sizeOf(Header), + epoch: u32 = 0, + view: u32 = 0, // Always 0. + /// The release that generated this block. + release: vsr.Release, + protocol: u16 = vsr.Version, + command: Command, + replica: u8 = 0, // Always 0. + reserved_frame: [12]u8 = @splat(0), + + // Schema is determined by `block_type`. + metadata_bytes: [metadata_size]u8, + + // Fields shared by all block types: + address: u64, + snapshot: u64, + block_type: schema.BlockType, + reserved_block: [15]u8 = @splat(0), + + pub const frame = HeaderFunctionsType(@This()).frame; + pub const frame_const = HeaderFunctionsType(@This()).frame_const; + pub const invalid = HeaderFunctionsType(@This()).invalid; + pub const calculate_checksum = HeaderFunctionsType(@This()).calculate_checksum; + pub const calculate_checksum_body = HeaderFunctionsType(@This()).calculate_checksum_body; + pub const set_checksum = HeaderFunctionsType(@This()).set_checksum; + pub const set_checksum_body = HeaderFunctionsType(@This()).set_checksum_body; + pub const valid_checksum = HeaderFunctionsType(@This()).valid_checksum; + pub const valid_checksum_body = HeaderFunctionsType(@This()).valid_checksum_body; + pub const format = HeaderFunctionsType(@This()).format; + + fn invalid_header(self: *const @This()) ?[]const u8 { + assert(self.command == .block); + if (self.size > constants.block_size) return "size > block_size"; + if (self.size == @sizeOf(Header)) return "size = @sizeOf(Header)"; + if (self.view != 0) return "view != 0"; + if (self.release.value == 0) return "release == 0"; + if (self.replica != 0) return "replica != 0"; + if (self.address == 0) return "address == 0"; // address ≠ 0 + if (!self.block_type.valid()) return "block_type invalid"; + if (self.block_type == .reserved) return "block_type == .reserved"; + // TODO When manifest blocks include a snapshot, verify that snapshot≠0. + return null; + } + }; +}; + +/// Messages are printed fairly frequently, so we provide a custom formatting function: +/// - checksums are printed in hex, +/// - padding and reserved fields are skipped if they are zeroed-out. +fn format_header(T: type, header: *const T, writer: anytype) !void { + const simple_type_name = comptime name_blk: { + const type_name = @typeName(T); + const last_part_idx = std.mem.lastIndexOf(u8, type_name, "."); + break :name_blk if (last_part_idx) |idx| type_name[idx + 1 ..] else type_name; + }; + + try writer.writeAll(simple_type_name ++ "{"); + inline for (@typeInfo(T).@"struct".fields, 0..) |field, field_index| { + comptime assert((field_index == 0) == std.mem.eql(u8, field.name, "checksum")); + try format_header_field(field.name, field.type, &@field(header, field.name), writer); + } + try writer.writeAll(" }"); +} + +fn format_header_field( + comptime field_name: []const u8, + comptime T: type, + field_value: *const T, + writer: anytype, +) !void { + if (format_header_field_skip(field_name, T, field_value)) return; + + const separator = comptime if (std.mem.eql(u8, field_name, "checksum")) " " else ", "; + try writer.writeAll(separator ++ "." ++ field_name ++ "="); + + if (T == u128) { + // Exhaustively list all checksum and non-checksum fields. + inline for (.{ + "checksum", "checksum_padding", + "checksum_body", "checksum_body_padding", + "prepare_checksum", "prepare_checksum_padding", + "commit_checksum", "commit_checksum_padding", + "request_checksum", "request_checksum_padding", + "reply_checksum", "reply_checksum_padding", + "parent", "parent_padding", + "context", "context_padding", + "checkpoint_id", + }) |field_name_checksum| { + if (comptime std.mem.eql(u8, field_name, field_name_checksum)) { + return try writer.print("{x:0>32}", .{field_value.*}); + } + } + inline for (.{ + "cluster", "client", + "present_bitset", "nack_bitset", + "nonce", "nonce_reserved", + "reply_client", + }) |field_name_non_checksum| { + if (comptime std.mem.eql(u8, field_name, field_name_non_checksum)) { + return try writer.print("{d}", .{field_value.*}); + } + } + @compileError("unhandled field: " ++ field_name); + } + + try writer.print("{any}", .{field_value.*}); +} + +fn format_header_field_skip( + comptime field_name: []const u8, + comptime T: type, + field_value: *const T, +) bool { + if (comptime std.mem.startsWith(u8, field_name, "reserved") or + std.mem.endsWith(u8, field_name, "reserved") or + std.mem.endsWith(u8, field_name, "padding")) + { + return if (@typeInfo(T) == .int) field_value.* == 0 else stdx.zeroed(field_value); + } else { + return false; + } +} + +// Verify each Command's header type. +comptime { + @setEvalBranchQuota(20_000); + + for (std.enums.values(Command)) |command| { + const CommandHeader = Header.Type(command); + assert(@sizeOf(CommandHeader) == @sizeOf(Header)); + assert(@alignOf(CommandHeader) == @alignOf(Header)); + assert(@typeInfo(CommandHeader) == .@"struct"); + assert(@typeInfo(CommandHeader).@"struct".layout == .@"extern"); + assert(stdx.no_padding(CommandHeader)); + + // Verify that the command's header's frame is identical to Header's. + for (std.meta.fields(Header)) |header_field| { + if (std.mem.eql(u8, header_field.name, "reserved_command")) { + assert(std.meta.fieldIndex(CommandHeader, header_field.name) == null); + } else { + const command_field_index = std.meta.fieldIndex(CommandHeader, header_field.name).?; + const command_field = std.meta.fields(CommandHeader)[command_field_index]; + assert(command_field.type == header_field.type); + assert(command_field.alignment == header_field.alignment); + assert(@offsetOf(CommandHeader, command_field.name) == + @offsetOf(Header, header_field.name)); + } + } + + // Verify that the command's header's re-exports all Header's functions. + const HeaderFunctions = Header.HeaderFunctionsType(CommandHeader); + for (@typeInfo(HeaderFunctions).@"struct".decls) |decl| { + assert(@hasDecl(CommandHeader, decl.name)); + + const a = @field(CommandHeader, decl.name); + const b = @field(HeaderFunctions, decl.name); + assert(a == b); + } + } +} + +const Snap = stdx.Snap; +const module_path = "src"; +const snap = Snap.snap_fn(module_path); + +test format_header { + var prepare = Header.Prepare{ + .checksum = 0x0123456789ABCDEF, + .checksum_body = 0xFEDCBA9876543210, + .cluster = 1, + .size = 321, + .view = 2, + .release = vsr.Release.zero, + .command = .prepare, + .replica = 3, + .parent = 0xABCDEFFEDCBA00123456789, + .request_checksum = 0x12345678987654321, + .checkpoint_id = 4, + .client = 5, + .op = 5, + .commit = 6, + .timestamp = 123456789, + .request = 7, + .operation = .pulse, + }; + + try snap(@src(), + \\Prepare{ .checksum=00000000000000000123456789abcdef, .checksum_body=0000000000000000fedcba9876543210, .cluster=1, .size=321, .epoch=0, .view=2, .release=0.0.0, .protocol=0, .command=vsr.Command.prepare, .replica=3, .parent=000000000abcdeffedcba00123456789, .request_checksum=00000000000000012345678987654321, .checkpoint_id=00000000000000000000000000000004, .client=5, .op=5, .commit=6, .timestamp=123456789, .request=7, .operation=vsr.Operation.pulse } + ).diff_fmt("{}", .{prepare}); + + // Check that non-zero padding/reserved fields are printed. + prepare.checksum_padding = 1; + prepare.reserved_frame[0] = 2; + prepare.reserved[0] = 3; + try snap(@src(), + \\Prepare{ .checksum=00000000000000000123456789abcdef, .checksum_padding=00000000000000000000000000000001, .checksum_body=0000000000000000fedcba9876543210, .cluster=1, .size=321, .epoch=0, .view=2, .release=0.0.0, .protocol=0, .command=vsr.Command.prepare, .replica=3, .reserved_frame={ 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, .parent=000000000abcdeffedcba00123456789, .request_checksum=00000000000000012345678987654321, .checkpoint_id=00000000000000000000000000000004, .client=5, .op=5, .commit=6, .timestamp=123456789, .request=7, .operation=vsr.Operation.pulse, .reserved={ 3, 0, 0 } } + ).diff_fmt("{}", .{prepare}); +} diff --git a/ocam/src/vsr/multi_batch.zig b/ocam/src/vsr/multi_batch.zig new file mode 100644 index 00000000..f2d071e1 --- /dev/null +++ b/ocam/src/vsr/multi_batch.zig @@ -0,0 +1,720 @@ +///! Multi-batching consists of the application submitting multiple independent units of work of +///! the same operation (the batch payload) within a single VSR message. +///! This amortizes network and consensus costs, improving performance in scenarios where highly +///! concurrent user requests submit operations containing only a few events each, sharing the same +///! physical request. +///! +///! - Multi-batched requests use a portion at the end of the message body (the trailer) to +///! encode batch metadata, so multi-batched requests can hold fewer events than regular ones. +///! +///! - The trailer size is always a multiple of the operation's `Event`/`Result` size to keep +///! system invariants. +///! +///! - The batch trailer is an array of `u16` values representing the number of events in each +///! batch, plus a "postamble" containing the total number of batches encoded. +///! +///! - The trailer has variable length, depending on the number of batches (one `u16` per batch, +///! in multiples of the operation's `Event`/`Result` size). +///! +///! - The trailer is written from the end of the message towards the beginning. The last element +///! of the array corresponds to the number of events in the first batch. +///! +///! - Unused elements in the trailer, required for padding, are filled with `maxInt(u16)`. +///! +///! Example: Multi-batch request containing 4 batches, with each event being 128 bytes. +///! +///! size message.body_used().len == 1792 bytes +///! 2048 bytes payload == 1664 bytes trailer == 128 bytes +///! ┌──────┐┌───────────────────────────────────────────┐┌────────────────────────┐ +///! │ VSR ││┌──────────┐┌─────────┐┌───────┐┌─────────┐││┌───────┐┌─┐┌─┐┌─┐┌─┐┌─┐│ +///! │Header│││1024 bytes││128 bytes││0 bytes││512 bytes││││padding││4││0││1││8││4││ +///! │ ││└───▲──────┘└──▲──────┘└▲──────┘└──▲──────┘││└───────┘└┬┘└┬┘└┬┘└┬┘└┬┘│ +///! └──────┘└────┼──────────┼────────┼──────────┼───────┘└──────────┼──┼──┼──┼──┼─┘ +///! │ │ │ │ │ │ │ │ └ postamble +///! │ │ │ └───────────────────┘ │ │ │ batch_count == 4 +///! │ │ │ │ │ │ +///! │ │ └─────────────────────────────────┘ │ │ +///! │ │ │ │ +///! │ └─────────────────────────────────────────────┘ │ +///! │ │ +///! └───────────────────────────────────────────────────────────┘ +///! +const std = @import("std"); +const testing = std.testing; + +const stdx = @import("stdx"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const MiB = stdx.MiB; + +const constants = @import("../constants.zig"); +const vsr = @import("../vsr.zig"); + +const Postamble = packed struct(u16) { + /// `maxInt(u16)` is reserved for padding. + const batch_count_max = std.math.maxInt(u16) - 1; + + /// The number of batches in the message body. + batch_count: u16, + comptime { + assert(@sizeOf(Postamble) == @sizeOf(TrailerItem)); + assert(@alignOf(Postamble) == @alignOf(TrailerItem)); + } +}; + +const TrailerItem = packed struct(u16) { + const padding: TrailerItem = .{ .element_count = std.math.maxInt(u16) }; + + /// The number of elements in each batch, either `Event` or `Result`. + element_count: u16, + comptime { + assert(@sizeOf(TrailerItem) == @sizeOf(Postamble)); + assert(@alignOf(TrailerItem) == @alignOf(Postamble)); + } +}; + +/// The maximum number of batches that can be encoded, assuming the worst case single-element +/// batches with the minimum size (maybe empty). +pub fn multi_batch_count_max(options: struct { + /// The minimum size of a single batch. + /// May be zero if the operation allows zeroed batches. + batch_size_min: u32, + /// The maximum size of the message body, including the multi-batch trailer. + batch_size_limit: u32, +}) u16 { + assert(options.batch_size_limit > @sizeOf(Postamble)); + maybe(options.batch_size_min == 0); + return @intCast(@min( + @divFloor( + options.batch_size_limit - @sizeOf(Postamble), + options.batch_size_min + @sizeOf(TrailerItem), + ), + Postamble.batch_count_max, + )); +} + +/// The trailer is an array of `TrailerItem`, each containing the number of elements +/// in a batch, followed by a `Postamble` that holds the total number of batches. +/// Encoding the trailer requires `(batch_count * @sizeOf(TrailerItem)) + @sizeOf(Postamble)` +/// bytes, but the total space occupied may be larger as padding bytes might be required for +/// alignment with the operation's element size. +pub fn trailer_total_size(options: struct { + element_size: u32, + batch_count: u16, +}) u32 { + assert(options.batch_count > 0); + assert(options.batch_count <= Postamble.batch_count_max); + // Supports zero-sized elements, or any power of two, including 2^0. + assert(options.element_size == 0 or std.math.isPowerOfTwo(options.element_size)); + + const trailer_unpadded_size: u32 = + (@as(u32, options.batch_count) * @sizeOf(TrailerItem)) + @sizeOf(Postamble); + if (options.element_size == 0) return trailer_unpadded_size; + + return stdx.div_ceil( + trailer_unpadded_size, + options.element_size, + ) * options.element_size; +} + +pub const MultiBatchDecoder = struct { + pub const Error = error{MultiBatchInvalid}; + + pub const Options = struct { + element_size: u32, + }; + + /// The message payload, excluding the trailer. + payload: []align(constants.cache_line_size) const u8, + /// The batching metadata, excluding the postamble. + trailer_items: []const TrailerItem, + + payload_index: u32, + batch_index: u16, + + options: Options, + + pub fn init( + /// The message body used, including the trailer. + body: []align(constants.cache_line_size) const u8, + options: Options, + ) Error!MultiBatchDecoder { + // Supports zero-sized elements, or any power of two, including 2^0. + assert(options.element_size == 0 or std.math.isPowerOfTwo(options.element_size)); + + const Parser = struct { + buffer: []align(constants.cache_line_size) const u8, + buffer_parsed: u32 = 0, + + fn parse_suffix(parser: *@This(), comptime T: type, count: u32) Error![]const T { + assert(count <= Postamble.batch_count_max); + + const suffix_size = count * @sizeOf(T); + if (parser.buffer.len < suffix_size) return error.MultiBatchInvalid; + + const suffix = parser.buffer[parser.buffer.len - suffix_size ..]; + const suffix_aligned = std.mem.isAligned(@intFromPtr(suffix.ptr), @alignOf(T)); + if (!suffix_aligned) return error.MultiBatchInvalid; + + parser.buffer = parser.buffer[0 .. parser.buffer.len - suffix_size]; + parser.buffer_parsed += suffix_size; + return stdx.bytes_as_slice(.exact, T, suffix); + } + }; + + var parser = Parser{ .buffer = body }; + const postamble: *const Postamble = postamble: { + const slice = try parser.parse_suffix(Postamble, 1); + break :postamble &slice[0]; + }; + if (postamble.batch_count == 0) return error.MultiBatchInvalid; + if (postamble.batch_count > Postamble.batch_count_max) return error.MultiBatchInvalid; + + const trailer_size = trailer_total_size(.{ + .element_size = options.element_size, + .batch_count = postamble.batch_count, + }); + + const trailer_items_used = try parser.parse_suffix(TrailerItem, postamble.batch_count); + // The trailer size is a multiple of the element size. + // Unused elements are filled with `maxInt` for padding. + const trailer_items_padding = try parser.parse_suffix( + u8, + trailer_size - parser.buffer_parsed, + ); + if (!std.mem.allEqual(u8, trailer_items_padding, std.math.maxInt(u8))) { + return error.MultiBatchInvalid; + } + + const events_count_total: u32 = count: { + var count: u32 = 0; + for (trailer_items_used) |trailer_item| { + count += trailer_item.element_count; + } + break :count count; + }; + if (options.element_size == 0 and events_count_total != 0) return error.MultiBatchInvalid; + const payload_size: u32 = std.math.mul( + u32, + events_count_total, + options.element_size, + ) catch |err| switch (err) { + error.Overflow => return error.MultiBatchInvalid, + }; + + // For byte-aligned elements, padding may be required between the payload and the trailer. + const trailer_padding_size: u32 = @intCast(payload_size % @sizeOf(TrailerItem)); + assert(trailer_padding_size < @sizeOf(TrailerItem)); + assert(trailer_padding_size == 0 or options.element_size == 1); + const trailer_padding = try parser.parse_suffix(u8, trailer_padding_size); + if (!std.mem.allEqual(u8, trailer_padding, std.math.maxInt(u8))) { + return error.MultiBatchInvalid; + } + + if (payload_size != body.len - parser.buffer_parsed) return error.MultiBatchInvalid; + assert(payload_size == parser.buffer.len); + + return .{ + .payload = parser.buffer, + .trailer_items = trailer_items_used, + .payload_index = 0, + .batch_index = 0, + .options = options, + }; + } + + pub fn reset( + self: *MultiBatchDecoder, + ) void { + self.* = .{ + .payload = self.payload, + .trailer_items = self.trailer_items, + .batch_index = 0, + .payload_index = 0, + .options = self.options, + }; + } + + pub fn batch_count(self: *const MultiBatchDecoder) u16 { + assert(self.trailer_items.len <= Postamble.batch_count_max); + return @intCast(self.trailer_items.len); + } + + pub fn pop(self: *MultiBatchDecoder) ?[]const u8 { + assert(self.trailer_items.len > 0); + maybe(self.payload.len == 0); + + if (self.batch_index == self.trailer_items.len) { + assert(self.payload_index == self.payload.len); + return null; + } + const batch_item: []const u8 = self.peek(); + self.batch_index += 1; + self.payload_index += @intCast(batch_item.len); + assert(self.batch_index <= self.trailer_items.len); + assert(self.payload_index <= self.payload.len); + return batch_item; + } + + pub fn peek(self: *const MultiBatchDecoder) []const u8 { + assert(self.trailer_items.len > 0); + assert(self.batch_index < self.trailer_items.len); + assert(self.payload_index <= self.payload.len); + maybe(self.payload.len == 0); + + // Batch metadata is written from the end of the message, so the last + // element corresponds to the first batch. + const trailer_item: *const TrailerItem = + &self.trailer_items[self.trailer_items.len - self.batch_index - 1]; + if (trailer_item.element_count == 0) { + assert(self.payload_index <= self.payload.len); + return &.{}; + } else { + assert(self.payload_index < self.payload.len); + } + + const batch_size = trailer_item.element_count * self.options.element_size; + assert(self.payload_index + batch_size <= self.payload.len); + + const slice: []const u8 = self.payload[self.payload_index..][0..batch_size]; + assert(slice.len > 0); + assert(slice.len % self.options.element_size == 0); + return slice; + } +}; + +pub const MultiBatchEncoder = struct { + const Options = struct { + element_size: u32, + }; + + buffer: ?[]align(constants.cache_line_size) u8, + batch_count: u16, + buffer_index: u32, + options: Options, + + pub fn init( + buffer: []align(constants.cache_line_size) u8, + options: Options, + ) MultiBatchEncoder { + // Supports zero-sized elements, or any power of two, including 2^0. + assert(options.element_size == 0 or std.math.isPowerOfTwo(options.element_size)); + + // The buffer must be large enough for at least one batch. + const trailer_size_min = trailer_total_size(.{ + .batch_count = 1, + .element_size = options.element_size, + }); + assert(buffer.len >= trailer_size_min); + + // The buffer size might not be a multiple of the element size. + // Only the final encoded message after calling `finish()` will be. + maybe(options.element_size > 0 and + buffer.len % options.element_size != 0); + + // The end of the buffer must be aligned with the trailer. + // If it isn't, reduce the buffer to maintain alignment. + const aligned_len = std.mem.alignBackward( + usize, + buffer.len, + @sizeOf(TrailerItem), + ); + + return .{ + .buffer = buffer[0..aligned_len], + .batch_count = 0, + .buffer_index = 0, + .options = options, + }; + } + + pub fn reset(self: *MultiBatchEncoder) void { + assert(self.buffer != null); + self.* = .{ + .buffer = self.buffer, + .batch_count = 0, + .buffer_index = 0, + .options = self.options, + }; + } + + /// Returns a writable slice aligned and sized appropriately for the current operation. + /// May return `null` if there isn't enough space in the buffer to add a new element + /// to the trailer. + /// The returned slice may have a length of zero if the remaining buffer + /// isn't large enough to hold at least one element of the current operation. + pub fn writable(self: *const MultiBatchEncoder) ?[]u8 { + if (self.batch_count == Postamble.batch_count_max) return null; + assert(self.batch_count < Postamble.batch_count_max); + maybe(self.batch_count == 0); + + assert(self.options.element_size > 0 or self.buffer_index == 0); + assert(self.options.element_size == 0 or + self.buffer_index % self.options.element_size == 0); + + // Takes into account extra trailer bytes that will need to be included. + const trailer_size: usize = trailer_total_size(.{ + .batch_count = self.batch_count + 1, + .element_size = self.options.element_size, + }); + + const buffer: []u8 = self.buffer.?; + if (buffer.len < self.buffer_index + trailer_size) { + // Insufficient space for one more batch. + return null; + } + + if (self.options.element_size == 0) { + // No writable buffer for zero-size elements, as they only add to the trailer. + return &.{}; + } + + // Get an aligned slice. + const slice: []u8 = buffer[self.buffer_index .. buffer.len - trailer_size]; + const size: usize = + @divFloor(slice.len, self.options.element_size) * self.options.element_size; + return slice[0..size]; + } + + /// Records how many bytes were written in the slice previously acquired by `writable()`. + pub fn add(self: *MultiBatchEncoder, bytes_written: u32) void { + assert(self.batch_count < Postamble.batch_count_max); + maybe(self.batch_count == 0); + + const written_element_count: u16 = written_element_count: { + if (self.options.element_size == 0) { + assert(self.buffer_index == 0); + assert(bytes_written == 0); + break :written_element_count 0; + } + + const written_element_count: u16 = @intCast(@divExact( + bytes_written, + self.options.element_size, + )); + maybe(written_element_count == 0); + break :written_element_count written_element_count; + }; + + self.batch_count += 1; + self.buffer_index += bytes_written; + + const buffer: []u8 = self.buffer.?; + assert(self.buffer_index < buffer.len); + + const trailer_size = trailer_total_size(.{ + .batch_count = self.batch_count, + .element_size = self.options.element_size, + }); + assert(self.buffer_index + trailer_size <= buffer.len); + + const trailer_items: []TrailerItem = @alignCast(std.mem.bytesAsSlice( + TrailerItem, + buffer[buffer.len - trailer_size .. buffer.len - @sizeOf(Postamble)], + )); + assert(trailer_items.len >= self.batch_count); + + // Batch metadata is stacked from the end of the message, so the first element + // of the array corresponds to the last batch added. + trailer_items[trailer_items.len - self.batch_count] = .{ + .element_count = written_element_count, + }; + } + + /// Finalizes the batch by writing the trailer with proper encoding. + /// Returns the total number of bytes written (payload + trailer). + /// At least one batch must be inserted, and the encoder should not be used after + /// being finished. + pub fn finish(self: *MultiBatchEncoder) u32 { + assert(self.batch_count > 0); + assert(self.batch_count <= Postamble.batch_count_max); + + const buffer: []align(constants.cache_line_size) u8 = self.buffer.?; + assert(buffer.len > self.buffer_index); + assert(self.options.element_size > 0 or self.buffer_index == 0); + maybe(self.buffer_index == 0); + + const trailer_size = trailer_total_size(.{ + .batch_count = self.batch_count, + .element_size = self.options.element_size, + }); + + // For byte-aligned elements, padding may be required between the payload and the trailer. + const padding: u32 = self.buffer_index % @sizeOf(TrailerItem); + assert(padding < @sizeOf(TrailerItem)); + assert(padding == 0 or self.options.element_size == 1); + assert(buffer.len >= self.buffer_index + padding + trailer_size); + // Filling the padding with sentinels. + @memset(buffer[self.buffer_index..][0..padding], std.math.maxInt(u8)); + + // While batches are being encoded, the trailer is written at the end of the buffer. + // Once all batches are encoded, the trailer needs to be moved closer to the last + // element written. + const source: []const u8 = buffer[buffer.len - trailer_size ..]; + const target: []u8 = buffer[self.buffer_index + padding ..][0..trailer_size]; + assert(source.len == target.len); + assert(@intFromPtr(source.ptr) >= @intFromPtr(target.ptr)); + if (source.ptr != target.ptr) { + stdx.copy_left(.exact, u8, target, source); + } + + const trailer_items: []TrailerItem = @alignCast(std.mem.bytesAsSlice( + TrailerItem, + buffer[self.buffer_index + padding ..][0 .. trailer_size - @sizeOf(Postamble)], + )); + // Filling in the extra alignment bytes with sentinels. + @memset( + trailer_items[0 .. trailer_items.len - self.batch_count], + TrailerItem.padding, + ); + + const postamble: *Postamble = @ptrCast(@alignCast( + buffer[self.buffer_index + padding + trailer_size - @sizeOf(Postamble) ..], + )); + postamble.* = .{ + .batch_count = self.batch_count, + }; + + self.buffer = null; + const bytes_written: u32 = self.buffer_index + padding + trailer_size; + assert(self.options.element_size > 0 or bytes_written == trailer_size); + assert(self.options.element_size == 0 or + bytes_written % self.options.element_size == 0); + + if (constants.verify) { + _ = MultiBatchDecoder.init(buffer[0..bytes_written], .{ + .element_size = self.options.element_size, + }) catch |err| switch (err) { + error.MultiBatchInvalid => unreachable, + }; + } + + return bytes_written; + } +}; + +// The maximum number of batches, all with zero elements. +test "batch: maximum batches with no elements" { + var prng = stdx.PRNG.from_seed_testing(); + + const batch_count = Postamble.batch_count_max; + const element_size = 128; + const buffer_size = trailer_total_size(.{ + .element_size = element_size, + .batch_count = batch_count, + }); + + const buffer = try testing.allocator.alignedAlloc( + u8, + constants.cache_line_size, + buffer_size, + ); + defer testing.allocator.free(buffer); + + const written_bytes = try TestRunner.run(.{ + .prng = &prng, + .element_size = element_size, + .buffer = buffer, + .batch_count = batch_count, + .batch_elements = 0, + }); + try testing.expectEqual(buffer_size, written_bytes); +} + +// The maximum number of batches, when each one has one single element. +test "batch: maximum batches with a single element" { + var prng = stdx.PRNG.from_seed_testing(); + + const element_size = 128; + const buffer_size = (1 * MiB) - @sizeOf(vsr.Header); // 1MiB message. + const batch_count_max: u16 = multi_batch_count_max(.{ + .batch_size_min = element_size, + .batch_size_limit = buffer_size, + }); + + const buffer = try testing.allocator.alignedAlloc(u8, constants.cache_line_size, buffer_size); + defer testing.allocator.free(buffer); + + const written_bytes = try TestRunner.run(.{ + .prng = &prng, + .element_size = element_size, + .buffer = buffer, + .batch_count = batch_count_max, + .batch_elements = 1, + }); + + const written_bytes_expected: usize = + std.math.mulWide(u16, batch_count_max, element_size) + + std.math.mulWide(u16, batch_count_max, @sizeOf(TrailerItem)) + + @sizeOf(Postamble); + assert(written_bytes_expected <= buffer_size); + try testing.expectEqual(written_bytes_expected, written_bytes); +} + +// The maximum number of elements on a single batch. +test "batch: maximum elements on a single batch" { + var prng = stdx.PRNG.from_seed_testing(); + + const element_size = 128; + const buffer_size = (1 * MiB) - @sizeOf(vsr.Header); // 1MiB message. + const batch_size_max = 8189; // maximum number of elements in a single-batch request. + assert(batch_size_max == @divExact(buffer_size - element_size, element_size)); + + const buffer = try testing.allocator.alignedAlloc(u8, constants.cache_line_size, buffer_size); + defer testing.allocator.free(buffer); + + const written_bytes = try TestRunner.run(.{ + .prng = &prng, + .element_size = element_size, + .buffer = buffer, + .batch_count = 1, + .batch_elements = batch_size_max, + }); + try testing.expectEqual(buffer_size, written_bytes); +} + +test "batch: invalid format" { + var prng = stdx.PRNG.from_seed_testing(); + + const element_size = 128; + const buffer_size = (1 * MiB) - @sizeOf(vsr.Header); // 1MiB message. + const buffer = try testing.allocator.alignedAlloc(u8, constants.cache_line_size, buffer_size); + defer testing.allocator.free(buffer); + + const batch_count = 10; + const trailer_size = trailer_total_size(.{ + .element_size = element_size, + .batch_count = batch_count, + }); + + var encoder = MultiBatchEncoder.init(buffer, .{ + .element_size = element_size, + }); + var event_total_count: usize = 0; + for (0..batch_count) |_| { + const event_count: u16 = prng.int_inclusive(u16, 100); + const batch_size: u32 = element_size * event_count; + const writable = encoder.writable().?; + try testing.expect(writable.len >= batch_size); + encoder.add(batch_size); + event_total_count += event_count; + } + const bytes_written = encoder.finish(); + + try testing.expect(encoder.batch_count == batch_count); + try testing.expect(bytes_written == (element_size * event_total_count) + trailer_size); + + _ = try MultiBatchDecoder.init( + buffer[0..bytes_written], + .{ .element_size = element_size }, + ); + + try testing.expectError(error.MultiBatchInvalid, MultiBatchDecoder.init( + buffer[0 .. bytes_written - element_size], + .{ .element_size = element_size }, + )); + try testing.expectError(error.MultiBatchInvalid, MultiBatchDecoder.init( + buffer[element_size..bytes_written], + .{ .element_size = element_size }, + )); + try testing.expectError(error.MultiBatchInvalid, MultiBatchDecoder.init( + buffer[0..bytes_written], + .{ .element_size = element_size * 2 }, + )); + try testing.expectError(error.MultiBatchInvalid, MultiBatchDecoder.init( + buffer[0..bytes_written], + .{ .element_size = element_size / 2 }, + )); + + const postamble: *Postamble = @ptrCast(@alignCast( + buffer[bytes_written - @sizeOf(Postamble) ..], + )); + postamble.batch_count = batch_count + 1; + try testing.expectError(error.MultiBatchInvalid, MultiBatchDecoder.init( + buffer[0..bytes_written], + .{ .element_size = element_size }, + )); + postamble.batch_count = batch_count - 1; + try testing.expectError(error.MultiBatchInvalid, MultiBatchDecoder.init( + buffer[0..bytes_written], + .{ .element_size = element_size }, + )); +} + +const TestRunner = struct { + fn run(options: struct { + prng: *stdx.PRNG, + element_size: u32, + buffer: []align(constants.cache_line_size) u8, + batch_count: u16, + batch_elements: ?u16 = null, + }) !usize { + const ratio = stdx.PRNG.ratio; + const BoundedArray = stdx.BoundedArrayType(u16, std.math.maxInt(u16)); + var expected: BoundedArray = .{}; + + const trailer_size = trailer_total_size(.{ + .element_size = options.element_size, + .batch_count = options.batch_count, + }); + + // Cleaning the buffer first, so it can assert the bytes. + options.prng.fill(options.buffer); + + var encoder = MultiBatchEncoder.init(options.buffer, .{ + .element_size = options.element_size, + }); + for (0..options.batch_count) |index| { + const bytes_available = options.buffer.len - encoder.buffer_index - trailer_size; + + const elements_count: u16 = if (options.batch_elements) |batch_elements| + batch_elements + else random: { + if (index == options.batch_count - 1) { + const batch_full = options.prng.chance(ratio(30, 100)); + if (batch_full) { + break :random @intCast(@divFloor(bytes_available, options.element_size)); + } + } + + const batch_empty = options.prng.chance(ratio(30, 100)); + if (batch_empty) break :random 0; + + break :random @intCast(@divFloor( + options.prng.int_inclusive(usize, bytes_available), + options.element_size, + )); + }; + + const slice = encoder.writable().?; + const bytes_written = elements_count * options.element_size; + assert(slice.len >= bytes_written); + try testing.expect(slice.len >= bytes_written); + @memset(std.mem.bytesAsSlice(u16, slice[0..bytes_written]), @intCast(index)); + encoder.add(bytes_written); + + expected.push(elements_count); + } + const bytes_written = encoder.finish(); + try testing.expect(encoder.batch_count == options.batch_count); + + var decoder = MultiBatchDecoder.init( + options.buffer[0..bytes_written], + .{ .element_size = options.element_size }, + ) catch unreachable; + assert(decoder.batch_count() == encoder.batch_count); + var batch_read_index: usize = 0; + while (decoder.pop()) |batch| : (batch_read_index += 1) { + const event_count = @divExact(batch.len, options.element_size); + try testing.expect(expected.slice()[batch_read_index] == event_count); + try testing.expect(std.mem.allEqual( + u16, + @alignCast(std.mem.bytesAsSlice(u16, batch)), + @intCast(batch_read_index), + )); + } + try testing.expect(options.batch_count == batch_read_index); + + return bytes_written; + } +}; diff --git a/ocam/src/vsr/multi_batch_fuzz.zig b/ocam/src/vsr/multi_batch_fuzz.zig new file mode 100644 index 00000000..1c29bbce --- /dev/null +++ b/ocam/src/vsr/multi_batch_fuzz.zig @@ -0,0 +1,185 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const vsr = @import("../vsr.zig"); +const constants = vsr.constants; +const MultiBatchDecoder = vsr.multi_batch.MultiBatchDecoder; +const MultiBatchEncoder = vsr.multi_batch.MultiBatchEncoder; +const stdx = @import("stdx"); +const MiB = stdx.MiB; +const fuzz = @import("../testing/fuzz.zig"); + +pub fn main(gpa: std.mem.Allocator, args: fuzz.FuzzArgs) !void { + var prng = stdx.PRNG.from_seed(args.seed); + const message_body_size_min = constants.sector_size - @sizeOf(vsr.Header); + const message_body_size_max = (1 * MiB) - @sizeOf(vsr.Header); + const buffer_expected = try gpa.alignedAlloc( + u8, + constants.cache_line_size, + message_body_size_max, + ); + defer gpa.free(buffer_expected); + + const buffer_actual = try gpa.alignedAlloc( + u8, + constants.cache_line_size, + message_body_size_max, + ); + defer gpa.free(buffer_actual); + + const events_max = args.events_max orelse 1024; + for (0..events_max) |_| { + const buffer_size: usize = prng.range_inclusive( + usize, + message_body_size_min, + message_body_size_max, + ); + try run_fuzz(.{ + .prng = &prng, + .buffer_expected = buffer_expected[0..buffer_size], + .buffer_actual = buffer_actual[0..buffer_size], + }); + } +} + +fn run_fuzz(options: struct { + prng: *stdx.PRNG, + buffer_expected: []align(constants.cache_line_size) u8, + buffer_actual: []align(constants.cache_line_size) u8, +}) !void { + assert(options.buffer_expected.len == options.buffer_actual.len); + // The end of the buffer must be aligned with the postamble. + const postamble_alignment = options.buffer_expected.len % @sizeOf(u16); + assert(postamble_alignment < @sizeOf(u16)); + const buffer_expected: []align(constants.cache_line_size) u8 = + options.buffer_expected[0 .. options.buffer_expected.len - postamble_alignment]; + const buffer_actual: []align(constants.cache_line_size) u8 = + options.buffer_actual[0 .. options.buffer_actual.len - postamble_alignment]; + + // Generate the batch plan with element sizes from 2^0 to 2^8. + const batch_element_size: u32 = std.math.pow(u32, 2, options.prng.int_inclusive(u32, 8)); + var batches = stdx.BoundedArrayType(u32, 8190){}; + const batch_count_max = options.prng.range_inclusive( + usize, + 1, + batches.capacity(), + ); + + // Encoder will ignore and overwrite any existing content in the target buffer, + // at the end, both buffers must be equal. + options.prng.fill(buffer_expected); + options.prng.fill(buffer_actual); + + // Encode. + var encoder = MultiBatchEncoder.init(buffer_actual, .{ + .element_size = batch_element_size, + }); + var expect_payload_size: u32 = 0; + var expect_trailer_size: u32 = 0; + for (0..batch_count_max) |_| { + assert(expect_payload_size + expect_trailer_size <= buffer_expected.len); + + const trailer_size_next = vsr.multi_batch.trailer_total_size(.{ + .batch_count = @intCast(batches.count() + 1), + .element_size = batch_element_size, + }); + const expect_padding: u32 = expect_payload_size % @sizeOf(u16); + assert(expect_padding < @sizeOf(u16)); + if (buffer_expected.len < expect_payload_size + expect_padding + trailer_size_next) { + assert(batches.count() > 0); + assert(encoder.writable() == null); + break; + } + + const batch_element_count_max: u32 = @intCast(@divFloor( + buffer_expected.len - + (expect_payload_size + expect_padding + trailer_size_next), + batch_element_size, + )); + const batch_element_count: u32 = if (batch_element_count_max > 0) + switch (options.prng.enum_uniform(enum { zero, one, random })) { + .zero => 0, + .one => 1, + .random => options.prng.range_inclusive(u32, 1, @min( + std.math.maxInt(u16), // Cannot encode more than `u16` elements. + batch_element_count_max, + )), + } + else + 0; + const batch_size: u32 = batch_element_count * batch_element_size; + const writable: []u8 = encoder.writable().?; + assert(batch_size <= writable.len); + + const source: []u8 = buffer_expected[expect_payload_size..][0..batch_size]; + const target: []u8 = writable[0..batch_size]; + stdx.copy_disjoint(.exact, u8, target, source); + + encoder.add(batch_size); + expect_payload_size += batch_size; + batches.push(batch_element_count); + expect_trailer_size = trailer_size_next; + } + assert(batches.count() > 0); + assert(batches.count() == encoder.batch_count); + assert(expect_payload_size + expect_trailer_size <= buffer_expected.len); + assert(expect_payload_size == encoder.buffer_index); + + const expect_padding: u32 = expect_payload_size % @sizeOf(u16); + assert(expect_padding < @sizeOf(u16)); + + const encoder_bytes_written = encoder.finish(); + assert(encoder_bytes_written > 0); + assert(encoder_bytes_written == + expect_payload_size + expect_padding + expect_trailer_size); + + { + // Decode. + var decoder = try MultiBatchDecoder.init( + buffer_actual[0..encoder_bytes_written], + .{ .element_size = batch_element_size }, + ); + assert(expect_payload_size == decoder.payload.len); + + var payloads_decoded: u32 = 0; + for (batches.const_slice()) |batch_element_count| { + const batch_size: u32 = batch_element_count * batch_element_size; + const expect_batch: []u8 = buffer_expected[payloads_decoded..][0..batch_size]; + payloads_decoded += batch_size; + + const decoded_batch = decoder.pop().?; + assert(batch_size == decoded_batch.len); + assert(std.mem.eql(u8, expect_batch, decoded_batch)); + } + assert(payloads_decoded == decoder.payload.len); + assert(decoder.pop() == null); + } + + // Verify that any flipped bit mutates the results or causes a decoding error + // (but never a panic). + for (0..32) |_| { + const byte_index = options.prng.int_inclusive(usize, encoder_bytes_written - 1); + for (0..@bitSizeOf(u8)) |bit_index| { + buffer_actual[byte_index] ^= @as(u8, 1) << @as(u3, @intCast(bit_index)); + defer buffer_actual[byte_index] ^= @as(u8, 1) << @as(u3, @intCast(bit_index)); + + var decoder = MultiBatchDecoder.init( + buffer_actual[0..encoder_bytes_written], + .{ .element_size = batch_element_size }, + ) catch continue; + + var same: bool = true; + var payloads_decoded: u32 = 0; + for (batches.const_slice()) |batch_element_count| { + const batch_size: u32 = batch_element_count * batch_element_size; + const expect_batch: []u8 = buffer_expected[payloads_decoded..][0..batch_size]; + payloads_decoded += batch_size; + + const decoded_batch = decoder.pop().?; + same = same and std.mem.eql(u8, expect_batch, decoded_batch); + } + assert(decoder.pop() == null); + assert(!same); + } + } +} diff --git a/ocam/src/vsr/repair_budget.zig b/ocam/src/vsr/repair_budget.zig new file mode 100644 index 00000000..ad265c5c --- /dev/null +++ b/ocam/src/vsr/repair_budget.zig @@ -0,0 +1,464 @@ +const std = @import("std"); +const assert = std.debug.assert; +const constants = @import("../constants.zig"); + +const vsr = @import("../vsr.zig"); +const stdx = @import("stdx"); +const maybe = stdx.maybe; + +const ratio = stdx.PRNG.ratio; +const Ratio = stdx.PRNG.Ratio; + +pub const RepairBudgetJournal = struct { + capacity: u32, + available: u32, + + replica_index: u8, + + // Tracks the prepare ops requested from each remote replica. + replicas_requested_prepares: []RequestedPrepares, + + // Exponential weighted moving average of the repair latency for each remote replica. + // + // Repair latency is calculated as the duration elapsed between when a prepare is requested from + // a remote replica, and when it is either received from the remote replica (see `decrement`), + // or expired (see `reap_expired_requests`). + replicas_repair_latency: []stdx.Duration, + + // Probability of choosing a random replica with available budget, as opposed to one with the + // best repair latency with available budget. + // + // Experiments ensure that we try alternative repair routes, and avoids potential resonance + // wherein we keep requesting from a permanently crashed replica with the best repair latency. + // This is because we don't penalize the repair latency once it exceeds `duration_expiry_max`, + // so if a crashed replica has the best latency, it may remain that way forever. + experiment_chance: Ratio = ratio(1, 10), + + // Multiple of repair latency used to determine expiry duration, which is the time we wait + // before restoring the budget for an inflight repair request if the prepare has not arrived. + repair_latency_multiple_expiry: u8 = 2, + + // The maximum amount of time we wait before reclaiming the budget for an inflight repair + // request if the prepare has not arrived. + // + // Capped at 500ms to avoid an unbounded increase in the tracked repair latency for remote + // replicas. Specifically, helps avoid the case where a partitioned replica with missing + // prepares gets into a cycle of requesting prepares, waiting for them to expire, and then + // increasing the repair latency on expiry. + duration_expiry_max: stdx.Duration = .ms(500), + + // Maximum inflight `get_prepare` messages per remote replica, at any point of time. + // + // This is kept small to ensure that even if the budget to a remote replica is saturated + // by multiple replicas, overflowing the egress `send_queue` (which leads to dropped messages) + // on the remote replica is unlikely. For example, since the `send_queue` is currently sized + // to 4 messages, if we were to set this limit to 4 as well, multiple repairing replicas are + // more likely to overflow the remote replica's send queue. + const repair_messages_inflight_count_max = 2; + + const RequestedPrepares = std.AutoArrayHashMapUnmanaged(u64, stdx.Instant); + + pub fn init(gpa: std.mem.Allocator, options: struct { + replica_index: u8, + replica_count: u8, + }) !RepairBudgetJournal { + // Replicas can repair from all replicas but themselves, + // while standbys can repair from all replicas. + const remote_replica_count = + options.replica_count - @intFromBool(options.replica_index < options.replica_count); + + var replicas_requested_prepares = try gpa.alloc(RequestedPrepares, options.replica_count); + errdefer gpa.free(replicas_requested_prepares); + + for (replicas_requested_prepares, 0..) |*requested_prepares, replica| { + errdefer for (replicas_requested_prepares[0..replica]) |*m| m.deinit(gpa); + requested_prepares.* = .{}; + + try requested_prepares.ensureTotalCapacity(gpa, repair_messages_inflight_count_max); + errdefer requested_prepares.deinit(gpa); + } + + errdefer for (replicas_requested_prepares) |*m| m.deinit(gpa); + + const replicas_repair_latency = try gpa.alloc(stdx.Duration, options.replica_count); + errdefer gpa.free(replicas_repair_latency); + + // Initialize repair latency to 1 ms for all replicas, this gets refined as we start + // repairing from these replicas. We choose a value lower than the the typical latency + // between two replicas, so as to not bias replica selection when we have few measurements. + @memset(replicas_repair_latency, .ms(1)); + + return RepairBudgetJournal{ + .capacity = repair_messages_inflight_count_max * remote_replica_count, + .available = repair_messages_inflight_count_max * remote_replica_count, + .replica_index = options.replica_index, + .replicas_requested_prepares = replicas_requested_prepares, + .replicas_repair_latency = replicas_repair_latency, + }; + } + + pub fn deinit(budget: *RepairBudgetJournal, gpa: std.mem.Allocator) void { + for (budget.replicas_requested_prepares) |*requested_prepares| { + requested_prepares.deinit(gpa); + } + gpa.free(budget.replicas_requested_prepares); + gpa.free(budget.replicas_repair_latency); + } + + /// Returns the index of the replica with the lowest repair latency, and budget availability, if + /// one exists. Otherwise, returns null. For a fraction of ops (guided by `experiment_chance`), + /// diverges from this heuristic and returns the index of a random replica with budget + /// availability, using reservoir sampling. + pub fn decrement( + budget: *RepairBudgetJournal, + op: u64, + now: stdx.Instant, + prng: *stdx.PRNG, + ) ?u8 { + assert(budget.capacity > 0); + maybe(budget.available == 0); + + budget.assert_invariants(); + defer budget.assert_invariants(); + + const experiment = prng.chance(budget.experiment_chance); + var experiment_replica_index: ?u8 = null; + var reservoir = stdx.PRNG.Reservoir.init(); + + var repair_latency_min: ?stdx.Duration = null; + var repair_latency_min_replica_index: ?u8 = null; + + for (budget.replicas_requested_prepares, 0..) |*requested_prepares, replica_index| { + // Disallow requests to self. + if (replica_index == budget.replica_index) continue; + // Enforce per-replica budget. + if (requested_prepares.count() == repair_messages_inflight_count_max) continue; + // Disallow requesting from a replica from which this op has already been requested. + if (requested_prepares.get(op) != null) continue; + + const replica_repair_latency = budget.replicas_repair_latency[replica_index]; + + if (repair_latency_min == null or replica_repair_latency.ns < repair_latency_min.?.ns) { + repair_latency_min = replica_repair_latency; + repair_latency_min_replica_index = @intCast(replica_index); + } + + // Reservoir sampling with an arbitrarily chosen weight of 1 for each item suffices + // our use case, as the goal is to get some degree of randomness during experiments. + if (reservoir.replace(prng, 1)) { + experiment_replica_index = @intCast(replica_index); + } + } + assert((repair_latency_min == null) == (repair_latency_min_replica_index == null)); + assert((repair_latency_min_replica_index == null) == (experiment_replica_index == null)); + + const replica_index_maybe = if (experiment) + experiment_replica_index + else + repair_latency_min_replica_index; + + if (replica_index_maybe) |replica_index| { + assert(replica_index != budget.replica_index); + budget.replicas_requested_prepares[replica_index].putAssumeCapacityNoClobber(op, now); + budget.available -= 1; + } + + return replica_index_maybe; + } + + /// Increments the budget by 1 for each replica that this prepare op has been requested from. + /// Also refines the repair latency for each of these replicas. + pub fn increment(budget: *RepairBudgetJournal, op: u64, now: stdx.Instant) void { + budget.assert_invariants(); + defer budget.assert_invariants(); + + for (budget.replicas_requested_prepares, 0..) |*requested_prepares, replica_index| { + if (requested_prepares.fetchSwapRemove(op)) |requested_prepare| { + budget.available += 1; + + // We have no information about the replica that sent this prepare, as the message + // header stores the index of the primary processed that prepare. Consequently, we + // refine repair latency for all replicas that this prepare op was requested from. + // This would lead to some inaccuracy in the latency measurement, but is acceptable + // since the scenario where a prepare has been requested from multiple replicas is + // rare in practice. The more common scenario is that we have a large number of + // prepares missing (for e.g. after state sync, or if a lagging replica transitions + // to a new checkpoint), in which case we request a unique op from each replica. + budget.replicas_repair_latency[replica_index] = ewma_add_duration( + budget.replicas_repair_latency[replica_index], + requested_prepare.value.elapsed(now), + ); + } + } + } + + pub fn refill(budget: *RepairBudgetJournal) void { + budget.assert_invariants(); + defer budget.assert_invariants(); + + for (budget.replicas_requested_prepares) |*requested_prepares| { + requested_prepares.clearRetainingCapacity(); + } + budget.available = budget.capacity; + } + + /// Iterates through the inflight requests across all remote replicas, and reclaims the budget + /// for expired requests. Penalizes the replicas for which some expired requests were found, + /// duration spent waiting for the expired requests to their repair latency. + /// + /// Expiry provides resilience to network faults, by ensuring that a dropped packet or the + /// remote replica crashing doesn't cause an op to get stuck in the queue for a remote replica. + /// We avoid spurious expiry due to transient network hiccups like increased latency by waiting + /// for twice the measured repair latency. + pub fn reap_expired_requests(budget: *RepairBudgetJournal, now: stdx.Instant) void { + budget.assert_invariants(); + defer budget.assert_invariants(); + + for (budget.replicas_requested_prepares, 0..) |*requested_prepares, replica_index| { + var requested_prepares_index: u32 = 0; + + while (requested_prepares_index < requested_prepares.entries.len) { + const requested_at = requested_prepares.values()[requested_prepares_index]; + const duration_since_requested_at = requested_at.elapsed(now); + const duration_expiry_ns = @min( + budget.repair_latency_multiple_expiry * + budget.replicas_repair_latency[replica_index].ns, + budget.duration_expiry_max.ns, + ); + if (duration_since_requested_at.ns > duration_expiry_ns) { + requested_prepares.swapRemoveAt(requested_prepares_index); + budget.replicas_repair_latency[replica_index] = ewma_add_duration( + budget.replicas_repair_latency[replica_index], + duration_since_requested_at, + ); + budget.available += 1; + } else { + requested_prepares_index += 1; + } + } + } + } + + fn assert_invariants(budget: *const RepairBudgetJournal) void { + assert(budget.available <= budget.capacity); + if (budget.replica_index < budget.replicas_requested_prepares.len) { + assert(budget.replicas_requested_prepares[budget.replica_index].count() == 0); + } + + var requested_prepares_count: u32 = 0; + for (budget.replicas_requested_prepares) |*requested_prepares| { + requested_prepares_count += @intCast(requested_prepares.count()); + } + assert(budget.capacity - budget.available == requested_prepares_count); + } + + fn ewma_add_duration(old: stdx.Duration, new: stdx.Duration) stdx.Duration { + return .{ + .ns = @divFloor((old.ns * 4) + new.ns, 5), + }; + } +}; + +pub const RepairBudgetGrid = struct { + capacity: u32, + available: u32, + replica_index: u8, + + // Tracks the blocks requested from each remote replica. + replicas_requested_blocks: []RequestedBlocks, + + // The amount of time we wait before restoring the budget for an + // inflight repair request if the block has not arrived. + const duration_expiry: stdx.Duration = .ms(250); + + // The amount of time we wait before re-requesting a block + // which has not yet arrived. + const duration_retry: stdx.Duration = .ms(100); + + // Maximum blocks that can be requested per remote replica. + // + // We use a small number to ensure that even if the budget to a + // remote replica is saturated by multiple replicas, overflowing + // the egress `send_queue` (which leads to dropped messages, and + // wasted network & storage IO) on the remote replica is unlikely. + // The +1 allows us to send a full `get_blocks` even when + // all but one request has been responded to. + const replica_blocks_requested_max = constants.grid_repair_request_max + 1; + + const RequestedBlocks = std.AutoArrayHashMapUnmanaged(vsr.BlockReference, stdx.Instant); + + pub fn init(gpa: std.mem.Allocator, options: struct { + replica_index: u8, + replica_count: u8, + }) !RepairBudgetGrid { + // Replicas can repair from all replicas but themselves, + // while standbys can repair from all replicas. + const remote_replica_count = + options.replica_count - @intFromBool(options.replica_index < options.replica_count); + + var replicas_requested_blocks = try gpa.alloc(RequestedBlocks, options.replica_count); + errdefer gpa.free(replicas_requested_blocks); + + for (replicas_requested_blocks, 0..) |*requested_blocks, replica| { + errdefer for (replicas_requested_blocks[0..replica]) |*m| m.deinit(gpa); + requested_blocks.* = .{}; + + try requested_blocks.ensureTotalCapacity(gpa, replica_blocks_requested_max); + errdefer requested_blocks.deinit(gpa); + } + errdefer for (replicas_requested_blocks) |*m| m.deinit(gpa); + + return RepairBudgetGrid{ + .capacity = replica_blocks_requested_max * remote_replica_count, + .available = replica_blocks_requested_max * remote_replica_count, + .replica_index = options.replica_index, + .replicas_requested_blocks = replicas_requested_blocks, + }; + } + + pub fn deinit(budget: *RepairBudgetGrid, gpa: std.mem.Allocator) void { + for (budget.replicas_requested_blocks) |*requested_blocks| { + requested_blocks.deinit(gpa); + } + gpa.free(budget.replicas_requested_blocks); + } + + fn assert_invariants(budget: *const RepairBudgetGrid) void { + assert(budget.available <= budget.capacity); + + if (budget.replica_index < budget.replicas_requested_blocks.len) { + assert(budget.replicas_requested_blocks[budget.replica_index].count() == 0); + } + + var requested_blocks_count: u32 = 0; + for (budget.replicas_requested_blocks) |*requested_blocks| { + requested_blocks_count += @intCast(requested_blocks.count()); + } + + assert(budget.available + requested_blocks_count == budget.capacity); + } + + pub fn next_destination(budget: *RepairBudgetGrid, prng: *stdx.PRNG) ?u8 { + budget.assert_invariants(); + defer budget.assert_invariants(); + + const replica_count = budget.replicas_requested_blocks.len; + var replica_indexes: [constants.replicas_max]u8 = undefined; + for (replica_indexes[0..replica_count], 0..) |*replica, i| replica.* = @intCast(i); + prng.shuffle(u8, replica_indexes[0..replica_count]); + + for (replica_indexes[0..replica_count]) |replica_index| { + if (replica_index != budget.replica_index and + budget.budget_available(replica_index) >= constants.grid_repair_request_max) + { + return replica_index; + } + } + return null; + } + + pub fn budget_available(budget: *const RepairBudgetGrid, replica_index: u8) u32 { + budget.assert_invariants(); + + assert(budget.replica_index != replica_index); + + const replica_requested_blocks = budget.replicas_requested_blocks[replica_index]; + + return @intCast(replica_blocks_requested_max - replica_requested_blocks.count()); + } + + pub fn decrement( + budget: *RepairBudgetGrid, + block_identifier: vsr.BlockReference, + replica_index: u8, + now: stdx.Instant, + ) bool { + budget.assert_invariants(); + defer budget.assert_invariants(); + + assert(budget.available > 0); + assert(block_identifier.address > 0); + assert(replica_index != budget.replica_index); + + var duration_since_requested_min: ?stdx.Duration = null; + + for (budget.replicas_requested_blocks, 0..) |requested_blocks, index| { + if (requested_blocks.get(block_identifier)) |requested_at| { + assert(index != budget.replica_index); + const duration_since_requested = requested_at.elapsed(now); + if (duration_since_requested_min == null or + duration_since_requested.ns < duration_since_requested_min.?.ns) + { + duration_since_requested_min = duration_since_requested; + } + } + } + + if (duration_since_requested_min) |duration| { + if (duration.ns < duration_retry.ns) return false; + } + + var replica_requested_blocks = + &budget.replicas_requested_blocks[replica_index]; + + assert(replica_requested_blocks.count() < replica_blocks_requested_max); + + const gop = replica_requested_blocks.getOrPutAssumeCapacity(block_identifier); + gop.value_ptr.* = now; + + if (!gop.found_existing) budget.available -= 1; + + return true; + } + + pub fn increment(budget: *RepairBudgetGrid, block_identifier: vsr.BlockReference) void { + budget.assert_invariants(); + defer budget.assert_invariants(); + + // We have no information about the replica that sent + // this block, as storing each replica's index in the + // block header would make storage non-deterministic. + // Consequently, we increase the budget for all replicas + // this block was requested from. This is safe to do as + // we only invoke `increment` *once* -- when the replica + // either uses the block to serve a read that's waiting + // on this block, or a repair write (see `on_block`). + for (budget.replicas_requested_blocks) |*requested_blocks| { + if (requested_blocks.swapRemove(block_identifier)) { + budget.available += 1; + } + } + } + + pub fn refill(budget: *RepairBudgetGrid) void { + budget.assert_invariants(); + defer budget.assert_invariants(); + + budget.available = budget.capacity; + + for (budget.replicas_requested_blocks) |*replicas_requested_blocks| { + replicas_requested_blocks.clearRetainingCapacity(); + } + } + + pub fn reap_expired_requests(budget: *RepairBudgetGrid, now: stdx.Instant) void { + budget.assert_invariants(); + defer budget.assert_invariants(); + + for (budget.replicas_requested_blocks) |*requested_blocks| { + var requested_blocks_index: u32 = 0; + + while (requested_blocks_index < requested_blocks.entries.len) { + const requested_at = requested_blocks.values()[requested_blocks_index]; + const duration_since_requested_at = requested_at.elapsed(now); + + if (duration_since_requested_at.ns > duration_expiry.ns) { + requested_blocks.swapRemoveAt(requested_blocks_index); + budget.available += 1; + } else { + requested_blocks_index += 1; + } + } + } + } +}; diff --git a/ocam/src/vsr/replica.zig b/ocam/src/vsr/replica.zig new file mode 100644 index 00000000..84d26e5b --- /dev/null +++ b/ocam/src/vsr/replica.zig @@ -0,0 +1,12413 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const maybe = stdx.maybe; +const SourceLocation = std.builtin.SourceLocation; + +const constants = @import("../constants.zig"); + +const stdx = @import("stdx"); +const RingBufferType = stdx.RingBufferType; +const Ratio = stdx.PRNG.Ratio; +const Duration = stdx.Duration; +const Instant = stdx.Instant; + +const StaticAllocator = @import("../static_allocator.zig"); +const GridType = @import("grid.zig").GridType; +const BlockPtr = @import("grid.zig").BlockPtr; +const IOPSType = stdx.IOPSType; +const MessagePool = @import("../message_pool.zig").MessagePool; +const Message = @import("../message_pool.zig").MessagePool.Message; +const MessageBuffer = @import("../message_buffer.zig").MessageBuffer; +const ForestTableIteratorType = + @import("../lsm/forest_table_iterator.zig").ForestTableIteratorType; +const TestStorage = @import("../testing/storage.zig").Storage; +const Time = @import("../time.zig").Time; +const RepairBudgetJournal = @import("repair_budget.zig").RepairBudgetJournal; +const RepairBudgetGrid = @import("repair_budget.zig").RepairBudgetGrid; +const Multiversion = @import("../multiversion.zig").Multiversion; + +const marks = @import("../testing/marks.zig"); + +const vsr = @import("../vsr.zig"); +const Header = vsr.Header; +const Timeout = vsr.Timeout; +const Command = vsr.Command; +const Version = vsr.Version; +const SyncStage = vsr.SyncStage; +const ClientSessions = vsr.ClientSessions; +const Tracer = vsr.trace.Tracer; + +const log = marks.wrap_log(stdx.log.scoped(.replica)); + +pub const Status = enum { + normal, + view_change, + /// Replicas start with `.recovering` status. Normally, replica immediately + /// transitions to a different status. The exception is a single-node cluster, + /// where the replica stays in `.recovering` state until it commits all entries + /// from its journal. + recovering, + /// Replica transitions from `.recovering` to `.recovering_head` at startup + /// if it finds its persistent state corrupted. In this case, replica can + /// not participate in consensus, as it might have forgotten some of the + /// messages it has sent or received before. Instead, it waits for a View + /// message to get into a consistent state. + recovering_head, +}; + +pub const ReplicaEvent = union(enum) { + message_sent: *const Message, + state_machine_opened, + /// Called immediately after a prepare is committed by the state machine. + committed: struct { + prepare: *const Message.Prepare, + /// Note that this reply may just be discarded, if the request originated from a replica. + reply: *const Message.Reply, + }, + /// Called immediately after a compaction. + compaction_completed, + /// Called immediately before a checkpoint. + checkpoint_commenced, + /// Called immediately after a checkpoint. + /// Note: The replica may checkpoint without calling this function: + /// 1. Begin checkpoint. + /// 2. Write 2/4 SuperBlock copies. + /// 3. Crash. + /// 4. Recover in the new checkpoint (but op_checkpoint wasn't called). + checkpoint_completed, + sync_stage_changed, + client_evicted: u128, +}; + +pub const CommitStage = union(enum) { + pub const Tag = std.meta.Tag(CommitStage); + + const CheckpointData = enum { + aof, + state_machine, + client_replies, + client_sessions, + grid, + }; + + const CheckpointDataProgress = std.enums.EnumSet(CheckpointData); + + /// Not committing. + idle, + /// Get the next prepare to commit from the journal or pipeline and... + start, + /// ...if there isn't any, break out of commit loop. + check_prepare, + /// Load required data from LSM tree on disk into memory. + prefetch, + /// Primary delays committing as backpressure, to allow backups to catch up. + stall, + /// Ensure that the ClientReplies has at least one Write available. + reply_setup, + /// Execute state machine logic. + execute, + /// Every vsr_checkpoint_ops, mark the current checkpoint as durable. + checkpoint_durable, + /// Run one beat of LSM compaction. + compact, + /// Every vsr_checkpoint_ops, persist the current state to disk and... + checkpoint_data: CheckpointDataProgress, + /// ...update the superblock. + checkpoint_superblock, +}; + +const Nonce = u128; + +const Prepare = struct { + /// The current prepare message (used to cross-check prepare_ok messages, and for resending). + message: *Message.Prepare, + + /// Unique prepare_ok messages for the same view, op number and checksum from ALL replicas. + ok_from_all_replicas: QuorumCounter = quorum_counter_null, + + /// Whether a quorum of prepare_ok messages has been received for this prepare. + ok_quorum_received: bool = false, +}; + +const Request = struct { + message: *Message.Request, + realtime: i64, +}; + +const JVQuorumMessages = [constants.replicas_max]?*Message.JoinView; +const jv_quorum_messages_null: JVQuorumMessages = @splat(null); + +const QuorumCounter = stdx.BitSetType(constants.replicas_max); +const quorum_counter_null: QuorumCounter = .{}; + +pub fn ReplicaType( + comptime StateMachine: type, + comptime MessageBus: type, + comptime Storage: type, + comptime AOF: type, +) type { + const Grid = GridType(Storage); + const Forest = StateMachine.Forest; + const GridScrubber = vsr.GridScrubberType(Forest, constants.grid_scrubber_reads_max); + + return struct { + const Replica = @This(); + + pub const SuperBlock = vsr.SuperBlockType(Storage); + const CheckpointTrailer = vsr.CheckpointTrailerType(Storage); + const Journal = vsr.JournalType(Replica, Storage); + const ClientReplies = vsr.ClientRepliesType(Storage); + const Clock = vsr.Clock; + const ForestTableIterator = ForestTableIteratorType(Forest); + + const BlockRead = struct { + read: Grid.Read, + replica: *Replica, + destination: u8, + message: *Message.Block, + }; + + const BlockWrite = struct { + write: Grid.Write = undefined, + replica: *Replica, + }; + + const LogPrefix = struct { + replica: u8, + status: Status, + primary: bool, + + pub fn format( + self: LogPrefix, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + try writer.print("{}", .{self.replica}); + + var status_character: u8 = switch (self.status) { + .normal => 'n', + .view_change => 'v', + .recovering => 'r', + .recovering_head => 'h', + }; + + if (self.primary) { + status_character = std.ascii.toUpper(status_character); + } + + try writer.print("{c}", .{status_character}); + } + }; + + /// We use this allocator during open/init and then disable it. + /// An accidental dynamic allocation after open/init will cause an assertion failure. + static_allocator: StaticAllocator, + + /// The number of the cluster to which this replica belongs: + cluster: u128, + + /// The number of replicas in the cluster: + replica_count: u8, + + /// The number of standbys in the cluster. + standby_count: u8, + + /// Total amount of nodes (replicas and standbys) in the cluster. + /// + /// Invariant: node_count = replica_count + standby_count + node_count: u8, + + /// The index of this replica's address in the configuration array held by the MessageBus. + /// If replica >= replica_count, this is a standby. + /// + /// Invariant: replica < node_count + replica: u8, + + /// Runtime upper-bound number of requests in the pipeline. + /// Does not change after initialization. + /// Invariants: + /// - pipeline_request_queue_limit ≥ 0 + /// - pipeline_request_queue_limit ≤ pipeline_request_queue_max + /// + /// The *total* runtime pipeline size is never less than the pipeline_prepare_queue_max. + /// This is critical since we don't guarantee that all replicas in a cluster are started + /// with the same `pipeline_request_queue_limit`. + pipeline_request_queue_limit: u32, + + /// Runtime upper-bound size of a `operation=request` message. + /// Does not change after initialization. + /// Invariants: + /// - request_size_limit > @sizeOf(Header) + /// - request_size_limit ≤ message_size_max + request_size_limit: u32, + + /// The minimum number of replicas required to form a replication quorum: + quorum_replication: u8, + + /// The minimum number of replicas required to form a view change quorum: + quorum_view_change: u8, + + /// The minimum number of replicas required to nack an uncommitted pipeline prepare + /// header/message. + quorum_nack_prepare: u8, + + /// More than half of replica_count. + quorum_majority: u8, + + /// The version of code that is running right now. + /// + /// Invariants: + /// - release_client_min > 0 + /// + /// Note that this is a property (rather than a constant) for the purpose of testing. + /// It should never be modified by a running replica. + release: vsr.Release, + + /// The minimum (inclusive) client version that the replica will accept requests from. + /// + /// Invariants: + /// - release_client_min > 0 + /// - release_client_min ≥ release + /// + /// Note that this is a property (rather than a constant) for the purpose of testing. + /// It should never be modified by a running replica. + release_client_min: vsr.Release, + + multiversion: Multiversion, + + /// Probability with which the primary unconditionally injects stalls. + commit_stall_probability: Ratio, + + /// Minimum lag in commit_min after which the primary starts injecting stalls. + commit_stall_lag_min: u32, + + /// Maximum lag in commit_min after which the primary stops injecting stalls, + /// considering a backup dead/partitioned. + commit_stall_lag_max: u32, + + /// Maximum stall multiple, which puts a cap on the stall duration + /// the primary can inject (`commit_stall_multiple_max * 10` ms). + commit_stall_multiple_max: u16, + + /// Maximum commit_min on each backup, tracked via prepare_ok messages. + commit_mins: [constants.replicas_max]u64 = @splat(0), + + /// Maximum head op on each backup, tracked via prepare_ok messages. + head_ops: [constants.replicas_max]u64 = @splat(0), + + /// A globally unique integer generated by a crypto rng during replica process startup. + /// Presently, it is used to detect outdated View messages in recovering head status. + nonce: Nonce, + + /// The (real)time at which the replica started. + time_start: i64, + + /// A distributed fault-tolerant clock for lower and upper bounds on the primary's wall + /// clock: + clock: Clock, + + /// The persistent log of hash-chained prepares: + journal: Journal, + + /// ClientSessions records for each client the latest session and the latest committed + /// reply. This is modified between checkpoints, and is persisted on checkpoint and sync. + client_sessions: ClientSessions, + + client_sessions_checkpoint: CheckpointTrailer, + + /// The persistent log of the latest reply per active client. + client_replies: ClientReplies, + + /// An abstraction to send messages from the replica to another replica or client. + /// The message bus will also deliver messages to this replica by calling + /// `on_messages_from_bus()`. + message_bus: MessageBus, + + /// For executing service up-calls after an operation has been committed: + state_machine: StateMachine, + + /// Set to true once StateMachine.open() completes. + /// When false, the replica must not commit/compact/checkpoint. + state_machine_opened: bool = false, + + /// Durably store VSR state, the "root" of the LSM tree, and other replica metadata. + superblock: SuperBlock, + + /// Context for SuperBlock.open() and .checkpoint(). + superblock_context: SuperBlock.Context = undefined, + /// Context for SuperBlock.view_change(), which can happen concurrently to .checkpoint(). + superblock_context_view_change: SuperBlock.Context = undefined, + + grid: Grid, + grid_reads: IOPSType(BlockRead, constants.grid_repair_reads_max) = .{}, + grid_repair_tables: IOPSType(Grid.RepairTable, constants.grid_missing_tables_max) = .{}, + grid_repair_table_bitsets: [constants.grid_repair_writes_max]std.DynamicBitSetUnmanaged, + grid_repair_writes: IOPSType(BlockWrite, constants.grid_repair_writes_max) = .{}, + grid_repair_write_blocks: [constants.grid_repair_writes_max]BlockPtr, + grid_scrubber: GridScrubber, + + opened: bool, + + syncing: SyncStage = .idle, + // Holds onto the View message that triggered a state sync during async cancelation phase. + // + // Invariants: + // - (sync_view ≠ null) ⇔ (syncing ∈ {.canceling_commit, .canceling_checkpoint}) + sync_view: ?*Message.View = null, + /// Invariants: + /// - If syncing≠idle then sync_tables=null. + sync_tables: ?ForestTableIterator = null, + /// Invariants: + /// - sync_tables_op_range=null ↔ sync_tables=null. + sync_tables_op_range: ?struct { min: u64, max: u64 } = null, + /// Tracks wal repair progress to decide when to switch to state sync. + /// Updated on repair_sync_timeout. + sync_wal_repair_progress: struct { + commit_min: u64 = 0, + advanced: bool = true, + } = .{}, + + /// The release we are currently upgrading towards. + /// + /// Invariants: + /// - upgrade_release > release + /// - upgrade_release > superblock.working.vsr_state.checkpoint.release + upgrade_release: ?vsr.Release = null, + + /// The latest release list from every other replica. (Constructed from pings.) + /// + /// Invariants: + /// - upgrade_targets[self.replica] = null + /// - upgrade_targets[*].releases > release + upgrade_targets: [constants.replicas_max]?struct { + checkpoint: u64, + view: u32, + releases: vsr.ReleaseList, + } = @splat(null), + + /// The current view. + /// Initialized from the superblock's VSRState. + /// + /// Invariants: + /// * `replica.view = replica.log_view` when status=normal + /// * `replica.view ≥ replica.log_view` + /// * `replica.view ≥ replica.view_durable` + /// * `replica.view = 0` when replica_count=1. + view: u32, + + /// The latest view where + /// - the replica was a primary and acquired a JV quorum, or + /// - the replica was a backup and processed a View message. + /// i.e. the latest view in which this replica changed its head message. + /// + /// Initialized from the superblock's VSRState. + /// + /// Invariants (see `view` for others): + /// * `replica.log_view ≥ replica.log_view_durable` + /// * `replica.log_view = 0` when replica_count=1. + log_view: u32, + + /// The current status, either normal, view_change, or recovering: + status: Status = .recovering, + + /// The op number assigned to the most recently prepared operation. + /// This op is sometimes referred to as the replica's "head" or "head op". + /// + /// Invariants (not applicable during status=recovering|recovering_head): + /// * `replica.op` exists in the Journal. + /// * `replica.op ≥ replica.op_checkpoint`. + /// * `replica.op ≥ replica.commit_min`. + /// * `replica.op - replica.commit_min ≤ journal_slot_count` + /// It is safe to overwrite all entries that are already committed (even past + /// op_prepare_max), but only up till op_checkpoint_next. This is because we require + /// op_checkpoint_next → op_checkpoint_next_trigger during upgrades and checkpoint. + op: u64, + + /// The op number of the latest committed and executed operation (according to the replica). + /// The replica may have to wait for repairs to complete before commit_min reaches + /// commit_max. + /// + /// Invariants (not applicable during status=recovering): + /// * `replica.commit_min` exists in the Journal OR `replica.commit_min == op_checkpoint`. + /// * `replica.commit_min ≤ replica.op`. + /// * `replica.commit_min ≥ replica.op_checkpoint`. + /// * never decreases while the replica is alive and not state-syncing. + commit_min: u64, + + /// The op number of the latest committed operation (according to the cluster). + /// This is the commit number in terms of the VRR paper. + /// + /// - When syncing=idle and status≠recovering_head, + /// this is the latest commit *within our view*. + /// - When syncing≠idle or status=recovering_head, + /// this is max(latest commit within our view, sync_target.op). + /// + /// Invariants: + /// * `replica.commit_max ≥ replica.commit_min`. + /// * `replica.commit_max ≥ replica.op -| constants.pipeline_prepare_queue_max`. + /// * never decreases. + /// Invariants (status=normal primary): + /// * `replica.commit_max = replica.commit_min`. + /// * `replica.commit_max = replica.op - pipeline.queue.prepare_queue.count`. + commit_max: u64, + + /// Guards against concurrent commits, and tracks the commit progress. + commit_stage: CommitStage = .idle, + commit_dispatch_entered: bool = false, + + /// The prepare message being committed. + commit_prepare: ?*Message.Prepare = null, + + /// Measures the time taken to commit a prepare, across the following stages: + /// prefetch → reply_setup → execute → compact → checkpoint_data → checkpoint_superblock + commit_started: ?stdx.Instant = null, + + /// Whether we are reading a prepare from storage to construct the pipeline. + pipeline_repairing: bool = false, + + /// The pipeline is a queue for a replica which is the primary and in status=normal. + /// At all other times the pipeline is a cache. + pipeline: union(enum) { + /// The primary's pipeline of inflight prepares waiting to commit in FIFO order, + /// with a tail of pending requests which have not begun to prepare. + /// This allows us to pipeline without the complexity of out-of-order commits. + queue: PipelineQueue, + /// Prepares in the cache may be committed or uncommitted, and may not belong to the + /// current view. + cache: PipelineCache, + }, + + /// When "log_view < view": The JV headers. + /// When "log_view = view": The View headers. (Just as a cache, + /// since they are regenerated for every get_view). + /// + /// Invariants: + /// - view_headers.len > 0 + /// - view_headers[0].view ≤ self.log_view + view_headers: vsr.Headers.ViewChangeArray, + + /// In some cases, a replica may send a message to itself. We do not submit these messages + /// to the message bus but rather queue them here for guaranteed immediate delivery, which + /// we require and assert in our protocol implementation. + loopback_queue: ?*Message = null, + + /// The last timestamp received on a commit heartbeat. + /// The timestamp originates from the primary's monotonic clock. It is used to discard + /// delayed or duplicate heartbeat messages. + /// (status=normal backup) + heartbeat_timestamp: u64 = 0, + + /// While set, don't send commit heartbeats. + /// Used when the primary believes that it is partitioned and needs to step down. + /// In particular, guards against a deadlock in the case where small messages (e.g. + /// heartbeats, pings/pongs) succeed, but large messages (e.g. prepares) fail. + /// (status=normal primary, pipeline has prepare with !ok_quorum_received) or + /// (status=normal primary, a request was dropped due to clock sync) + primary_abdicating: bool = false, + + /// Unique exit_view messages for the same view from ALL replicas (including + /// ourself). + exit_view_from_all_replicas: QuorumCounter = quorum_counter_null, + + /// Unique join_view messages for the same view from ALL replicas (including ourself). + join_view_from_all_replicas: JVQuorumMessages = jv_quorum_messages_null, + + /// Whether the primary has received a quorum of join_view messages for the view + /// change. Determines whether the primary may effect repairs according to the CTRL + /// protocol. + join_view_quorum: bool = false, + + /// The number of ticks before a primary or backup broadcasts a ping to other replicas. + /// TODO Explain why we need this (MessageBus handshaking, leapfrogging faulty replicas, + /// deciding whether starting a view change would be detrimental under some network + /// partitions). + /// (always running) + ping_timeout: Timeout, + + /// The number of ticks without enough prepare_ok's before the primary resends a prepare. + /// (status=normal primary, pipeline has prepare with !ok_quorum_received) + prepare_timeout: Timeout, + + /// The number of ticks waiting for a prepare_ok. + /// When triggered, set primary_abdicating=true, which pauses outgoing commit heartbeats. + /// (status=normal primary, pipeline has prepare with !ok_quorum_received) + primary_abdicate_timeout: Timeout, + + /// The number of ticks before the primary sends a commit heartbeat: + /// The primary always sends a commit heartbeat irrespective of when it last sent a prepare. + /// This improves liveness when prepare messages cannot be replicated fully due to + /// partitions. + /// (status=normal primary) + commit_message_timeout: Timeout, + + /// Fault detector that treats fresh prepare and commit messages as liveness signal. + /// - Backups use it to trigger exit_view messages. + /// - Primary uses it to inject extra smoothing commit messages when requests stop abruptly. + commit_fault: vsr.FaultDetector, + + /// The number of ticks before resetting the EV quorum. + /// (status=normal|view-change, EV quorum contains message from ANY OTHER replica) + exit_view_window_timeout: Timeout, + + /// The number of ticks before resending a `exit_view` message. + /// (status=normal|view-change) + exit_view_message_timeout: Timeout, + + /// The number of ticks before a view change is timed out. + /// When triggered, begin sending EV messages (to attempt to increment the view and try a + /// different primary) — but keep trying JVs as well. + /// (status=view-change) + view_change_status_timeout: Timeout, + + /// The number of ticks before resending a `join_view` message: + /// (status=view-change) + join_view_message_timeout: Timeout, + + /// The number of ticks before resending a `get_view` message. + /// (status=view-change backup) + get_view_message_timeout: Timeout, + + /// The number of ticks before repairing missing/disconnected headers, dirty/missing + /// prepares, and replenishing the repair budget. + /// (status=normal or (status=view-change and primary)). + journal_repair_timeout: Timeout, + + journal_repair_message_budget: RepairBudgetJournal, + + /// The number of ticks before checking whether state sync should be requested. + /// This allows the replica to attempt WAL/grid repair before falling back, even if it + /// is lagging behind the primary, to try to avoid unnecessary state sync. + /// + /// Reset anytime that commit work progresses. + /// (status=normal backup) + repair_sync_timeout: Timeout, + + /// The number of ticks before requesting missing/corrupt grid blocks. + /// (always running) + grid_repair_timeout: Timeout, + grid_repair_message_budget: RepairBudgetGrid, + + /// (always running) + grid_scrub_timeout: Timeout, + + /// The number of ticks on an idle cluster before injecting a `pulse` operation. + /// (status=normal and primary and !self.aof_recovery) + pulse_timeout: Timeout, + + /// The number of ticks before checking whether we are ready to begin an upgrade. + /// (status=normal primary) + upgrade_timeout: Timeout, + + /// The number of ticks until we resume committing. + /// The tick count is dynamic, based on how far behind the backups are. + /// (commit_stage=stall) + commit_stall_timeout: Timeout, + + /// Used to calculate exponential backoff with random jitter, and for the grid scrubber. + /// Seeded with the replica's nonce. + prng: stdx.PRNG, + + /// Used by `Cluster` in the simulator. + test_context: ?*anyopaque, + /// Simulator hooks. + event_callback: ?*const fn (replica: *const Replica, event: ReplicaEvent) void = null, + + trace: *Tracer, + trace_emit_timeout: Timeout, + + // Record the lowest and highest client releases that this replica has seen. These get reset + // every time they are emitted (by trace_emit_timeout). + release_seen_client_min: ?u32 = null, + release_seen_client_max: ?u32 = null, + + aof: ?*AOF, + aof_recovery: bool, + + const OpenOptions = struct { + node_count: u8, + pipeline_requests_limit: u32, + storage_size_limit: u64, + nonce: Nonce, + aof: ?*AOF, + aof_recovery: bool, + state_machine_options: StateMachine.Options, + message_bus_options: MessageBus.Options, + tracer: *Tracer, + grid_cache_blocks_count: u32 = Grid.Cache.value_count_max_multiple, + release: vsr.Release, + release_client_min: vsr.Release, + multiversion: Multiversion, + test_context: ?*anyopaque = null, + timeout_prepare_ticks: ?u64 = null, + timeout_grid_repair_message_ticks: ?u64 = null, + commit_stall_probability: ?Ratio = null, + commit_stall_lag_min: ?u32 = null, + commit_stall_lag_max: ?u32 = null, + commit_stall_multiple_max: ?u16 = null, + }; + + /// Initializes and opens the provided replica using the options. + pub fn open( + self: *Replica, + parent_allocator: std.mem.Allocator, + time: Time, + storage: *Storage, + message_pool: *MessagePool, + options: OpenOptions, + ) !void { + assert(options.storage_size_limit <= constants.storage_size_limit_max); + assert(options.storage_size_limit % constants.sector_size == 0); + assert(options.nonce != 0); + assert(options.release.value > 0); + assert(options.release.value >= options.release_client_min.value); + assert(options.pipeline_requests_limit >= 0); + assert(options.pipeline_requests_limit <= constants.pipeline_request_queue_max); + if (options.commit_stall_probability) |p| assert(p.numerator <= p.denominator); + + self.static_allocator = StaticAllocator.init(parent_allocator); + const allocator = self.static_allocator.allocator(); + + // Once initialized, the replica is responsible for deinitializing replica components. + var initialized = false; + + self.superblock = try SuperBlock.init( + allocator, + storage, + .{ + .storage_size_limit = options.storage_size_limit, + }, + ); + errdefer if (!initialized) self.superblock.deinit(allocator); + + // Open the superblock: + self.opened = false; + self.superblock.open(superblock_open_callback, &self.superblock_context); + while (!self.opened) self.superblock.storage.run(); + self.superblock.working.vsr_state.assert_internally_consistent(); + + const replica_id = self.superblock.working.vsr_state.replica_id; + const replica = for (self.superblock.working.vsr_state.members, 0..) |member, index| { + if (member == replica_id) break @as(u8, @intCast(index)); + } else unreachable; + const replica_count = self.superblock.working.vsr_state.replica_count; + if (replica >= options.node_count or replica_count > options.node_count) { + log.err("{}: open: no address for replica (replica_count={} node_count={})", .{ + self.log_prefix(), + replica_count, + options.node_count, + }); + return error.NoAddress; + } + + self.trace = options.tracer; + self.trace.set_replica(.{ + .cluster = self.superblock.working.cluster, + .replica = replica, + }); + + self.test_context = options.test_context; + + // Initialize the replica: + try self.init( + allocator, + time, + storage, + message_pool, + .{ + .cluster = self.superblock.working.cluster, + .replica_index = replica, + .replica_count = replica_count, + .standby_count = options.node_count - replica_count, + .pipeline_requests_limit = options.pipeline_requests_limit, + .aof = options.aof, + .aof_recovery = options.aof_recovery, + .nonce = options.nonce, + .state_machine_options = options.state_machine_options, + .message_bus_options = options.message_bus_options, + .grid_cache_blocks_count = options.grid_cache_blocks_count, + .release = options.release, + .release_client_min = options.release_client_min, + .multiversion = options.multiversion, + .timeout_prepare_ticks = options.timeout_prepare_ticks, + .timeout_grid_repair_message_ticks = options.timeout_grid_repair_message_ticks, + .commit_stall_probability = options.commit_stall_probability, + .commit_stall_lag_min = options.commit_stall_lag_min, + .commit_stall_lag_max = options.commit_stall_lag_max, + .commit_stall_multiple_max = options.commit_stall_multiple_max, + .tracer = options.tracer, + }, + ); + + // Disable all dynamic allocation from this point onwards. + self.static_allocator.transition_from_init_to_static(); + + const release_target = self.superblock.working.vsr_state.checkpoint.release; + assert(release_target.value >= self.superblock.working.release_format.value); + log.info("superblock release={}", .{release_target}); + + if (self.superblock.working.cluster != 0) { + if (self.release.triple().major == vsr.Release.development_major) { + @panic("Test builds must only be used with test clusters. (cluster=0)"); + } + } + + if (release_target.value != self.release.value) { + self.release_transition(@src()); + return; + } + + initialized = true; + errdefer self.deinit(allocator); + + self.opened = false; + self.journal.recover(journal_recover_callback); + while (!self.opened) self.superblock.storage.run(); + + // Abort if all slots are faulty, since something is very wrong. + if (self.journal.faulty.count == constants.journal_slot_count) return error.WALInvalid; + + const view_headers = self.superblock.working.view_headers(); + // If we were a lagging backup that installed a View but didn't finish fast-forwarding, + // the view_headers head op may be part of the checkpoint after this one. + maybe(view_headers.slice[0].op > self.op_prepare_max()); + + // Given on-disk state, try to recover the head op after a restart. + // + // If the replica crashed in status == .normal (view == log_view), the head is generally + // the last record in WAL. As a special case, during the first open the last (and the + // only) record in WAL is the root prepare. + // + // Otherwise, the head is recovered from the superblock. When transitioning to a + // view_change, replicas encode the current head into view_headers. + // + // It is a possibility that the head can't be recovered from the local data. + // In this case, the replica transitions to .recovering_head and waits for a .view + // message from a primary to reset its head. + var op_head: ?u64 = null; + + if (self.log_view == self.view) { + for (self.journal.headers) |*header| { + assert(header.command == .prepare); + if (header.operation != .reserved) { + assert(header.op <= self.op_prepare_max()); + assert(header.view <= self.log_view); + + if (op_head == null or op_head.? < header.op) op_head = header.op; + } + } + } else { + // Fall-through to choose op-head from view_headers. + // + // "Highest op from log_view in WAL" is not the correct choice for op-head when + // recovering with a durable JV (though we still resort to this if there are no + // usable headers in the view_headers). It is possible that we started the view and + // finished some repair before updating our view_durable. + // + // To avoid special-casing this all over, we pretend this higher op doesn't + // exist. This is safe because we never prepared any ops in the view we joined just + // before the crash. + assert(self.log_view < self.view); + maybe(self.journal.op_maximum() > view_headers.slice[0].op); + } + + // Try to use view_headers to update our head op and its header. + // To avoid the following scenario, don't load headers prior to the head: + // 1. Replica A prepares[/commits] op X. + // 2. Replica A crashes. + // 3. Prepare X is corrupted in the WAL. + // 4. Replica A recovers. During `Replica.open()`, Replica A loads the header + // for op `X - journal_slot_count` (same slot, prior wrap) from view_headers + // into the journal. + // 5. Replica A participates in a view-change, but nacks[/does not include] op X. + // 6. Checkpoint X is truncated. + for (view_headers.slice) |*vsr_header| { + if (vsr.Headers.jv_header_type(vsr_header) == .valid and + vsr_header.op <= self.op_prepare_max() and + (op_head == null or op_head.? <= vsr_header.op)) + { + op_head = vsr_header.op; + + if (!self.journal.has_header(vsr_header)) { + self.journal.set_header_as_dirty(vsr_header); + } + break; + } + } else { + // This case can occur if we loaded a View for its hook header but never finished + // that View to a JV (dropping the hooks), and never finished the view change. + if (op_head == null) { + assert(self.view > self.log_view); + if (self.journal.op_maximum() < self.op_checkpoint() or + // Corrupted root prepare: + (self.journal.op_maximum() == 0 and self.journal.header_with_op(0) == null)) + { + const header_checkpoint = + &self.superblock.working.vsr_state.checkpoint.header; + assert(header_checkpoint.op == self.op_checkpoint()); + assert(!self.journal.has_header(header_checkpoint)); + self.journal.set_header_as_dirty(header_checkpoint); + } + op_head = self.journal.op_maximum(); + } + } + + // Guaranteed since our durable view_headers always contain an op from the current + // checkpoint (see `commit_checkpoint_superblock`). + assert(op_head.? >= self.op_checkpoint()); + assert(op_head.? <= self.op_prepare_max()); + + self.op = op_head.?; + self.commit_max = @max( + self.commit_max, + self.op -| constants.pipeline_prepare_queue_max, + ); + + const header_head = self.journal.header_with_op(self.op).?; + assert(header_head.view <= self.superblock.working.vsr_state.log_view); + + if (self.solo()) { + if (self.journal.faulty.count > 0) return error.WALCorrupt; + assert(self.op_head_certain()); + + // Solo replicas must increment their view after recovery. + // Otherwise, two different versions of an op could exist within a single view + // (the former version truncated as a torn write). + // + // on_request() will ignore incoming requests until the view_durable_update() + // completes. + self.log_view += 1; + self.view += 1; + self.primary_update_view_headers(); + self.view_durable_update(); + + if (self.commit_min == self.op) { + self.transition_to_normal_from_recovering_status(); + } + } else { + if (self.log_view == self.view) { + if (self.op_head_certain()) { + if (self.primary_index(self.view) == self.replica) { + self.transition_to_view_change_status(self.view + 1); + } else { + self.transition_to_normal_from_recovering_status(); + } + } else { + self.transition_to_recovering_head_from_recovering_status(); + } + } else { + // Don't call op_head_certain() here, as we didn't use the journal to infer our + // head op. We used only view_headers, and a JV always has a certain head op. + assert(self.view > self.log_view); + self.transition_to_view_change_status(self.view); + } + } + + maybe(self.status == .normal); + maybe(self.status == .view_change); + maybe(self.status == .recovering_head); + if (self.status == .recovering) assert(self.solo()); + + if (self.superblock.working.vsr_state.sync_op_max != 0) { + log.info("{}: sync: ops={}..{}", .{ + self.log_prefix(), + self.superblock.working.vsr_state.sync_op_min, + self.superblock.working.vsr_state.sync_op_max, + }); + + self.sync_tables = .{}; + self.sync_tables_op_range = .{ + .min = self.superblock.working.vsr_state.sync_op_min, + .max = self.superblock.working.vsr_state.sync_op_max, + }; + } + + // Asynchronously open the free set and then the (Forest inside) StateMachine so that we + // can repair grid blocks if necessary: + self.grid.open(grid_open_callback); + self.trace_emit_timeout.start(); + self.invariants(); + } + + fn superblock_open_callback(superblock_context: *SuperBlock.Context) void { + const self: *Replica = @alignCast( + @fieldParentPtr("superblock_context", superblock_context), + ); + assert(!self.opened); + self.opened = true; + } + + fn journal_recover_callback(journal: *Journal) void { + const self: *Replica = @alignCast(@fieldParentPtr("journal", journal)); + assert(!self.opened); + self.opened = true; + } + + fn grid_open_callback(grid: *Grid) void { + const self: *Replica = @alignCast(@fieldParentPtr("grid", grid)); + assert(!self.state_machine_opened); + assert(self.commit_stage == .idle); + assert(self.syncing == .idle); + assert(!self.grid.blocks_missing.repairing_tables()); + assert(std.meta.eql( + grid.free_set_checkpoint_blocks_acquired.checkpoint_reference(), + self.superblock.working.free_set_reference(.blocks_acquired), + )); + assert(std.meta.eql( + grid.free_set_checkpoint_blocks_released.checkpoint_reference(), + self.superblock.working.free_set_reference(.blocks_released), + )); + + // TODO This can probably be performed concurrently to StateMachine.open(). + self.client_sessions_checkpoint.open( + &self.grid, + self.superblock.working.client_sessions_reference(), + client_sessions_open_callback, + ); + } + + fn client_sessions_open_callback(client_sessions_checkpoint: *CheckpointTrailer) void { + const self: *Replica = @alignCast( + @fieldParentPtr("client_sessions_checkpoint", client_sessions_checkpoint), + ); + assert(!self.state_machine_opened); + assert(self.commit_stage == .idle); + assert(self.syncing == .idle); + assert(!self.grid.blocks_missing.repairing_tables()); + assert(self.client_sessions.entries_present.empty()); + assert(std.meta.eql( + self.client_sessions_checkpoint.checkpoint_reference(), + self.superblock.working.client_sessions_reference(), + )); + + const checkpoint = &self.client_sessions_checkpoint; + self.grid.release(checkpoint.block_addresses[0..checkpoint.block_count()]); + + const trailer_size = self.client_sessions_checkpoint.size; + const trailer_chunks = self.client_sessions_checkpoint.decode_chunks(); + + if (self.superblock.working.client_sessions_reference().empty()) { + assert(trailer_chunks.len == 0); + assert(trailer_size == 0); + } else { + assert(trailer_chunks.len == 1); + assert(trailer_size == ClientSessions.encode_size); + assert(trailer_size == trailer_chunks[0].len); + self.client_sessions.decode(trailer_chunks[0]); + } + + if (self.superblock.working.vsr_state.sync_op_max > 0) { + maybe(!self.client_replies.writing.empty()); + for (0..constants.clients_max) |entry_slot| { + const slot_faulty = self.client_replies.faulty.is_set(entry_slot); + const slot_free = !self.client_sessions.entries_present.is_set(entry_slot); + assert(!slot_faulty); + if (!slot_free) { + const entry = &self.client_sessions.entries[entry_slot]; + if (entry.header.op >= self.superblock.working.vsr_state.sync_op_min and + entry.header.op <= self.superblock.working.vsr_state.sync_op_max) + { + const entry_faulty = entry.header.size > @sizeOf(Header); + self.client_replies.faulty.set_value(entry_slot, entry_faulty); + } + } + } + } + + self.state_machine.open(state_machine_open_callback); + } + + fn state_machine_open_callback(state_machine: *StateMachine) void { + const self: *Replica = @alignCast(@fieldParentPtr("state_machine", state_machine)); + assert(self.grid.free_set.opened); + assert(!self.state_machine_opened); + assert(self.commit_stage == .idle); + assert(self.syncing == .idle); + assert(!self.grid.blocks_missing.repairing_tables()); + assert(self.grid.stash_available <= 1); // Only the burst block may be free. + self.assert_free_set_consistent(); + + log.info("{}: state_machine_open_callback: sync_ops={}..{}", .{ + self.log_prefix(), + self.superblock.working.vsr_state.sync_op_min, + self.superblock.working.vsr_state.sync_op_max, + }); + + self.state_machine_opened = true; + if (self.event_callback) |hook| hook(self, .state_machine_opened); + + self.grid_scrubber.open(&self.prng); + if (self.sync_tables) |_| self.sync_content(); + + if (self.solo()) { + if (self.commit_min < self.op) { + self.advance_commit_max(self.op, @src()); + self.commit_journal(); + + // Recovery will complete when commit_journal finishes. + assert(self.status == .recovering); + } else { + assert(self.status == .normal); + } + } else { + if (self.status == .normal and self.primary()) { + if (self.pipeline.queue.prepare_queue.count > 0) { + self.commit_pipeline(); + } + } else { + if (self.status != .recovering_head) { + self.commit_journal(); + } + } + } + } + + const Options = struct { + cluster: u128, + replica_count: u8, + standby_count: u8, + replica_index: u8, + pipeline_requests_limit: u32, + nonce: Nonce, + aof: ?*AOF, + aof_recovery: bool, + message_bus_options: MessageBus.Options, + state_machine_options: StateMachine.Options, + grid_cache_blocks_count: u32, + release: vsr.Release, + release_client_min: vsr.Release, + multiversion: Multiversion, + timeout_prepare_ticks: ?u64, + timeout_grid_repair_message_ticks: ?u64, + commit_stall_probability: ?Ratio, + commit_stall_lag_min: ?u32, + commit_stall_lag_max: ?u32, + commit_stall_multiple_max: ?u16, + tracer: *Tracer, + }; + + /// NOTE: self.superblock must be initialized and opened prior to this call. + fn init( + self: *Replica, + allocator: Allocator, + time: Time, + storage: *Storage, + message_pool: *MessagePool, + options: Options, + ) !void { + assert(options.nonce != 0); + + const replica_count = options.replica_count; + const standby_count = options.standby_count; + const node_count = replica_count + standby_count; + assert(replica_count > 0); + assert(replica_count <= constants.replicas_max); + assert(standby_count <= constants.standbys_max); + assert(node_count <= constants.members_max); + + const replica_index = options.replica_index; + assert(replica_index < node_count); + + self.replica_count = replica_count; + self.standby_count = standby_count; + self.node_count = node_count; + self.replica = replica_index; + + self.journal_repair_message_budget = try RepairBudgetJournal.init( + allocator, + .{ + .replica_index = replica_index, + .replica_count = replica_count, + }, + ); + errdefer self.journal_repair_message_budget.deinit(allocator); + + assert(self.journal_repair_message_budget.available == + self.journal_repair_message_budget.capacity); + + self.grid_repair_message_budget = try RepairBudgetGrid.init( + allocator, + .{ + .replica_index = replica_index, + .replica_count = replica_count, + }, + ); + errdefer self.grid_repair_message_budget.deinit(allocator); + + assert(self.grid_repair_message_budget.available == + self.grid_repair_message_budget.capacity); + + if (self.solo()) { + assert(self.journal_repair_message_budget.capacity == 0); + assert(self.grid_repair_message_budget.capacity == 0); + } else { + assert(self.journal_repair_message_budget.capacity > 0); + + assert(self.grid_repair_message_budget.capacity > 0); + assert(self.grid_repair_message_budget.capacity >= + constants.grid_repair_request_max); + } + + assert(self.opened); + assert(self.superblock.opened); + self.superblock.working.vsr_state.assert_internally_consistent(); + + const quorums = vsr.quorums(replica_count); + const quorum_replication = quorums.replication; + const quorum_view_change = quorums.view_change; + const quorum_nack_prepare = quorums.nack_prepare; + const quorum_majority = quorums.majority; + assert(quorum_replication <= replica_count); + assert(quorum_view_change <= replica_count); + assert(quorum_nack_prepare <= replica_count); + assert(quorum_majority <= replica_count); + + if (replica_count <= 2) { + assert(quorum_replication == replica_count); + assert(quorum_view_change == replica_count); + } else { + assert(quorum_replication < replica_count); + assert(quorum_view_change < replica_count); + } + + // Flexible quorums are safe if these two quorums intersect so that this relation holds: + assert(quorum_replication + quorum_view_change > replica_count); + + const releases_bundled = options.multiversion.releases_bundled(); + releases_bundled.verify(); + assert(releases_bundled.contains(options.release)); + + const request_size_limit = + @sizeOf(Header) + options.state_machine_options.batch_size_limit; + assert(request_size_limit <= constants.message_size_max); + assert(request_size_limit > @sizeOf(Header)); + + const commit_stall_lag_min = options.commit_stall_lag_min orelse + constants.pipeline_prepare_queue_max; + const commit_stall_lag_max = options.commit_stall_lag_max orelse + 3 * constants.vsr_checkpoint_ops; + const commit_stall_multiple_max = options.commit_stall_multiple_max orelse + 4; + assert(commit_stall_lag_min <= commit_stall_lag_max); + assert(commit_stall_multiple_max > 0); + + // The clock is special-cased for standbys. We want to balance two concerns: + // - standby clock should never affect cluster time, + // - standby should have up-to-date clock, such that it can quickly join the cluster + // (or be denied joining if its clock is broken). + // + // To do this: + // - an active replica clock tracks only other active replicas, + // - a standby clock tracks active replicas and the standby itself. + self.clock = try Clock.init( + allocator, + time, + options.tracer, + if (replica_index < replica_count) .{ + .replica_count = replica_count, + .replica = replica_index, + .quorum = quorum_replication, + } else .{ + .replica_count = replica_count + 1, + .replica = replica_count, + .quorum = quorum_replication + 1, + }, + ); + errdefer self.clock.deinit(allocator); + + self.journal = try Journal.init(allocator, storage, replica_index); + errdefer self.journal.deinit(allocator); + + var client_sessions = try ClientSessions.init(allocator); + errdefer client_sessions.deinit(allocator); + + var client_sessions_checkpoint = try CheckpointTrailer.init( + allocator, + .client_sessions, + ClientSessions.encode_size, + ); + errdefer client_sessions_checkpoint.deinit(allocator); + + var client_replies = ClientReplies.init(.{ + .storage = storage, + .message_pool = message_pool, + .replica_index = replica_index, + }); + errdefer client_replies.deinit(); + + const stash_blocks_count = + constants.grid_iops_read_max + + constants.grid_repair_writes_max + + // Scans: *2 is for 1 index and 1 value block (per scan per level). + constants.lsm_scans_max * @as(u64, constants.lsm_levels) * 2 + + options.state_machine_options.lsm_forest_compaction_block_count + + vsr.checkpoint_trailer.block_count_for_trailer_size(ClientSessions.encode_size) + + Forest.manifest_log_compaction_pace.blocks_count() + + 1; // GridScrubber.tour_index_block + + self.grid = try Grid.init(allocator, .{ + .superblock = &self.superblock, + .trace = self.trace, + .cache_blocks_count = options.grid_cache_blocks_count, + .stash_blocks_count = stash_blocks_count, + .missing_blocks_max = constants.grid_missing_blocks_max, + .missing_tables_max = constants.grid_missing_tables_max, + .blocks_released_prior_checkpoint_durability_max = Forest + .compaction_blocks_released_per_pipeline_max() + + vsr.checkpoint_trailer.block_count_for_trailer_size(ClientSessions.encode_size), + }); + errdefer self.grid.deinit(allocator); + + for (&self.grid_repair_table_bitsets, 0..) |*bitset, i| { + errdefer for (self.grid_repair_table_bitsets[0..i]) |*b| b.deinit(allocator); + bitset.* = try std.DynamicBitSetUnmanaged + .initEmpty(allocator, constants.lsm_table_value_blocks_max); + } + errdefer for (&self.grid_repair_table_bitsets) |*b| b.deinit(allocator); + + for (&self.grid_repair_write_blocks, 0..) |*block, i| { + errdefer for (self.grid_repair_write_blocks[0..i]) |b| self.grid.block_unref(b); + block.* = self.grid.get_block(); + } + errdefer for (self.grid_repair_write_blocks) |b| self.grid.block_unref(b); + + try self.state_machine.init( + allocator, + time, + &self.grid, + options.state_machine_options, + ); + errdefer self.state_machine.deinit(allocator); + + self.grid_scrubber = try GridScrubber.init( + allocator, + &self.state_machine.forest, + &self.client_sessions_checkpoint, + ); + errdefer self.grid_scrubber.deinit(allocator); + + // Initialize the MessageBus last. This brings the time when the replica can be + // externally spoken to (ie, MessageBus will accept TCP connections) closer to the time + // when Replica is actually listening for messages and won't simply drop them. + // + // Specifically, the grid cache in Grid.init above can take a long period of time while + // faulting in. + self.message_bus = try MessageBus.init( + allocator, + .{ .replica = options.replica_index }, + message_pool, + Replica.on_messages_from_bus, + options.message_bus_options, + ); + errdefer self.message_bus.deinit(allocator); + + try self.message_bus.listen(); + + self.* = .{ + .static_allocator = self.static_allocator, + .cluster = options.cluster, + .replica_count = replica_count, + .standby_count = standby_count, + .node_count = node_count, + .replica = replica_index, + .pipeline_request_queue_limit = options.pipeline_requests_limit, + .request_size_limit = request_size_limit, + .quorum_replication = quorum_replication, + .quorum_view_change = quorum_view_change, + .quorum_nack_prepare = quorum_nack_prepare, + .quorum_majority = quorum_majority, + .release = options.release, + .release_client_min = options.release_client_min, + .multiversion = options.multiversion, + .commit_stall_probability = options.commit_stall_probability orelse + stdx.PRNG.ratio(2, 5), + .commit_stall_lag_min = commit_stall_lag_min, + .commit_stall_lag_max = commit_stall_lag_max, + .commit_stall_multiple_max = commit_stall_multiple_max, + .nonce = options.nonce, + .time_start = self.clock.realtime(), + .clock = self.clock, + .journal = self.journal, + .journal_repair_message_budget = self.journal_repair_message_budget, + .client_sessions = client_sessions, + .client_sessions_checkpoint = client_sessions_checkpoint, + .client_replies = client_replies, + .message_bus = self.message_bus, + .state_machine = self.state_machine, + .superblock = self.superblock, + .grid = self.grid, + .grid_repair_table_bitsets = self.grid_repair_table_bitsets, + .grid_repair_write_blocks = self.grid_repair_write_blocks, + .grid_repair_message_budget = self.grid_repair_message_budget, + .grid_scrubber = self.grid_scrubber, + .opened = self.opened, + .view = self.superblock.working.vsr_state.view, + .log_view = self.superblock.working.vsr_state.log_view, + .op = undefined, + .commit_min = self.superblock.working.vsr_state.checkpoint.header.op, + .commit_max = self.superblock.working.vsr_state.commit_max, + .pipeline = .{ .cache = .{ + .capacity = constants.pipeline_prepare_queue_max + + options.pipeline_requests_limit, + } }, + + .view_headers = vsr.Headers.ViewChangeArray.init( + self.superblock.working.view_headers().command, + self.superblock.working.view_headers().slice, + ), + .ping_timeout = Timeout{ + .name = "ping_timeout", + .id = replica_index, + .after = 1_000 / constants.tick_ms, + }, + .prepare_timeout = Timeout{ + .name = "prepare_timeout", + .id = replica_index, + .after = options.timeout_prepare_ticks orelse (250 / constants.tick_ms), + }, + .primary_abdicate_timeout = Timeout{ + .name = "primary_abdicate_timeout", + .id = replica_index, + .after = 10_000 / constants.tick_ms, + }, + .commit_message_timeout = Timeout{ + .name = "commit_message_timeout", + .id = replica_index, + .after = 500 / constants.tick_ms, + }, + .commit_fault = vsr.FaultDetector.init(.{ + .now = self.clock.monotonic(), + .interval_min = .ms(100), + // The interval converges to commit_message_timeout regardless of the network + // latency, but the initial interval after view change must encompass the worst + // expected network delay. + .interval_max = .ms(2_000), + }), + .exit_view_window_timeout = Timeout{ + .name = "exit_view_window_timeout", + .id = replica_index, + .after = 5_000 / constants.tick_ms, + }, + .exit_view_message_timeout = Timeout{ + .name = "exit_view_message_timeout", + .id = replica_index, + .after = 500 / constants.tick_ms, + }, + .view_change_status_timeout = Timeout{ + .name = "view_change_status_timeout", + .id = replica_index, + .after = 5_000 / constants.tick_ms, + }, + .join_view_message_timeout = Timeout{ + .name = "join_view_message_timeout", + .id = replica_index, + .after = 500 / constants.tick_ms, + }, + .get_view_message_timeout = Timeout{ + .name = "get_view_message_timeout", + .id = replica_index, + .after = 1_000 / constants.tick_ms, + }, + .journal_repair_timeout = Timeout{ + .name = "journal_repair_timeout", + .id = replica_index, + .after = 100 / constants.tick_ms, + }, + .repair_sync_timeout = Timeout{ + .name = "repair_sync_timeout", + .id = replica_index, + .after = 5_000 / constants.tick_ms, + }, + .grid_repair_timeout = Timeout{ + .name = "grid_repair_timeout", + .id = replica_index, + .after = options.timeout_grid_repair_message_ticks orelse + (100 / constants.tick_ms), + }, + .grid_scrub_timeout = Timeout{ + .name = "grid_scrub_timeout", + .id = replica_index, + // (`after` will be adjusted at runtime to tune the scrubber pace.) + .after = 500 / constants.tick_ms, + }, + .pulse_timeout = Timeout{ + .name = "pulse_timeout", + .id = replica_index, + .after = 100 / constants.tick_ms, + }, + .upgrade_timeout = Timeout{ + .name = "upgrade_timeout", + .id = replica_index, + .after = 5_000 / constants.tick_ms, + }, + .trace_emit_timeout = Timeout{ + .name = "trace_emit_timeout", + .id = replica_index, + .after = 10_000 / constants.tick_ms, + }, + .commit_stall_timeout = Timeout{ + .name = "commit_stall_timeout", + .id = replica_index, + // (`after` will be adjusted at runtime each time before it is started.) + .after = 10 / constants.tick_ms, + }, + .prng = stdx.PRNG.from_seed(@truncate(options.nonce)), + + .trace = self.trace, + .test_context = self.test_context, + .aof = options.aof, + .aof_recovery = options.aof_recovery, + }; + + log.info("{}: init: replica_count={} quorum_view_change={} quorum_replication={} " ++ + "release={}", .{ + self.log_prefix(), + self.replica_count, + self.quorum_view_change, + self.quorum_replication, + self.release, + }); + assert(self.status == .recovering); + } + + /// Free all memory and unref all messages held by the replica. + /// This does not deinitialize the Storage or Time. + pub fn deinit(self: *Replica, allocator: Allocator) void { + self.static_allocator.transition_from_static_to_deinit(); + + var grid_reads = self.grid_reads.iterate(); + while (grid_reads.next()) |read| self.message_bus.unref(read.message); + + for (self.grid_repair_write_blocks) |block| self.grid.block_unref(block); + for (&self.grid_repair_table_bitsets) |*bit_set| bit_set.deinit(allocator); + + self.grid_scrubber.deinit(allocator); + self.client_replies.deinit(); + self.client_sessions_checkpoint.deinit(allocator); + self.client_sessions.deinit(allocator); + self.journal.deinit(allocator); + self.journal_repair_message_budget.deinit(allocator); + self.clock.deinit(allocator); + self.state_machine.deinit(allocator); + self.superblock.deinit(allocator); + self.grid.deinit(allocator); + self.grid_repair_message_budget.deinit(allocator); + defer self.message_bus.deinit(allocator); + + switch (self.pipeline) { + inline else => |*pipeline| pipeline.deinit(self.message_bus.pool), + } + + if (self.loopback_queue) |loopback_message| { + assert(loopback_message.link.next == null); + self.message_bus.unref(loopback_message); + self.loopback_queue = null; + } + + if (self.commit_prepare) |message| { + assert(self.commit_stage != .idle); + self.message_bus.unref(message); + self.commit_prepare = null; + } + + if (self.sync_view) |message| self.message_bus.unref(message); + + for (self.join_view_from_all_replicas) |message| { + if (message) |m| self.message_bus.unref(m); + } + } + + pub fn invariants(self: *const Replica) void { + assert(self.journal.header_with_op(self.op) != null); + + assert((self.sync_tables == null) == (self.sync_tables_op_range == null)); + assert(self.commit_min <= self.op); + } + + /// Time is measured in logical ticks that are incremented on every call to tick(). + /// This eliminates a dependency on the system time and enables deterministic testing. + pub fn tick(self: *Replica) void { + self.trace.start(.loop_tick); + defer self.trace.stop(.loop_tick); + + assert(self.opened); + // Ensure that all asynchronous IO callbacks flushed the loopback queue as needed. + // If an IO callback queues a loopback message without flushing the queue then this will + // delay the delivery of messages (e.g. a prepare_ok from the primary to itself) and + // decrease throughput significantly. + assert(self.loopback_queue == null); + defer self.invariants(); + + if (self.message_bus.resume_needed()) { + // See fn suspend_message conditions. + assert(self.journal.writes.available() == 0 or + self.grid_repair_writes.available() == 0 or + self.syncing == .updating_checkpoint); + } + + if (self.status == .normal and !self.standby()) { + self.tick_normal_heartbeat_fault(); + } + + self.clock.tick(); + self.message_bus.tick(); + self.multiversion.tick(); + + const timeouts = .{ + .{ &self.ping_timeout, on_ping_timeout }, + .{ &self.prepare_timeout, on_prepare_timeout }, + .{ &self.primary_abdicate_timeout, on_primary_abdicate_timeout }, + .{ &self.commit_message_timeout, on_commit_message_timeout }, + .{ &self.exit_view_window_timeout, on_exit_view_window_timeout }, + .{ &self.exit_view_message_timeout, on_exit_view_message_timeout }, + .{ &self.view_change_status_timeout, on_view_change_status_timeout }, + .{ &self.join_view_message_timeout, on_join_view_message_timeout }, + .{ &self.get_view_message_timeout, on_get_view_message_timeout }, + .{ &self.journal_repair_timeout, on_journal_repair_timeout }, + + .{ &self.repair_sync_timeout, on_repair_sync_timeout }, + .{ &self.grid_repair_timeout, on_grid_repair_timeout }, + .{ &self.upgrade_timeout, on_upgrade_timeout }, + .{ &self.pulse_timeout, on_pulse_timeout }, + .{ &self.grid_scrub_timeout, on_grid_scrub_timeout }, + .{ &self.trace_emit_timeout, on_trace_emit_timeout }, + .{ &self.commit_stall_timeout, on_commit_stall_timeout }, + }; + + inline for (timeouts) |timeout| { + timeout[0].tick(); + } + inline for (timeouts) |timeout| { + if (timeout[0].fired()) timeout[1](self); + } + + // None of the on_timeout() functions above should send a message to this replica. + assert(self.loopback_queue == null); + } + + fn tick_normal_heartbeat_fault(self: *Replica) void { + assert(self.status == .normal); + assert(self.backup() or self.primary()); + assert(self.commit_fault.interval_min.to_ms() > 2 * constants.tick_ms); + assert(self.commit_fault.interval_ewma.to_ms() > 2 * constants.tick_ms); + + const now = self.clock.monotonic(); + const tardy = self.commit_fault.tardy(now); + if (tardy == .green) return; // Everything is fine! + + if (self.primary()) { + if (tardy == .red) { + // Can only happen if there's an abnormal delay between ticks. + log.warn( + "{}: tick_normal_heartbeat_fault: tick delayed (interval={} delay={})", + .{ + self.replica, + self.commit_fault.interval_ewma, + self.commit_fault.signal_last.elapsed(now), + }, + ); + } + // See FaultDetector smoothing test for why resetting the timeout is critical here. + self.commit_message_timeout.reset(); + self.send_commit(now); + } else { + assert(self.backup()); + if (tardy == .yellow) { + // A slight delay which could be caused by a natural drop in the load, + // so wait some more for a Commit message from the primary. + } else { + log.warn( + "{}: tick_normal_heartbeat_fault: heartbeat lost (interval={} delay={})", + .{ + self.replica, + self.commit_fault.interval_ewma, + self.commit_fault.signal_last.elapsed(now), + }, + ); + self.send_exit_view(); + self.commit_fault.signal(now); + } + } + assert(self.commit_fault.tardy(now) != .red); + } + + /// Called by the MessageBus to deliver a message to the replica. + fn on_messages_from_bus(message_bus: *MessageBus, buffer: *MessageBuffer) void { + const self: *Replica = @alignCast(@fieldParentPtr("message_bus", message_bus)); + self.on_messages(buffer); + } + + pub fn on_messages(self: *Replica, buffer: *MessageBuffer) void { + var message_count: u32 = 0; + var message_suspended_count: u32 = 0; + while (buffer.next_header()) |header| { + message_count += 1; + + if (header.cluster != self.cluster) { + buffer.invalidate(.header_cluster); + return; + } + + if (self.suspend_message(&header)) { + buffer.suspend_message(&header); + message_suspended_count += 1; + continue; + } + + const message = buffer.consume_message(self.message_bus.pool, &header); + defer self.message_bus.unref(message); + + assert(message.references == 1); + + // Avoid leaking sector padding for messages written to a block device: + if (message.header.command == .request or + message.header.command == .prepare or + message.header.command == .block or + message.header.command == .reply) + { + const sector_ceil = vsr.sector_ceil(message.header.size); + if (message.header.size != sector_ceil) { + assert(message.header.size < sector_ceil); + assert(message.buffer.len == constants.message_size_max); + @memset(message.buffer[message.header.size..sector_ceil], 0); + } + } + + if (message.header.into(.request)) |request_header| { + assert(request_header.client != 0 or self.aof_recovery); + } + + self.trace.count(.{ .replica_messages_in = .{ + .command = message.header.command, + } }, 1); + self.on_message(message); + } + + if (message_count > constants.bus_message_burst_warn_min) { + log.warn("{}: on_messages: message count={} suspended={}", .{ + self.log_prefix(), + message_count, + message_suspended_count, + }); + } + } + + // See fn tick for an assert to verify that we don't miss resumption. + fn suspend_message(self: *const Replica, header: *const Header) bool { + switch (header.into_any()) { + .prepare => |header_prepare| if (self.journal.writes.available() == 0) { + log.warn("{}: on_messages: suspending command=prepare " ++ + "op={} view={} checksum={x:0>32}", .{ + self.log_prefix(), + header_prepare.op, + header_prepare.view, + header_prepare.checksum, + }); + return true; + }, + .block => |header_block| { + if (self.grid_repair_writes.available() == 0 or + self.syncing == .updating_checkpoint) + { + log.warn("{}: on_messages: suspending command=block " ++ + "address={} checksum={x:0>32}", .{ + self.log_prefix(), + header_block.address, + header_block.checksum, + }); + return true; + } + }, + else => {}, + } + return false; + } + + fn on_message(self: *Replica, message: *Message) void { + assert(self.opened); + assert(self.loopback_queue == null); + assert(message.references > 0); + defer self.invariants(); + + log.debug("{}: on_message: view={} status={s} {}", .{ + self.log_prefix(), + self.view, + @tagName(self.status), + message.header, + }); + + if (constants.verify) { + assert(message.header.valid_checksum_body(message.body_used())); + } + + if (message.header.invalid()) |reason| { + log.warn("{}: on_message: invalid (command={}, {s})", .{ + self.log_prefix(), + message.header.command, + reason, + }); + return; + } + + // No client or replica should ever send a .reserved message. + assert(message.header.command != .reserved); + + if (message.header.cluster != self.cluster) { + log.warn("{}: on_message: wrong cluster (cluster must be {} not {})", .{ + self.log_prefix(), + self.cluster, + message.header.cluster, + }); + return; + } + + switch (self.syncing) { + .idle => {}, + .canceling_commit, .canceling_grid => { + // Ignore further messages until finishing (asynchronous) processing + // of sync View. This prevents our view number from jumping ahead of View. + assert(self.sync_view != null); + log.warn("{}: on_message: ignoring (syncing)", .{self.log_prefix()}); + return; + }, + .updating_checkpoint => {}, + } + + self.jump_view(message.header); + + assert(message.header.replica < self.node_count); + const message_any = message.into_any(); + + // Don't rely on header.peer_type, since it's about who sent the message rather than + // who created it. Handle messages that have explicitly been created by a client. + switch (message_any) { + inline .ping_client, .request => |m| { + if (m.header.client != 0) { + self.release_seen_client_min = @min( + message.header.release.value, + self.release_seen_client_min orelse message.header.release.value, + ); + self.release_seen_client_max = @max( + message.header.release.value, + self.release_seen_client_max orelse message.header.release.value, + ); + } + }, + else => {}, + } + + switch (message_any) { + .ping => |m| self.on_ping(m), + .pong => |m| self.on_pong(m), + .ping_client => |m| self.on_ping_client(m), + .request => |m| self.on_request(m), + .prepare => |m| self.on_prepare(m), + .prepare_ok => |m| self.on_prepare_ok(m), + .reply => |m| self.on_reply(m), + .commit => |m| self.on_commit(m), + .exit_view => |m| self.on_exit_view(m), + .join_view => |m| self.on_join_view(m), + .view => |m| self.on_view(m), + .get_view => |m| self.on_get_view(m), + .get_prepare => |m| self.on_get_prepare(m), + .get_headers => |m| self.on_get_headers(m), + .get_reply => |m| self.on_get_reply(m), + .headers => |m| self.on_headers(m), + .get_blocks => |m| self.on_get_blocks(m), + .block => |m| self.on_block(m), + // A replica should never handle misdirected messages intended for a client: + .pong_client, .eviction => { + log.warn("{}: on_message: misdirected message ({s})", .{ + self.log_prefix(), + @tagName(message.header.command), + }); + return; + }, + .reserved => unreachable, + .deprecated_12 => unreachable, + .deprecated_21 => unreachable, + .deprecated_22 => unreachable, + .deprecated_23 => unreachable, + } + + if (self.loopback_queue) |loopback_message| { + log.err("{}: on_message: on_{s}() queued a {s} loopback message with no flush", .{ + self.log_prefix(), + @tagName(message.header.command), + @tagName(loopback_message.header.command), + }); + // Any message handlers that loopback must take responsibility for the flush. + @panic("loopback message with no flush"); + } + } + + /// Pings are used by replicas to synchronise cluster time and to probe for network + /// connectivity. + fn on_ping(self: *Replica, message: *const Message.Ping) void { + assert(message.header.command == .ping); + if (self.status != .normal and self.status != .view_change) return; + + assert(self.status == .normal or self.status == .view_change); + + if (message.header.replica == self.replica) { + log.warn("{}: on_ping: misdirected message (self)", .{self.log_prefix()}); + return; + } + + self.send_header_to_replica(message.header.replica, @bitCast(Header.Pong{ + .command = .pong, + .cluster = self.cluster, + .replica = self.replica, + .view = self.view_durable(), // Don't drop pongs while the view is being updated. + .release = self.release, + // Copy the ping's monotonic timestamp to our pong and add our wall clock sample: + .ping_timestamp_monotonic = message.header.ping_timestamp_monotonic, + .pong_timestamp_wall = @bitCast(self.clock.realtime()), + })); + + if (message.header.replica < self.replica_count) { + const upgrade_targets = &self.upgrade_targets[message.header.replica]; + if (upgrade_targets.* == null or + (upgrade_targets.*.?.checkpoint <= message.header.checkpoint_op and + upgrade_targets.*.?.view <= message.header.view)) + { + upgrade_targets.* = .{ + .checkpoint = message.header.checkpoint_op, + .view = message.header.view, + .releases = .empty, + }; + + const releases = ping_message_release_list(message); + assert(releases.contains(message.header.release)); + + for (releases.slice()) |release| { + if (release.value > self.release.value) { + upgrade_targets.*.?.releases.push(release); + } + } + if (upgrade_targets.*.?.releases.count > 0) { + upgrade_targets.*.?.releases.verify(); + } + } + } + } + + fn on_pong(self: *Replica, message: *const Message.Pong) void { + assert(message.header.command == .pong); + if (message.header.replica == self.replica) { + log.warn("{}: on_pong: misdirected message (self)", .{self.log_prefix()}); + return; + } + + // Ignore clocks of standbys. + if (message.header.replica >= self.replica_count) return; + + const m0 = message.header.ping_timestamp_monotonic; + const t1: i64 = @bitCast(message.header.pong_timestamp_wall); + const m2 = self.clock.monotonic().ns; + + self.clock.learn(message.header.replica, m0, t1, m2); + if (self.clock.round_trip_time_median_ns()) |rtt_ns| { + self.prepare_timeout.set_rtt_ns(rtt_ns); + } + } + + /// Pings are used by clients to learn about the current view. + fn on_ping_client(self: *Replica, message: *const Message.PingClient) void { + assert(message.header.command == .ping_client); + assert(message.header.client != 0); + + if (self.ignore_ping_client(message)) return; + + self.send_header_to_client(message.header.client, @bitCast(Header.PongClient{ + .command = .pong_client, + .cluster = self.cluster, + .replica = self.replica, + .view = self.log_view_durable(), + .release = self.release, + .ping_timestamp_monotonic = message.header.ping_timestamp_monotonic, + })); + } + + /// When there is free space in the pipeline's prepare queue: + /// The primary advances op-number, adds the request to the end of the log, and updates + /// the information for this client in the client-table to contain the new request number. + /// Then it sends a ⟨Prepare v, m, n, k⟩ message to the other replicas, where v is the + /// current view-number, m is the message it received from the client, n is the op-number + /// it assigned to the request, and k is the commit-number. + /// Otherwise, when there is room in the pipeline's request queue: + /// The request is queued, and will be dequeued & prepared when the pipeline head commits. + /// Otherwise, drop the request. + fn on_request(self: *Replica, message: *Message.Request) void { + if (self.ignore_request_message(message)) return; + + assert(self.status == .normal); + assert(self.primary()); + assert(self.syncing == .idle); + assert(self.commit_min == self.commit_max); + assert(self.commit_max + self.pipeline.queue.prepare_queue.count == self.op); + + assert(message.header.command == .request); + assert(message.header.operation != .reserved); + assert(message.header.operation != .root); + assert(message.header.view <= self.view); // The client's view may be behind ours. + + if (self.aof_recovery) { + if (message.header.timestamp == 0) { + log.warn("{}: on_request: ignoring (timestamp=2; non-aof-recovery message):" ++ + "{}", .{ self.replica, message.header }); + log.warn("{}: on_request: if recovery is complete, " ++ + "restart replica without --aof-recovery", .{self.replica}); + return; + } + } else { + if (message.header.timestamp != 0) { + log.warn("{}: on_request: ignoring (timestamp!=0)", .{self.replica}); + return; + } + } + + // Messages with `client == 0` are sent from itself, setting `realtime` to zero + // so the StateMachine `{prepare,commit}_timestamp` will be used instead. + // Invariant: header.timestamp ≠ 0 only for AOF recovery, then we need to be + // deterministic with the timestamp being replayed. + + const realtime: i64 = if (message.header.client == 0) + @intCast(message.header.timestamp) + else + self.clock.realtime_synchronized() orelse { + if (!self.primary_abdicate_timeout.ticking) { + assert(!self.solo()); + self.primary_abdicate_timeout.start(); + } + log.warn("{}: on_request: dropping (clock not synchronized)", .{ + self.log_prefix(), + }); + return; + }; + + const request: Request = .{ + .message = message.ref(), + .realtime = realtime, + }; + + if (self.pipeline.queue.prepare_queue.full()) { + self.pipeline.queue.push_request(request); + } else { + self.primary_pipeline_prepare(request); + } + } + + /// Replication is simple, with a single code path for the primary and backups. + /// + /// The primary starts by sending a prepare message to itself. + /// + /// The primary broadcasts this prepare message to every other replica and standby, in + /// parallel to writing to its own journal. + /// + /// Star replication prioritizes latency: with Flexible Paxos the primary can commit after + /// the fastest prepare_ok quorum returns, without waiting for a ring of forwards. + /// + /// At the same time, asynchronous replication keeps going, so that if our local disk is + /// slow, then any latency spike will be masked by more remote prepare_ok messages as they + /// come in. This gives automatic tail latency tolerance for storage latency spikes. + /// + /// The remaining problem then is tail latency tolerance for network latency spikes. If + /// prepare_oks do not arrive in time, the primary's prepare timeout fires and resends to + /// all replicas still missing from the prepare's ack set. + fn on_prepare(self: *Replica, message: *Message.Prepare) void { + assert(message.header.command == .prepare); + assert(message.header.replica < self.replica_count); + assert(message.header.operation != .reserved); + + // Sanity check --- if the prepare is definitely from the current log-wrap, it should be + // appended. + defer { + if (self.status == .normal and self.syncing == .idle and + message.header.view == self.view and + message.header.op >= self.op_repair_min() and + message.header.op <= self.op_checkpoint_next_trigger()) + { + assert(self.journal.has_header(message.header)); + } + } + + // Replication balances two goals: + // - replicate anything that remote replicas are likely missing, + // - avoid a feedback loop of cascading needless replication. + // Replicate anything that we didn't previously have ourselves. + // + // Use `has_prepare` (checks whether a replica has both the header and the corresponding + // prepare) instead of `has_header` (checks whether the replica has the header). The + // latter is prone to a race where a replica that receives a future header before the + // corresponding prepare (via View, for instance) would not help repair the prepare. + if (message.header.op > self.commit_min and !self.journal.has_prepare(message.header)) { + self.replicate(message); + if (message.header.op > self.op) { + self.commit_fault.signal(self.clock.monotonic()); + } + } else { + log.warn("{}: on_prepare: not replicating op={} commit_min={} present={}", .{ + self.log_prefix(), + message.header.op, + self.commit_min, + self.journal.has_prepare(message.header), + }); + } + + if (self.syncing == .updating_checkpoint) { + log.warn("{}: on_prepare: ignoring (sync)", .{self.log_prefix()}); + return; + } + + if (message.header.view < self.view or + (self.status == .normal and + message.header.view == self.view and message.header.op <= self.op)) + { + log.debug("{}: on_prepare: ignoring (repair)", .{self.log_prefix()}); + self.on_repair(message); + return; + } + + if (self.status != .normal) { + log.warn("{}: on_prepare: ignoring ({})", .{ + self.log_prefix(), + self.status, + }); + return; + } + + if (message.header.view > self.view) { + log.warn("{}: on_prepare: ignoring (newer view)", .{self.log_prefix()}); + return; + } + + if (message.header.size > self.request_size_limit) { + // The replica needs to be restarted with a higher batch size limit. + log.err("{}: on_prepare: ignoring (large prepare, op={} size={} size_limit={})", .{ + self.log_prefix(), + message.header.op, + message.header.size, + self.request_size_limit, + }); + @panic("Cannot prepare; batch limit too low."); + } + + assert(self.status == .normal); + assert(message.header.view == self.view); + assert(self.primary() or self.backup()); + assert(message.header.replica == self.primary_index(message.header.view)); + assert(message.header.op > self.op_checkpoint()); + assert(message.header.op > self.op); + assert(message.header.op > self.commit_min); + + if (self.backup()) { + self.advance_commit_max(message.header.commit, @src()); + assert(self.commit_max >= message.header.commit); + } + defer { + if (self.backup()) { + self.commit_journal(); + self.repair(); + } + } + + if (message.header.op > self.commit_min + 2 * constants.pipeline_prepare_queue_max) { + log.warn("{}: on_prepare: lagging behind the cluster prepare.op={} " ++ + "(commit_min={} op={} commit_max={})", .{ + self.log_prefix(), + message.header.op, + self.commit_min, + self.op, + self.commit_max, + }); + } + + // Normally, we cache prepares between (commit_min, commit_min + cache.capacity] to + // avoid a disk read for these prepares during commit. However, if we are lagging and + // encounter a message from the next log wrap, we prefer caching prepares between + // (op_prepare_max, op_prepare_max + cache.capacity], to avoid repairing them over the + // network during the subsequent checkpoint. The rationale here is that local reads are + // cheaper than repairing prepares over the network. + const op_cache_min = if (message.header.op <= self.op_prepare_max()) + self.commit_min + 1 + else + self.op_prepare_max() + 1; + assert(message.header.op >= op_cache_min); + + if (self.backup() and + message.header.op < op_cache_min + self.pipeline.cache.capacity) + { + log.debug("{}: on_prepare: caching prepare.op={} " ++ + "(commit_min={} op={} commit_max={} prepare_max={})", .{ + self.log_prefix(), + message.header.op, + self.commit_min, + self.op, + self.commit_max, + self.op_prepare_max(), + }); + self.cache_prepare(message); + } + + // Verify that the new request will fit in the WAL. + if (message.header.op > self.op_prepare_max()) { + assert(self.backup()); + assert(vsr.Checkpoint.durable(self.op_checkpoint_next(), self.commit_max)); + if (message.header.op > @min( + // Committed ops can be safely overwritten. + self.commit_min + constants.journal_slot_count, + // Except op_checkpoint_next to op_checkpoint_next_trigger, which are required + // during upgrade (see `release_for_next_checkpoint`) and checkpoint (see + // `commit_checkpoint_superblock`), and can't be overwritten. + self.op_checkpoint_next() + constants.journal_slot_count - 1, + )) { + log.warn("{}: on_prepare: ignoring prepare.op={} " ++ + "(too far ahead, commit_min={} op={} commit_max={} prepare_max={})", .{ + self.log_prefix(), + message.header.op, + self.commit_min, + self.op, + self.commit_max, + self.op_prepare_max(), + }); + return; + } + } else { + if (message.header.checkpoint_id != self.superblock.working.checkpoint_id() and + message.header.checkpoint_id != + self.superblock.working.vsr_state.checkpoint.parent_checkpoint_id) + { + // Panic on encountering a prepare which does not match the expected checkpoint + // id. + // + // If this branch is hit, there is a storage determinism problem. At this point + // in the code it is not possible to distinguish whether the problem is with + // this replica, the prepare's replica, or both independently. + log.err("{}: on_prepare: checkpoint diverged " ++ + "(op={} expect={x:0>32} received={x:0>32} from={})", .{ + self.log_prefix(), + message.header.op, + self.superblock.working.checkpoint_id(), + message.header.checkpoint_id, + message.header.replica, + }); + + assert(self.backup()); + @panic("checkpoint diverged"); + } + } + + if (message.header.op > self.op + 1) { + log.debug("{}: on_prepare: newer op", .{self.log_prefix()}); + self.jump_to_newer_op_in_normal_status(message.header); + // "`replica.op` exists" invariant is temporarily broken. + assert(self.journal.header_with_op(message.header.op - 1) == null); + } + + if (self.journal.previous_entry(message.header)) |previous| { + // Any previous entry may be a whole journal's worth of ops behind due to wrapping. + // We therefore do not do any further op or checksum assertions beyond this: + self.panic_if_hash_chain_would_break_in_the_same_view(previous, message.header); + } + + // If we are going to overwrite an op from the previous WAL wrap, assert that it's part + // of a checkpoint that is durable on a commit quorum of replicas. See `op_repair_min` + // for when a checkpoint can be considered durable on a quorum of replicas. + const op_overwritten = (self.op + 1) -| constants.journal_slot_count; + const op_checkpoint_previous = self.op_checkpoint() -| + constants.vsr_checkpoint_ops; + if (op_overwritten > op_checkpoint_previous) { + assert(vsr.Checkpoint.durable(self.op_checkpoint(), self.commit_max)); + } + + // We must advance our op and set the header as dirty before replicating and + // journalling. The primary needs this before its journal is outrun by any + // prepare_ok quorum: + log.debug("{}: on_prepare: advancing: op={}..{} checksum={x:0>32}..{x:0>32}", .{ + self.log_prefix(), + self.op, + message.header.op, + message.header.parent, + message.header.checksum, + }); + assert(message.header.op == self.op + 1); + assert(message.header.op <= self.op_prepare_max() or + vsr.Checkpoint.durable(self.op_checkpoint_next(), self.commit_max)); + assert(message.header.op - self.op_repair_min() <= constants.journal_slot_count); + + self.op = message.header.op; + self.journal.set_header_as_dirty(message.header); + + self.append(message); + } + + fn on_prepare_ok(self: *Replica, message: *Message.PrepareOk) void { + assert(message.header.command == .prepare_ok); + if (self.ignore_prepare_ok(message)) return; + + assert(self.status == .normal); + assert(message.header.view == self.view); + assert(self.primary()); + assert(self.syncing == .idle); + + // Crucial to track commit_min for prepares outside the pipeline; + // this message may be from a lagging replica that was withholding + // prepare_oks (see `send_prepare_oks_after_checkpoint`). + // Also important for the case where backups are geographically + // far from the primary (for example multi-region deployments). + // In that case, prepare_ok for a prepare may arrive *after* it is + // outside of the primary's pipeline. + self.commit_mins[message.header.replica] = @max( + message.header.commit_min, + self.commit_mins[message.header.replica], + ); + self.head_ops[message.header.replica] = @max( + message.header.op, + self.head_ops[message.header.replica], + ); + + const prepare = self.pipeline.queue.prepare_by_prepare_ok(message) orelse { + // This can be normal, for example, if an old prepare_ok is replayed. + log.debug("{}: on_prepare_ok: not preparing op={} checksum={x:0>32}", .{ + self.log_prefix(), + message.header.op, + message.header.prepare_checksum, + }); + return; + }; + + assert(prepare.message.header.checksum == message.header.prepare_checksum); + assert(prepare.message.header.op >= self.commit_max + 1); + assert(prepare.message.header.op <= self.commit_max + + self.pipeline.queue.prepare_queue.count); + assert(prepare.message.header.op <= self.op); + + assert(prepare.message.header.checkpoint_id == message.header.checkpoint_id); + assert(prepare.message.header.checkpoint_id == + self.checkpoint_id_for_op(prepare.message.header.op).?); + + // Wait until we have a quorum of prepare_ok messages (including ourself). + const threshold = self.quorum_replication; + + if (!prepare.ok_from_all_replicas.is_set(message.header.replica)) { + self.primary_abdicating = false; + if (!prepare.ok_quorum_received) { + self.primary_abdicate_timeout.reset(); + } + } + + assert(message.header.commit_min >= message.header.op -| constants.journal_slot_count); + assert(message.header.commit_min <= + self.commit_min + constants.pipeline_prepare_queue_max); + + const count = self.count_message_and_receive_quorum_exactly_once( + &prepare.ok_from_all_replicas, + message, + threshold, + ) orelse return; + + // This is the first time we're receiving this prepare_ok, the + // commit_min in the message is guaranteed to be more up-to-date. + self.commit_mins[message.header.replica] = message.header.commit_min; + + assert(count == threshold); + assert(!prepare.ok_quorum_received); + prepare.ok_quorum_received = true; + + log.debug("{}: on_prepare_ok: quorum received, prepare_checksum={x:0>32}", .{ + self.log_prefix(), + prepare.message.header.checksum, + }); + + assert(self.prepare_timeout.ticking); + assert(self.primary_abdicate_timeout.ticking); + assert(!self.primary_abdicating); + if (self.primary_pipeline_pending()) |prepare_pending| { + assert(prepare != prepare_pending); + if (prepare.message.header.op < prepare_pending.message.header.op) { + self.prepare_timeout.reset(); + } + } else { + self.prepare_timeout.stop(); + self.primary_abdicate_timeout.stop(); + } + + self.commit_pipeline(); + } + + fn on_reply(self: *Replica, message: *Message.Reply) void { + assert(message.header.command == .reply); + assert(message.header.replica < self.replica_count); + + const entry = self.client_sessions.get(message.header.client) orelse { + log.debug("{}: on_reply: ignoring, client not in table (client={} request={})", .{ + self.log_prefix(), + message.header.client, + message.header.request, + }); + return; + }; + + if (message.header.checksum != entry.header.checksum) { + log.debug("{}: on_reply: ignoring, reply not in table (client={} request={})", .{ + self.log_prefix(), + message.header.client, + message.header.request, + }); + return; + } + + const slot = self.client_sessions.get_slot_for_header(message.header).?; + if (!self.client_replies.faulty.is_set(slot.index)) { + log.debug("{}: on_reply: ignoring, reply is clean (client={} request={})", .{ + self.log_prefix(), + message.header.client, + message.header.request, + }); + return; + } + + if (!self.client_replies.ready_sync()) { + log.debug("{}: on_reply: ignoring, busy (client={} request={})", .{ + self.log_prefix(), + message.header.client, + message.header.request, + }); + return; + } + + log.debug("{}: on_reply: repairing reply (client={} request={})", .{ + self.log_prefix(), + message.header.client, + message.header.request, + }); + + self.client_replies.write_reply(slot, message, .repair); + } + + /// Known issue: + /// TODO The primary should stand down if it sees too many retries in on_prepare_timeout(). + /// It's possible for the network to be one-way partitioned so that backups don't see the + /// primary as down, but neither can the primary hear from the backups. + fn on_commit(self: *Replica, message: *const Message.Commit) void { + assert(message.header.command == .commit); + assert(message.header.replica < self.replica_count); + + if (self.status != .normal) { + log.debug("{}: on_commit: ignoring ({})", .{ + self.log_prefix(), + self.status, + }); + return; + } + + if (message.header.view < self.view) { + log.debug("{}: on_commit: ignoring (older view)", .{self.log_prefix()}); + return; + } + + if (message.header.view > self.view) { + log.debug("{}: on_commit: ignoring (newer view)", .{self.log_prefix()}); + return; + } + + if (self.primary()) { + log.warn("{}: on_commit: misdirected message (primary)", .{self.log_prefix()}); + return; + } + + assert(self.status == .normal); + assert(self.backup()); + assert(message.header.view == self.view); + assert(message.header.replica == self.primary_index(message.header.view)); + + // Old/duplicate heartbeats don't count. + if (self.heartbeat_timestamp < message.header.timestamp_monotonic) { + self.heartbeat_timestamp = message.header.timestamp_monotonic; + self.commit_fault.signal(self.clock.monotonic()); + if (!self.standby()) { + self.exit_view_from_all_replicas.unset(self.replica); + } + } + + // We may not always have the latest commit entry but if we do our checksum must match: + if (self.journal.header_with_op(message.header.commit)) |commit_entry| { + if (commit_entry.checksum == message.header.commit_checksum) { + log.debug("{}: on_commit: checksum verified", .{self.log_prefix()}); + } else if (self.valid_hash_chain_between(message.header.commit, self.op)) { + @panic("commit checksum verification failed"); + } else { + // We may still be repairing after receiving the View message. + log.debug("{}: on_commit: skipping checksum verification", .{ + self.log_prefix(), + }); + } + } + + self.advance_commit_max(message.header.commit, @src()); + self.commit_journal(); + } + + fn on_repair(self: *Replica, message: *Message.Prepare) void { + assert(message.header.command == .prepare); + assert(self.syncing != .updating_checkpoint); + + if (self.status != .normal and self.status != .view_change) { + log.debug("{}: on_repair: ignoring ({})", .{ + self.log_prefix(), + self.status, + }); + return; + } + + if (message.header.view > self.view) { + log.debug("{}: on_repair: ignoring (newer view)", .{self.log_prefix()}); + return; + } + + if (self.status == .view_change and message.header.view == self.view) { + log.debug("{}: on_repair: ignoring (view started)", .{self.log_prefix()}); + return; + } + + if (self.status == .view_change and self.primary_index(self.view) != self.replica) { + log.debug("{}: on_repair: ignoring (view change, backup)", .{self.log_prefix()}); + return; + } + + if (self.status == .view_change and !self.join_view_quorum) { + log.debug("{}: on_repair: ignoring (view change, waiting for quorum)", .{ + self.log_prefix(), + }); + return; + } + + if (message.header.op > self.op) { + assert(message.header.view < self.view); + log.debug("{}: on_repair: ignoring (would advance self.op)", .{self.log_prefix()}); + return; + } + + if (message.header.release.value > self.release.value) { + // This case is possible if we advanced self.op to a prepare from the next + // checkpoint (which is on a higher version) via a View. + // This would be safe to prepare, but rejecting it simplifies assertions. + assert(message.header.op > self.op_checkpoint_next_trigger()); + + log.debug("{}: on_repair: ignoring (newer release)", .{self.log_prefix()}); + return; + } + + assert(self.status == .normal or self.status == .view_change); + assert(self.repairs_allowed()); + assert(message.header.view <= self.view); + assert(message.header.op <= self.op); // Repairs may never advance `self.op`. + + if (self.journal.has_prepare(message.header)) { + log.debug("{}: on_repair: ignoring (duplicate)", .{self.log_prefix()}); + + self.send_prepare_ok(message.header); + return self.flush_loopback_queue(); + } + + if (self.replica != self.primary_index(self.view) and + message.header.op > self.commit_min and + message.header.op <= self.commit_min + self.pipeline.cache.capacity) + { + self.cache_prepare(message); + } + + if (self.repair_header(message.header) and self.write_prepare(message)) { + assert(self.journal.has_dirty(message.header)); + + self.journal_repair_message_budget.increment( + message.header.op, + self.clock.monotonic(), + ); + + log.debug("{}: on_repair: repairing journal op={}", .{ + self.log_prefix(), + message.header.op, + }); + + // Write prepare adds it synchronously to in-memory pipeline cache. + // Optimistically start committing without waiting for the disk write to finish. + if (self.status == .normal and self.backup()) { + self.commit_journal(); + } + + // Initiate repair so `repair_header`/`get_prepare` network messages can be + // sent concurrently while writing this prepare. + self.repair(); + } + } + + fn on_exit_view(self: *Replica, message: *Message.ExitView) void { + assert(message.header.command == .exit_view); + if (self.ignore_exit_view_message(message)) return; + + assert(!self.solo()); + assert(self.status == .normal or self.status == .view_change); + assert(message.header.view == self.view); + + // Wait until we have a view-change quorum of messages (possibly including ourself). + // This ensures that we do not start a view-change while normal request processing + // is possible. + const threshold = self.quorum_view_change; + + self.exit_view_from_all_replicas.set(message.header.replica); + + if (self.replica != message.header.replica and + !self.exit_view_window_timeout.ticking) + { + self.exit_view_window_timeout.start(); + } + + const count = self.exit_view_from_all_replicas.count(); + assert(count <= threshold); + + if (count < threshold) { + log.debug("{}: on_exit_view: view={} waiting for quorum " ++ + "({}/{}; replicas={b:0>6})", .{ + self.log_prefix(), + self.view, + count, + threshold, + self.exit_view_from_all_replicas.bits, + }); + return; + } + log.info("{}: on_exit_view: view={} quorum received (replicas={b:0>6})", .{ + self.log_prefix(), + self.view, + self.exit_view_from_all_replicas.bits, + }); + + self.transition_to_view_change_status(self.view + 1); + assert(self.exit_view_from_all_replicas.empty()); + } + + /// JV serves two purposes: + /// + /// When the new primary receives a quorum of join_view messages from different + /// replicas (including itself), it sets its view number to that in the messages and selects + /// as the new log the one contained in the message with the largest v′; if several messages + /// have the same v′ it selects the one among them with the largest n. It sets its op number + /// to that of the topmost entry in the new log, sets its commit number to the largest such + /// number it received in the join_view messages, changes its status to normal, and + /// informs the other replicas of the completion of the view change by sending + /// ⟨View v, l, n, k⟩ messages to the other replicas, where l is the new log, n is the + /// op number, and k is the commit number. + /// + /// When a new backup receives a join_view message for a new view, it transitions to + /// that new view in view-change status and begins to broadcast its own JV. + fn on_join_view(self: *Replica, message: *Message.JoinView) void { + assert(message.header.command == .join_view); + if (self.ignore_view_change_message(message.base_const())) return; + + assert(!self.solo()); + assert(self.status == .view_change); + assert(self.syncing == .idle); + assert(!self.join_view_quorum); + assert(self.primary_index(self.view) == self.replica); + assert(message.header.view == self.view); + JVQuorum.verify_message(message); + + self.primary_receive_join_view(message); + + // Wait until we have a quorum of messages (including ourself): + assert(self.join_view_from_all_replicas[self.replica] != null); + assert(self.join_view_from_all_replicas[self.replica].?.header.checkpoint_op <= + self.op_checkpoint()); + JVQuorum.verify(self.join_view_from_all_replicas); + + // Store in a var so that `.complete_valid` can capture a mutable pointer in switch. + var headers = JVQuorum.quorum_headers( + self.join_view_from_all_replicas, + .{ + .quorum_nack_prepare = self.quorum_nack_prepare, + .quorum_view_change = self.quorum_view_change, + .replica_count = self.replica_count, + }, + ); + const op_head = switch (headers) { + .awaiting_quorum => { + log.debug( + "{}: on_join_view: view={} waiting for quorum", + .{ self.log_prefix(), self.view }, + ); + return; + }, + .awaiting_repair => { + log.mark.warn( + "{}: on_join_view: view={} quorum received, awaiting repair", + .{ self.log_prefix(), self.view }, + ); + self.primary_log_join_view_quorum("on_join_view"); + return; + }, + .complete_invalid => { + log.mark.err( + "{}: on_join_view: view={} quorum received, deadlocked", + .{ self.log_prefix(), self.view }, + ); + self.primary_log_join_view_quorum("on_join_view"); + return; + }, + .complete_valid => |*quorum_headers| quorum_headers.next().?.op, + }; + + log.info("{}: on_join_view: view={} quorum received", .{ + self.log_prefix(), + self.view, + }); + self.primary_log_join_view_quorum("on_join_view"); + + const op_checkpoint_max = + JVQuorum.op_checkpoint_max(self.join_view_from_all_replicas); + + // self.commit_max could be more up-to-date than the commit_max in our JV headers. + // For instance, if we checkpoint (and persist commit_max) in our superblock + // right before crashing, our persistent view_headers could still have an older + // commit_max. We could restart and use these view_headers as JV headers. + const commit_max = @max( + self.commit_max, + JVQuorum.commit_max(self.join_view_from_all_replicas), + ); + + // A lagging potential primary may forfeit the view change to allow a more up-to-date + // replica to step up as primary: + // * Unconditionally, when it is lagging by least a checkpoint and that checkpoint is + // durable. + // * Heuristically, when the maximum checkpoint in the cluster is not durable. The + // heuristic is simple - a lagging replica gives a more-up-date replica *one* chance + // to step up as primary before it attempts to step up as primary. + if (vsr.Checkpoint.durable(self.op_checkpoint_next(), commit_max) or + (op_checkpoint_max > self.op_checkpoint() and + (self.view - self.log_view < self.replica_count))) + { + // This serves a few purposes: + // 1. Availability: We pick a primary to minimize the number of WAL repairs, to + // minimize the likelihood of a repair-deadlock. + // 2. Optimization: The cluster does not need to wait for a lagging replicas before + // prepares/commits can resume. + // 3. Simplify repair: A new primary never needs to fast-forward to a new + // checkpoint. + + // As an optimization, jump directly to a view where the primary will have the + // cluster's latest checkpoint. + var v: u32 = 1; + const next_view = while (v < self.replica_count) : (v += 1) { + const next_view = self.view + v; + const next_primary = self.primary_index(next_view); + assert(next_primary != self.replica); + + if (self.join_view_from_all_replicas[next_primary]) |jv| { + assert(jv.header.replica == next_primary); + + const jv_checkpoint = jv.header.checkpoint_op; + if (jv_checkpoint == op_checkpoint_max) break next_view; + } + } else unreachable; + + log.mark.warn("{}: on_join_view: lagging primary; forfeiting " ++ + "(view={}..{} checkpoint={}..{})", .{ + self.log_prefix(), + self.view, + next_view, + self.op_checkpoint(), + op_checkpoint_max, + }); + self.transition_to_view_change_status(next_view); + } else { + assert(!self.join_view_quorum); + self.join_view_quorum = true; + + self.primary_set_log_from_join_view_messages(); + + // We aren't status=normal yet, but our headers from our prior log_view may have + // been replaced. If we participate in another JV (before reaching status=normal, + // which would update our log_view), we must disambiguate our (new) headers from the + // headers of any other replica with the same log_view so that the next primary can + // identify an unambiguous set of canonical headers. + self.log_view = self.view; + + assert(self.op == op_head); + assert(self.op >= self.commit_max); + assert(self.state_machine.prepare_timestamp >= + self.journal.header_with_op(self.op).?.timestamp); + + // Start repairs according to the CTRL protocol: + assert(!self.journal_repair_timeout.ticking); + self.journal_repair_timeout.start(); + self.repair(); + } + } + + // When other replicas receive the View message, they replace their log and + // checkpoint with the ones in the message, set their op number to that of the latest entry + // in the log, set their view number to the view number in the message, change their status + // to normal, and update the information in their client table. If there are non-committed + // operations in the log, they send a ⟨PrepareOk, v, n, i⟩ message to the primary; here n + // is the op-number. Then they execute all operations known to be committed that they + // haven’t executed previously, advance their commit number, and update the information in + // their client table. + fn on_view(self: *Replica, message: *Message.View) void { + assert(message.header.command == .view); + if (self.ignore_view_change_message(message.base_const())) return; + + if (self.status == .recovering_head) { + if (message.header.view > self.view or + message.header.op >= self.op_prepare_max() or + message.header.nonce == self.nonce) + { + // This View is guaranteed to have originated after the replica crash, + // it is safe to use to determine the head op. + } else { + log.mark.debug( + "{}: on_view: ignoring (recovering_head, nonce mismatch)", + .{self.log_prefix()}, + ); + return; + } + } + + assert(self.status == .view_change or + self.status == .normal or + self.status == .recovering_head); + switch (self.syncing) { + .idle, .updating_checkpoint => {}, + .canceling_commit, .canceling_grid => unreachable, + } + assert(self.sync_view == null); + assert(message.header.view >= self.view); + assert(message.header.replica != self.replica); + assert(message.header.replica == self.primary_index(message.header.view)); + assert(message.header.commit_max >= message.header.checkpoint_op); + assert(message.header.op >= message.header.commit_max); + assert(message.header.op - message.header.commit_max <= + constants.pipeline_prepare_queue_max); + + // The View message may be from a primary that hasn't yet committed up to + // its commit_max, and the commit_max may be from the primary's *next* checkpoint. + maybe(message.header.commit_max - message.header.checkpoint_op > + constants.vsr_checkpoint_ops + constants.lsm_compaction_ops); + + if (message.header.view == self.log_view and message.header.op < self.op) { + // We were already in this view prior to receiving the View. + assert(self.status == .normal or self.status == .recovering_head); + + log.debug("{}: on_view view={} (ignoring, old message)", .{ + self.log_prefix(), + self.log_view, + }); + return; + } + + if (self.status == .recovering_head) { + assert(message.header.view >= self.view); + self.view = message.header.view; + maybe(self.view == self.log_view); + } else { + if (self.view < message.header.view) { + self.transition_to_view_change_status(message.header.view); + } + + if (self.status == .normal) { + assert(self.backup()); + assert(self.view == self.log_view); + } + } + assert(self.view == message.header.view); + + // Logically, View atomically updates both the checkpoint state and the log suffix. + // Physically, updating the checkpoint is an asynchronous operation: it requires waiting + // for in-progress write IOPs to complete. If the checkpoint needs to be updated + // (set_checkpoint returns true), the replica doesn't update the journal here, and + // instead arranges that to happen after the checkpoint update. + if (self.on_view_set_checkpoint(message)) { + switch (self.syncing) { + .idle => { + assert(self.commit_stage == .checkpoint_data or + self.commit_stage == .checkpoint_superblock); + assert(self.sync_view == null); + }, + .updating_checkpoint => { + assert(self.sync_view == null); + }, + .canceling_commit, .canceling_grid => { + assert(self.sync_view == message); + }, + } + } else { + self.on_view_set_journal(message); + } + } + + fn on_view_set_checkpoint(self: *Replica, message: *Message.View) bool { + const view_checkpoint = view_message_checkpoint(message); + + if (vsr.Checkpoint.trigger_for_checkpoint(view_checkpoint.header.op)) |trigger| { + assert(message.header.commit_max >= trigger); + } + assert( + message.header.op <= vsr.Checkpoint.prepare_max_for_checkpoint( + vsr.Checkpoint.checkpoint_after(view_checkpoint.header.op), + ).?, + ); + assert( + message.header.commit_max <= vsr.Checkpoint.prepare_max_for_checkpoint( + vsr.Checkpoint.checkpoint_after(view_checkpoint.header.op), + ).?, + ); + + if (!vsr.Checkpoint.durable(self.op_checkpoint_next(), message.header.commit_max)) { + return false; + } + + // Cluster is at least two checkpoints ahead. Although View's checkpoint is not + // guaranteed to be durable on a quorum, it is safe to sync to it, because prepares in + // this replica's WAL are no longer needed. + const far_behind = vsr.Checkpoint.durable(self.op_checkpoint_next() + + constants.vsr_checkpoint_ops, message.header.commit_max); + // Cluster is on the next checkpoint, and that checkpoint is durable and is safe to + // sync to. Try to optimistically avoid state sync and prefer WAL repair, unless + // there's evidence that the repair can't be completed. + const likely_stuck = self.syncing == .idle and self.repair_stuck(); + + if (!far_behind and !likely_stuck) return false; + + // State sync: at this point, we know we want to replace our checkpoint + // with the one from this View. + + assert(message.header.commit_max > self.op_checkpoint_next_trigger()); + assert(view_checkpoint.header.op > self.op_checkpoint()); + + // If we are already checkpointing, let that finish first --- perhaps we won't + // need state sync after all. + if (self.commit_stage == .checkpoint_superblock) return true; + if (self.commit_stage == .checkpoint_data) return true; + if (self.syncing == .updating_checkpoint) return true; + + // Otherwise, cancel in progress commit and prepare to sync. + log.mark.debug( + \\{}: on_view_set_checkpoint: sync started view={} checkpoint={}..{} + , .{ + self.log_prefix(), + self.log_view, + self.op_checkpoint(), + view_checkpoint.header.op, + }); + + self.sync_start_from_committing(); + assert(self.syncing == .canceling_commit or self.syncing == .canceling_grid); + assert(self.sync_view == null); + self.sync_view = message.ref(); + return true; + } + + fn on_view_set_journal(self: *Replica, message: *const Message.View) void { + assert(!self.ignore_view_change_message(message.base_const())); + assert(self.status == .view_change or + self.status == .normal or + self.status == .recovering_head); + assert(self.sync_view == null); + assert(message.header.view == self.view); + assert(message.header.replica != self.replica); + assert(message.header.replica == self.primary_index(message.header.view)); + assert(message.header.commit_max >= message.header.checkpoint_op); + assert(message.header.op >= message.header.commit_max); + assert(message.header.op - message.header.commit_max <= + constants.pipeline_prepare_queue_max); + + const view_headers = view_message_headers(message); + assert(view_headers[0].op == message.header.op); + assert(view_headers[0].op >= view_headers[view_headers.len - 1].op); + assert(self.syncing == .idle or self.syncing == .updating_checkpoint); + + { + // Replace our log with the suffix from View. Transition to sync above guarantees + // that there's at least one message that fits the effective checkpoint, but some + // messages might be beyond its prepare_max. + maybe(view_headers[0].op > self.op_prepare_max_sync()); + + // Find the first message that fits, make it our new head. + for (view_headers) |*header| { + assert(header.commit <= message.header.commit_max); + + if (header.op <= self.op_prepare_max_sync()) { + if (self.log_view < self.view or + (self.log_view == self.view and header.op >= self.op)) + { + self.set_op_and_commit_max( + header.op, + message.header.commit_max, + @src(), + ); + assert(self.op == header.op); + assert(self.commit_max >= message.header.commit_max); + + if (self.syncing == .updating_checkpoint) { + // State sync can "truncate" the first batch of committed ops! + maybe(self.commit_min > + self.syncing.updating_checkpoint.header.op); + assert(self.commit_min <= constants.lsm_compaction_ops + + self.syncing.updating_checkpoint.header.op); + + self.commit_min = self.syncing.updating_checkpoint.header.op; + self.sync_wal_repair_progress = .{ + .commit_min = self.commit_min, + .advanced = true, + }; + } + assert(self.commit_min <= self.commit_max); + + break; + } + } + } else { + assert(self.log_view == self.view); + assert(self.op > self.op_prepare_max_sync()); + self.advance_commit_max(message.header.commit_max, @src()); + } + + for (view_headers) |*header| { + if (header.op <= self.op_prepare_max_sync()) { + self.replace_header(header); + } + } + } + + self.view_headers.replace(.view, view_headers); + assert(self.view_headers.array.get(0).view <= self.view); + assert(self.view_headers.array.get(0).op == message.header.op); + maybe(self.view_headers.array.get(0).op > self.op_prepare_max_sync()); + assert(self.view_headers.array.get(self.view_headers.array.count() - 1).op <= + self.op_prepare_max_sync()); + + switch (self.status) { + .view_change => { + self.transition_to_normal_from_view_change_status(message.header.view); + self.send_prepare_oks_after_view_change(); + self.commit_journal(); + }, + .recovering_head => { + self.transition_to_normal_from_recovering_head_status(message.header.view); + if (self.syncing == .updating_checkpoint) { + self.view_durable_update(); + } + self.commit_journal(); + }, + .normal => { + if (self.syncing == .updating_checkpoint) { + self.view_durable_update(); + } + }, + .recovering => unreachable, + } + + assert(self.status == .normal); + assert(message.header.view == self.log_view); + assert(message.header.view == self.view); + assert(self.backup()); + if (self.syncing == .updating_checkpoint) assert(self.view_durable_updating()); + + if (self.syncing == .idle) self.repair(); + } + + fn on_get_view( + self: *Replica, + message: *const Message.GetView, + ) void { + assert(message.header.command == .get_view); + if (self.ignore_repair_message(message.base_const())) return; + + assert(self.status == .normal); + assert(self.view == self.log_view); + assert(message.header.view == self.view); + assert(message.header.replica != self.replica); + assert(self.primary()); + + const view_message = self.create_view_message(message.header.nonce); + defer self.message_bus.unref(view_message); + + assert(view_message.header.command == .view); + assert(view_message.references == 1); + assert(view_message.header.view == self.view); + assert(view_message.header.op == self.op); + assert(view_message.header.commit_max == self.commit_max); + assert(view_message.header.nonce == message.header.nonce); + self.send_message_to_replica(message.header.replica, view_message); + } + + /// If the requested prepare has been guaranteed by this replica: + /// * Read the prepare from storage, and forward it to the replica that requested it. + /// * Otherwise send no reply — it isn't safe to nack. + /// If the requested prepare has *not* been guaranteed by this replica, then send a nack. + /// + /// A prepare is considered "guaranteed" by a replica if that replica has acknowledged it + /// to the cluster. The cluster sees the replica as an underwriter of a guaranteed + /// prepare. If a guaranteed prepare is found to by faulty, the replica must repair it + /// to restore durability. + fn on_get_prepare(self: *Replica, message: *const Message.GetPrepare) void { + assert(message.header.command == .get_prepare); + if (self.ignore_repair_message(message.base_const())) return; + + assert(self.node_count > 1); + maybe(self.status == .recovering_head); + assert(message.header.replica != self.replica); + + const checksum = blk: { + if (message.header.view == 0) { + break :blk message.header.prepare_checksum; + } + + assert(message.header.prepare_checksum == 0); + if (self.journal.header_with_op(message.header.prepare_op)) |header| { + break :blk header.checksum; + } else { + log.debug("{}: on_get_prepare: op={} missing", .{ + self.log_prefix(), + message.header.prepare_op, + }); + return; + } + }; + + // Try to serve the message directly from the pipeline. + // This saves us from going to disk. And we don't need to worry that the WAL's copy + // of an uncommitted prepare is lost/corrupted. + if (self.pipeline_prepare_by_op_and_checksum( + message.header.prepare_op, + checksum, + )) |prepare| { + log.debug("{}: on_get_prepare: op={} checksum={x:0>32} reply from pipeline", .{ + self.log_prefix(), + message.header.prepare_op, + checksum, + }); + self.send_message_to_replica(message.header.replica, prepare); + return; + } + + const slot = self.journal.slot_for_op(message.header.prepare_op); + // Consult `journal.prepare_checksums` (rather than `journal.headers`): + // the former may have the prepare we want — even if journal recovery marked the + // slot as faulty and left the in-memory header as reserved. + if (self.journal.prepare_inhabited[slot.index] and + self.journal.prepare_checksums[slot.index] == checksum) + { + // Improve availability by calling `read_prepare_with_op_and_checksum` instead + // of `read_prepare` — even if `journal.headers` contains the target message. + // The latter skips the read when the target prepare is present but dirty (e.g. + // it was recovered with decision=fix). + // TODO Do not reissue the read if we are already reading in order to send to + // this particular destination replica. + self.journal.read_prepare_with_op_and_checksum( + on_get_prepare_read, + .{ + .op = message.header.prepare_op, + .checksum = checksum, + .destination_replica = message.header.replica, + }, + ); + } else { + log.debug("{}: on_get_prepare: op={} checksum={x:0>32} missing", .{ + self.log_prefix(), + message.header.prepare_op, + checksum, + }); + } + } + + fn on_get_prepare_read( + self: *Replica, + prepare: ?*Message.Prepare, + options: Journal.Read.Options, + ) void { + const message = prepare orelse { + log.debug("{}: on_get_prepare_read: " ++ + "op={} checksum={x:0>32} prepare=null", .{ + self.log_prefix(), + options.op, + options.checksum, + }); + return; + }; + const destination_replica = options.destination_replica.?; + + assert(message.header.command == .prepare); + assert(destination_replica != self.replica); + assert(options.op == message.header.op); + assert(options.checksum == message.header.checksum); + + log.debug("{}: on_get_prepare_read: " ++ + "op={} checksum={x:0>32} sending to replica={}", .{ + self.log_prefix(), + message.header.op, + message.header.checksum, + destination_replica, + }); + + self.send_message_to_replica(destination_replica, message); + } + + fn on_get_headers(self: *Replica, message: *const Message.GetHeaders) void { + assert(message.header.command == .get_headers); + if (self.ignore_repair_message(message.base_const())) return; + + maybe(self.status == .recovering_head); + assert(message.header.replica != self.replica); + + const response = self.message_bus.get_message(.headers); + defer self.message_bus.unref(response); + + response.header.* = .{ + .command = .headers, + .cluster = self.cluster, + .replica = self.replica, + .view = self.view, + }; + + const op_min = message.header.op_min; + const op_max = message.header.op_max; + assert(op_max >= op_min); + + // We must add 1 because op_max and op_min are both inclusive: + const count_max: usize = @min(constants.get_headers_max, op_max - op_min + 1); + assert(count_max * @sizeOf(vsr.Header) <= constants.message_body_size_max); + + const count = self.journal.copy_latest_headers_between( + op_min, + op_max, + std.mem.bytesAsSlice( + Header.Prepare, + response.buffer[@sizeOf(Header)..][0 .. @sizeOf(Header) * count_max], + ), + ); + assert(count <= count_max); + + if (count == 0) { + log.debug("{}: on_get_headers: ignoring (op={}..{}, no headers)", .{ + self.log_prefix(), + op_min, + op_max, + }); + return; + } + + response.header.size = @intCast(@sizeOf(Header) * (1 + count)); + response.header.set_checksum_body(response.body_used()); + response.header.set_checksum(); + + // Assert that the headers are valid. + _ = message_body_as_prepare_headers(response.base_const()); + + self.send_message_to_replica(message.header.replica, response); + } + + fn on_get_reply(self: *Replica, message: *const Message.GetReply) void { + assert(message.header.command == .get_reply); + assert(message.header.reply_client != 0); + + if (self.ignore_repair_message(message.base_const())) return; + assert(message.header.replica != self.replica); + + const entry = self.client_sessions.get(message.header.reply_client) orelse { + log.debug("{}: on_get_reply: ignoring, client not in table", .{ + self.log_prefix(), + }); + return; + }; + assert(entry.header.client == message.header.reply_client); + + if (entry.header.checksum != message.header.reply_checksum) { + log.debug("{}: on_get_reply: ignoring, reply not in table " ++ + "(requested={x:0>32} stored={x:0>32})", .{ + self.log_prefix(), + message.header.reply_checksum, + entry.header.checksum, + }); + return; + } + assert(entry.header.size != @sizeOf(Header)); + assert(entry.header.op == message.header.reply_op); + + const slot = self.client_sessions.get_slot_for_header(&entry.header).?; + if (self.client_replies.read_reply_sync(slot, entry)) |reply| { + on_get_reply_read_callback( + &self.client_replies, + &entry.header, + reply, + message.header.replica, + ); + } else { + self.client_replies.read_reply( + slot, + entry, + on_get_reply_read_callback, + message.header.replica, + ) catch |err| switch (err) { + error.Busy => { + log.debug("{}: on_get_reply: ignoring, client_replies busy", .{ + self.log_prefix(), + }); + }, + }; + } + } + + fn on_get_reply_read_callback( + client_replies: *ClientReplies, + reply_header: *const Header.Reply, + reply_: ?*Message.Reply, + destination_replica: ?u8, + ) void { + const self: *Replica = @alignCast(@fieldParentPtr("client_replies", client_replies)); + const reply = reply_ orelse { + log.debug("{}: on_get_reply: reply not found for replica={} " ++ + "(op={} checksum={x:0>32})", .{ + self.log_prefix(), + destination_replica.?, + reply_header.op, + reply_header.checksum, + }); + + if (self.client_sessions.get_slot_for_header(reply_header)) |slot| { + self.client_replies.faulty.set(slot.index); + } + return; + }; + + assert(reply.header.command == .reply); + assert(reply.header.checksum == reply_header.checksum); + + log.debug("{}: on_get_reply: sending reply to replica={} " ++ + "(op={} checksum={x:0>32})", .{ + self.log_prefix(), + destination_replica.?, + reply_header.op, + reply_header.checksum, + }); + + self.send_message_to_replica(destination_replica.?, reply); + } + + fn on_headers(self: *Replica, message: *const Message.Headers) void { + assert(message.header.command == .headers); + if (self.ignore_repair_message(message.base_const())) return; + + assert(self.status == .normal or self.status == .view_change); + maybe(message.header.view == self.view); + assert(message.header.replica != self.replica); + + // We expect at least one header in the body, or otherwise no response to our request. + assert(message.header.size > @sizeOf(Header)); + + var op_min: ?u64 = null; + var op_max: ?u64 = null; + for (message_body_as_prepare_headers(message.base_const())) |*h| { + if (op_min == null or h.op < op_min.?) op_min = h.op; + if (op_max == null or h.op > op_max.?) op_max = h.op; + + _ = self.repair_header(h); + } + assert(op_max.? >= op_min.?); + + self.repair(); + } + + fn on_get_blocks(self: *Replica, message: *const Message.GetBlocks) void { + assert(message.header.command == .get_blocks); + + if (message.header.replica == self.replica) { + log.warn("{}: on_get_blocks: ignoring; misdirected message (self)", .{ + self.log_prefix(), + }); + return; + } + + if (self.standby()) { + log.warn("{}: on_get_blocks: ignoring; misdirected message (standby)", .{ + self.log_prefix(), + }); + return; + } + + if (self.grid.callback == .cancel) { + log.debug("{}: on_get_blocks: ignoring; canceling grid", .{self.log_prefix()}); + return; + } + + // TODO Rate limit replicas that keep requesting the same blocks (maybe via + // checksum_body?) to avoid unnecessary work in the presence of an asymmetric partition. + const requests = std.mem.bytesAsSlice(vsr.BlockRequest, message.body_used()); + assert(requests.len > 0); + + next_request: for (requests, 0..) |*request, i| { + assert(stdx.zeroed(&request.reserved)); + + var reads = self.grid_reads.iterate(); + while (reads.next()) |read| { + if (read.read.address == request.block_address and + read.read.checksum == request.block_checksum and + read.destination == message.header.replica) + { + log.debug("{}: on_get_blocks: ignoring block request;" ++ + " already reading (destination={} address={} checksum={x:0>32})", .{ + self.log_prefix(), + message.header.replica, + request.block_address, + request.block_checksum, + }); + continue :next_request; + } + } + + const read = self.grid_reads.acquire() orelse { + log.debug("{}: on_get_blocks: ignoring remaining blocks; busy " ++ + "(replica={} ignored={}/{})", .{ + self.log_prefix(), + message.header.replica, + requests.len - i, + requests.len, + }); + return; + }; + + log.debug("{}: on_get_blocks: reading block " ++ + "(replica={} address={} checksum={x:0>32})", .{ + self.log_prefix(), + message.header.replica, + request.block_address, + request.block_checksum, + }); + + const reply = self.message_bus.get_message(.block); + defer self.message_bus.unref(reply); + + read.* = .{ + .replica = self, + .destination = message.header.replica, + .read = undefined, + .message = reply.ref(), + }; + + self.grid.read_block( + .{ .from_local_storage = on_get_blocks_read_block }, + &read.read, + request.block_address, + request.block_checksum, + .{ .cache_read = true, .cache_write = false }, + ); + } + } + + fn on_get_blocks_read_block( + grid_read: *Grid.Read, + result: Grid.ReadBlockResult, + ) void { + const read: *BlockRead = @fieldParentPtr("read", grid_read); + const self = read.replica; + defer { + self.message_bus.unref(read.message); + self.grid_reads.release(read); + } + + assert(read.destination != self.replica); + + if (result != .valid) { + log.debug("{}: on_get_blocks: error: {s}: " ++ + "(destination={} address={} checksum={x:0>32})", .{ + self.log_prefix(), + @tagName(result), + read.destination, + grid_read.address, + grid_read.checksum, + }); + return; + } + + log.debug("{}: on_get_blocks: success: " ++ + "(destination={} address={} checksum={x:0>32})", .{ + self.log_prefix(), + read.destination, + grid_read.address, + grid_read.checksum, + }); + + stdx.copy_disjoint(.inexact, u8, read.message.buffer, result.valid); + + assert(read.message.header.command == .block); + assert(read.message.header.address == grid_read.address); + assert(read.message.header.checksum == grid_read.checksum); + assert(read.message.header.size <= constants.block_size); + + self.send_message_to_replica(read.destination, read.message); + } + + fn on_block(self: *Replica, message: *const Message.Block) void { + maybe(self.state_machine_opened); + assert(message.header.command == .block); + assert(message.header.size <= constants.block_size); + assert(message.header.address > 0); + assert(message.header.protocol <= vsr.Version); + maybe(message.header.protocol < vsr.Version); + assert(self.grid_repair_writes.available() > 0); + + if (self.release.value < message.header.release.value) { + log.debug("{}: on_block: ignoring; release={} (address={} checksum={x:0>32})", .{ + self.log_prefix(), + message.header.release, + message.header.address, + message.header.checksum, + }); + return; + } + + if (self.grid.callback == .cancel) { + assert(self.grid.read_global_queue.empty()); + + log.debug("{}: on_block: ignoring; grid is canceling " ++ + "(address={} checksum={x:0>32})", .{ + self.log_prefix(), + message.header.address, + message.header.checksum, + }); + return; + } + + const write = self.grid_repair_writes.acquire().?; + const write_index = self.grid_repair_writes.index(write); + const write_block: *BlockPtr = &self.grid_repair_write_blocks[write_index]; + assert(self.grid.block_references(write_block.*) == 1); + + stdx.copy_disjoint( + .inexact, + u8, + write_block.*, + message.buffer[0..message.header.size], + ); + + const grid_fulfill = self.grid.fulfill_block(write_block.*); + if (grid_fulfill) { + assert(!self.grid.free_set.is_free(message.header.address)); + + log.debug("{}: on_block: fulfilled address={} checksum={x:0>32} {s}", .{ + self.log_prefix(), + message.header.address, + message.header.checksum, + @tagName(message.header.block_type), + }); + } + + const grid_repair = + self.grid.repair_block_waiting(message.header.address, message.header.checksum); + if (grid_repair) { + assert(!self.grid.free_set.is_free(message.header.address)); + + log.debug("{}: on_block: repairing address={} checksum={x:0>32} {s}", .{ + self.log_prefix(), + message.header.address, + message.header.checksum, + @tagName(message.header.block_type), + }); + + write.* = .{ .replica = self }; + self.grid.repair_block(grid_repair_block_callback, &write.write, write_block); + } else { + self.grid_repair_writes.release(write); + // A recipient of fulfill_block() may be borrowing this block. + self.grid.block_unref(write_block.*); + write_block.* = self.grid.get_block(); + } + + if (grid_fulfill or grid_repair) { + self.grid_repair_message_budget.increment(.{ + .address = message.header.address, + .checksum = message.header.checksum, + }); + + if (self.grid_repair_message_budget.next_destination(&self.prng)) |replica_index| { + self.send_get_blocks(replica_index); + } + } else { + log.debug("{}: on_block: ignoring; block not needed " ++ + "(address={} checksum={x:0>32})", .{ + self.log_prefix(), + message.header.address, + message.header.checksum, + }); + } + } + + fn grid_repair_block_callback(grid_write: *Grid.Write) void { + const write: *BlockWrite = @fieldParentPtr("write", grid_write); + const self = write.replica; + const write_index = self.grid_repair_writes.index(write); + assert(self.grid.block_references(self.grid_repair_write_blocks[write_index]) == 1); + + defer { + self.grid_repair_writes.release(write); + } + + log.debug("{}: on_block: repair done address={}", .{ + self.log_prefix(), + grid_write.address, + }); + + self.sync_reclaim_tables(); + self.message_bus.resume_receive(); + } + + fn on_ping_timeout(self: *Replica) void { + self.ping_timeout.reset(); + + const message = self.message_bus.pool.get_message(.ping); + defer self.message_bus.unref(message); + + // Don't drop pings while the view is being updated. + const ping_view = self.view_durable(); + + const releases = self.multiversion.releases_bundled(); + releases.verify(); + assert(releases.contains(self.release)); + + message.header.* = Header.Ping{ + .command = .ping, + .size = @sizeOf(Header) + @sizeOf(vsr.Release) * constants.vsr_releases_max, + .cluster = self.cluster, + .replica = self.replica, + .view = ping_view, + .release = self.release, + .checkpoint_id = self.superblock.working.checkpoint_id(), + .checkpoint_op = self.op_checkpoint(), + .ping_timestamp_monotonic = self.clock.monotonic().ns, + .release_count = releases.count, + }; + + const ping_versions = std.mem.bytesAsSlice(vsr.Release, message.body_used()); + stdx.copy_disjoint( + .inexact, + vsr.Release, + ping_versions, + releases.slice(), + ); + @memset(ping_versions[releases.count..], vsr.Release.zero); + message.header.set_checksum_body(message.body_used()); + message.header.set_checksum(); + + assert(message.header.view <= self.view); + self.send_message_to_other_replicas_and_standbys(message.base()); + } + + fn on_prepare_timeout(self: *Replica) void { + // We will decide below whether to reset or backoff the timeout. + assert(self.status == .normal); + assert(self.primary()); + assert(self.prepare_timeout.ticking); + + const prepare = self.primary_pipeline_pending().?; + + if (self.solo()) { + // Replica=1 doesn't write prepares concurrently to avoid gaps in its WAL. + assert(self.journal.writes.executing() <= 1); + assert(self.journal.writes.executing() == 1 or + self.commit_stage != .idle or + self.client_replies.writes.executing() > 0); + + self.prepare_timeout.reset(); + return; + } + + // The list of remote replicas yet to send a prepare_ok: + var waiting: [constants.replicas_max]u8 = undefined; + var waiting_count: usize = 0; + for (1..self.replica_count) |ring_index| { + comptime assert(constants.replicas_max * 2 < std.math.maxInt(u8)); + const ring_index_u8: u8 = @intCast(ring_index); + const replica: u8 = (self.replica + ring_index_u8) % self.replica_count; + assert(replica != self.replica); + if (!prepare.ok_from_all_replicas.is_set(replica)) { + waiting[waiting_count] = replica; + waiting_count += 1; + } + } + + if (waiting_count == 0) { + assert(self.quorum_replication == self.replica_count); + assert(!prepare.ok_from_all_replicas.is_set(self.replica)); + assert(prepare.ok_from_all_replicas.count() == self.replica_count - 1); + assert(prepare.message.header.op <= self.op); + + self.prepare_timeout.reset(); + log.debug("{}: on_prepare_timeout: waiting for journal", .{self.log_prefix()}); + + // We may be slow and waiting for the write to complete. + // + // We may even have maxed out our IO depth and been unable to initiate the write, + // which can happen if `constants.pipeline_prepare_queue_max` exceeds + // `constants.journal_iops_write_max`. This can lead to deadlock for a cluster of + // one or two (if we do not retry here), since there is no other way for the primary + // to repair the dirty op because no other replica has it. + // + // Retry the write through `on_repair()` which will work out which is which. + // We do expect that the op would have been run through `on_prepare()` already. + self.on_repair(prepare.message); + return; + } + + self.prepare_timeout.backoff(&self.prng); + + assert(waiting_count < self.replica_count); + for (waiting[0..waiting_count]) |replica| { + assert(replica != self.replica); + + log.debug("{}: on_prepare_timeout: waiting for replica {}; replicating", .{ + self.log_prefix(), + replica, + }); + self.send_message_to_replica(replica, prepare.message); + } + } + + fn on_primary_abdicate_timeout(self: *Replica) void { + assert(self.status == .normal); + assert(self.primary()); + self.primary_abdicate_timeout.reset(); + if (self.solo()) return; + + log.warn("{}: on_primary_abdicate_timeout: abdicating (view={})", .{ + self.log_prefix(), + self.view, + }); + self.primary_abdicating = true; + } + + fn on_commit_message_timeout(self: *Replica) void { + self.commit_message_timeout.reset(); + + assert(self.status == .normal); + assert(self.primary()); + assert(self.commit_min == self.commit_max); + + self.send_commit(self.clock.monotonic()); + } + + fn on_exit_view_window_timeout(self: *Replica) void { + assert(self.status == .normal or self.status == .view_change); + assert(!self.exit_view_from_all_replicas.empty()); + assert(!self.solo()); + self.exit_view_window_timeout.stop(); + + if (self.standby()) return; + + // Don't reset our own EV; it will be reset if/when we receive a heartbeat. + const exit_view = self.exit_view_from_all_replicas.is_set(self.replica); + self.reset_quorum_exit_view(); + if (exit_view) self.exit_view_from_all_replicas.set(self.replica); + } + + fn on_exit_view_message_timeout(self: *Replica) void { + assert(self.status == .normal or self.status == .view_change); + self.exit_view_message_timeout.reset(); + + if (self.solo()) return; + if (self.standby()) return; + + if (self.exit_view_from_all_replicas.is_set(self.replica)) { + self.send_exit_view(); + } + } + + fn on_view_change_status_timeout(self: *Replica) void { + assert(self.status == .view_change); + assert(!self.solo()); + self.view_change_status_timeout.reset(); + + self.send_exit_view(); + } + + fn on_join_view_message_timeout(self: *Replica) void { + assert(self.status == .view_change); + assert(!self.solo()); + self.join_view_message_timeout.reset(); + + if (self.primary_index(self.view) == self.replica and self.join_view_quorum) { + // A primary in status=view_change with a complete JV quorum must be repairing — + // it does not need to signal other replicas. + assert(self.view == self.log_view); + } else { + assert(self.view > self.log_view); + self.send_join_view(); + } + } + + fn on_get_view_message_timeout(self: *Replica) void { + assert(self.status == .view_change); + assert(self.primary_index(self.view) != self.replica); + self.get_view_message_timeout.reset(); + + log.debug("{}: on_get_view_message_timeout: view={}", .{ + self.log_prefix(), + self.view, + }); + self.send_header_to_replica( + self.primary_index(self.view), + @bitCast(Header.GetView{ + .command = .get_view, + .cluster = self.cluster, + .replica = self.replica, + .view = self.view, + .nonce = self.nonce, + }), + ); + } + + fn on_journal_repair_timeout(self: *Replica) void { + assert(self.status == .normal or self.status == .view_change); + + self.journal_repair_timeout.reset_with_jitter(&self.prng); + self.journal_repair_message_budget.reap_expired_requests(self.clock.monotonic()); + self.repair(); + } + + fn on_repair_sync_timeout(self: *Replica) void { + assert(!self.solo()); + assert(self.status == .normal); + assert(self.backup()); + assert(self.repair_sync_timeout.ticking); + self.repair_sync_timeout.reset(); + + const commit_min_previous = self.sync_wal_repair_progress.commit_min; + assert(commit_min_previous <= self.commit_min); + self.sync_wal_repair_progress = .{ + .commit_min = self.commit_min, + .advanced = commit_min_previous < self.commit_min, + }; + + if (self.repair_stuck()) { + log.warn("{}: on_repair_sync_timeout: request sync; lagging behind cluster " ++ + "(op_head={} commit_min={} commit_max={} commit_stage={s})", .{ + self.log_prefix(), + self.op, + self.commit_min, + self.commit_max, + @tagName(self.commit_stage), + }); + self.send_header_to_replica( + self.primary_index(self.view), + @bitCast(Header.GetView{ + .command = .get_view, + .cluster = self.cluster, + .replica = self.replica, + .view = self.view, + .nonce = self.nonce, + }), + ); + } + } + + fn on_grid_repair_timeout(self: *Replica) void { + assert(self.grid_repair_timeout.ticking); + maybe(self.state_machine_opened); + + self.grid_repair_timeout.reset_with_jitter(&self.prng); + self.grid_repair_message_budget.reap_expired_requests(self.clock.monotonic()); + + if (self.grid.callback != .cancel) { + if (self.grid_repair_message_budget.next_destination(&self.prng)) |replica_index| { + self.send_get_blocks(replica_index); + } + } + } + + fn on_grid_scrub_timeout(self: *Replica) void { + assert(self.grid_scrub_timeout.ticking); + self.grid_scrub_timeout.reset(); + + if (!self.state_machine_opened) return; + if (self.syncing != .idle) return; + if (self.sync_tables != null) return; + assert(self.grid.callback != .cancel); + + assert(self.grid_scrub_timeout.after_dynamic != null); + self.grid_scrub_timeout.after_dynamic = std.math.clamp( + @divFloor( + constants.grid_scrubber_cycle_ticks, + @max(1, self.grid.free_set.count_acquired()), + ) * constants.grid_scrubber_reads_max, + constants.grid_scrubber_interval_ticks_min, + constants.grid_scrubber_interval_ticks_max, + ); + + while (self.grid.blocks_missing.repair_blocks_available() > 0) { + const fault = blk: { + while (self.grid_scrubber.read_result_next()) |result| { + if (result.status == .repair) { + break :blk result.block; + } + } else break; + }; + assert(!self.grid.free_set.is_free(fault.block_address)); + + log.warn("{}: on_grid_scrub_timeout: fault found: " ++ + "block_address={} block_checksum={x:0>32} block_type={s}", .{ + self.log_prefix(), + fault.block_address, + fault.block_checksum, + @tagName(fault.block_type), + }); + + self.grid.blocks_missing.repair_block( + fault.block_address, + fault.block_checksum, + ); + } + + for (0..constants.grid_scrubber_reads_max + 1) |_| { + const scrub_next = self.grid_scrubber.read_next(); + if (!scrub_next) { + if (self.grid_scrubber.tour == .done) self.grid_scrubber.wrap(); + break; + } + } else unreachable; + } + + fn on_trace_emit_timeout(self: *Replica) void { + assert(self.trace_emit_timeout.ticking); + self.trace_emit_timeout.reset(); + + self.trace.gauge(.replica_start, self.time_start); + self.trace.gauge(.replica_status, @intFromEnum(self.status)); + self.trace.gauge(.replica_view, self.view); + self.trace.gauge(.replica_log_view, self.log_view); + self.trace.gauge(.replica_op, self.op); + self.trace.gauge(.replica_op_checkpoint, self.op_checkpoint()); + self.trace.gauge(.replica_commit_min, self.commit_min); + self.trace.gauge(.replica_commit_max, self.commit_max); + self.trace.gauge(.replica_sync_stage, @intFromEnum(self.syncing)); + self.trace.gauge(.replica_sync_op_min, self.superblock.working.vsr_state.sync_op_min); + self.trace.gauge(.replica_sync_op_max, self.superblock.working.vsr_state.sync_op_max); + self.trace.gauge(.replica_commit_timestamp, self.state_machine.commit_timestamp); + self.trace.gauge(.journal_dirty, self.journal.dirty.count); + self.trace.gauge(.journal_faulty, self.journal.faulty.count); + self.trace.gauge(.grid_blocks_missing, self.grid.blocks_missing.faulty_blocks.count()); + self.trace.gauge(.grid_cache_hits, self.grid.cache.metrics.hits); + self.trace.gauge(.grid_cache_misses, self.grid.cache.metrics.misses); + self.trace.gauge(.lsm_nodes_free, self.state_machine.forest.node_pool.free.count()); + self.trace.gauge(.release, self.release.value); + + self.trace.gauge( + .grid_blocks_acquired, + if (self.grid.free_set.opened) self.grid.free_set.count_acquired() else 0, + ); + + self.trace.gauge( + .replica_pipeline_queue_length, + switch (self.pipeline) { + .cache => |_| 0, + .queue => |*queue| queue.prepare_queue.count + queue.request_queue.count, + }, + ); + self.trace.gauge( + .lsm_manifest_block_count, + self.superblock.working.vsr_state.checkpoint.manifest_block_count, + ); + + if (self.release_seen_client_min) |release_seen_client_min| { + self.trace.gauge(.release_seen_client_min, release_seen_client_min); + } + if (self.release_seen_client_max) |release_seen_client_max| { + self.trace.gauge(.release_seen_client_max, release_seen_client_max); + } + self.release_seen_client_min = null; + self.release_seen_client_max = null; + + self.message_bus.trace_gauge(); + + self.trace.emit_metrics(); + } + + fn on_pulse_timeout(self: *Replica) void { + assert(!self.aof_recovery); + assert(self.status == .normal); + assert(self.primary()); + assert(self.pulse_timeout.ticking); + + self.pulse_timeout.reset(); + if (self.pipeline.queue.full()) return; + if (!self.pulse_enabled()) return; + + // To decide whether or not to `pulse` a time-dependant + // operation, the State Machine needs an updated `prepare_timestamp`. + const realtime = self.clock.realtime(); + const timestamp = @max( + self.state_machine.prepare_timestamp, + @as(u64, @intCast(realtime)), + ); + + if (self.state_machine.pulse_needed(timestamp)) { + self.state_machine.prepare_timestamp = timestamp; + if (self.view_durable_updating()) { + log.debug("{}: on_pulse_timeout: ignoring (still persisting view)", .{ + self.log_prefix(), + }); + } else { + self.send_request_pulse_to_self(); + } + } + } + + fn on_upgrade_timeout(self: *Replica) void { + assert(self.primary()); + assert(self.upgrade_timeout.ticking); + + self.upgrade_timeout.reset(); + + if (self.upgrade_release) |upgrade_release| { + // Already upgrading. + // Normally we chain send-upgrade-to-self via the commit chain. + // But there are a couple special cases where we need to restart the chain: + // - The request-to-self might have been dropped if the clock is not synchronized. + // - Alternatively, if a primary starts a new view, and an upgrade is already in + // progress, it needs to start preparing more upgrades. + const release_next = self.release_for_next_checkpoint(); + if (release_next == null or release_next.?.value != upgrade_release.value) { + if (self.view_durable_updating()) { + log.debug("{}: on_upgrade_timeout: ignoring (still persisting view)", .{ + self.log_prefix(), + }); + } else { + self.send_request_upgrade_to_self(); + } + } else { + // (Don't send an upgrade to ourself if we are already ready to upgrade and just + // waiting on the last commit + checkpoint before we restart.) + assert(self.commit_stage != .idle); + } + return; + } + + const release_target: ?vsr.Release = release: { + var release_target: ?vsr.Release = null; + const releases = self.multiversion.releases_bundled(); + for (releases.slice(), 0..) |release, i| { + if (i > 0) assert(release.value > releases.slice()[i - 1].value); + // Ignore old releases. + if (release.value <= self.release.value) continue; + + var release_replicas: usize = 1; // Count ourself. + for (self.upgrade_targets, 0..) |targets_or_null, replica| { + const targets = targets_or_null orelse continue; + assert(replica != self.replica); + + release_replicas += @intFromBool(targets.releases.contains(release)); + } + + if (release_replicas >= vsr.quorums(self.replica_count).upgrade) { + release_target = release; + } + } + break :release release_target; + }; + + if (release_target) |release_target_| { + log.info("{}: on_upgrade_timeout: upgrading from release={}..{}", .{ + self.log_prefix(), + self.release, + release_target_, + }); + + // If there is already an UpgradeRequest in our pipeline, + // ignore_request_message_duplicate() will ignore this one. + const upgrade = vsr.UpgradeRequest{ .release = release_target_ }; + self.send_request_to_self(.upgrade, std.mem.asBytes(&upgrade)); + } else { + // One of: + // - We are on the latest version. + // - There is a newer version available, but not on enough replicas. + } + } + + fn on_commit_stall_timeout(self: *Replica) void { + assert(self.commit_stall_timeout.ticking); + assert(self.commit_stage == .stall); + + self.commit_stall_timeout.stop(); + self.commit_dispatch_resume(); + } + + fn primary_receive_join_view( + self: *Replica, + message: *Message.JoinView, + ) void { + assert(!self.solo()); + assert(self.status == .view_change); + assert(self.primary_index(self.view) == self.replica); + assert(self.join_view_from_all_replicas.len == constants.replicas_max); + + assert(message.header.command == .join_view); + assert(message.header.cluster == self.cluster); + assert(message.header.replica < self.replica_count); + assert(message.header.view == self.view); + + const command: []const u8 = @tagName(message.header.command); + + if (self.join_view_from_all_replicas[message.header.replica]) |m| { + // Assert that this is a duplicate message and not a different message: + assert(m.header.command == message.header.command); + assert(m.header.replica == message.header.replica); + assert(m.header.view == message.header.view); + assert(m.header.op == message.header.op); + assert(m.header.checksum_body == message.header.checksum_body); + + // Replicas don't resend `join_view` messages to themselves. + assert(message.header.replica != self.replica); + // A replica may resend a `join_view` with a different checkpoint or commit + // if it was checkpointing/committing originally. + // Keep the one with the highest checkpoint, then commit. + // This is *not* necessary for correctness. + if (m.header.checkpoint_op < message.header.checkpoint_op or + (m.header.checkpoint_op == message.header.checkpoint_op and + m.header.commit_min < message.header.commit_min)) + { + log.debug("{}: on_{s}: replacing " ++ + "(newer message replica={} checkpoint={}..{} commit={}..{})", .{ + self.log_prefix(), + command, + message.header.replica, + m.header.checkpoint_op, + message.header.checkpoint_op, + m.header.commit_min, + message.header.commit_min, + }); + // TODO(Buggify): skip updating the JV, since it isn't required for + // correctness. + self.message_bus.unref(m); + self.join_view_from_all_replicas[message.header.replica] = message.ref(); + } else if (m.header.checkpoint_op != message.header.checkpoint_op or + m.header.commit_min != message.header.commit_min or + m.header.nack_bitset != message.header.nack_bitset or + m.header.present_bitset != message.header.present_bitset) + { + log.debug("{}: on_{s}: ignoring (older message replica={})", .{ + self.log_prefix(), + command, + message.header.replica, + }); + } else { + assert(m.header.checksum == message.header.checksum); + } + + log.debug("{}: on_{s}: ignoring (duplicate message replica={})", .{ + self.log_prefix(), + command, + message.header.replica, + }); + } else { + // Record the first receipt of this message: + assert(self.join_view_from_all_replicas[message.header.replica] == null); + self.join_view_from_all_replicas[message.header.replica] = message.ref(); + } + } + + fn count_message_and_receive_quorum_exactly_once( + self: *Replica, + counter: *QuorumCounter, + message: *Message.PrepareOk, + threshold: u32, + ) ?usize { + assert(threshold >= 1); + assert(threshold <= self.replica_count); + + assert(counter.capacity() == constants.replicas_max); + assert(message.header.cluster == self.cluster); + assert(message.header.replica < self.replica_count); + assert(message.header.view == self.view); + + switch (message.header.command) { + .prepare_ok => { + if (self.replica_count <= 2) assert(threshold == self.replica_count); + + assert(self.status == .normal); + assert(self.primary()); + }, + else => unreachable, + } + + const command: []const u8 = @tagName(message.header.command); + + // Do not allow duplicate messages to trigger multiple passes through a state + // transition: + if (counter.is_set(message.header.replica)) { + log.debug("{}: on_{s}: ignoring (duplicate message replica={})", .{ + self.log_prefix(), + command, + message.header.replica, + }); + return null; + } + + // Record the first receipt of this message: + counter.set(message.header.replica); + assert(counter.is_set(message.header.replica)); + + // Count the number of unique messages now received: + const count = counter.count(); + log.debug("{}: on_{s}: {} message(s)", .{ self.log_prefix(), command, count }); + assert(count <= self.replica_count); + + // Wait until we have exactly `threshold` messages for quorum: + if (count < threshold) { + log.debug("{}: on_{s}: waiting for quorum", .{ self.log_prefix(), command }); + return null; + } + + // This is not the first time we have had quorum, the state transition has already + // happened: + if (count > threshold) { + log.debug("{}: on_{s}: ignoring (quorum received already)", .{ + self.log_prefix(), + command, + }); + return null; + } + + assert(count == threshold); + return count; + } + + /// Caller must ensure that: + /// - op=commit is indeed committed by the cluster, + /// - local WAL doesn't contain truncated prepares from finished views. + fn advance_commit_max(self: *Replica, commit: u64, source: SourceLocation) void { + defer { + assert(self.commit_max >= commit); + assert(self.commit_max >= self.commit_min); + assert(self.commit_max >= self.op -| constants.pipeline_prepare_queue_max); + if (self.status == .normal and self.primary()) { + assert(self.commit_max == self.commit_min); + assert(self.commit_max == self.op - self.pipeline.queue.prepare_queue.count); + } + } + + if (commit > self.commit_max) { + log.debug("{}: {s}: advancing commit_max={}..{}", .{ + self.log_prefix(), + source.fn_name, + self.commit_max, + commit, + }); + self.commit_max = commit; + } + } + + fn append(self: *Replica, message: *Message.Prepare) void { + assert(self.status == .normal); + assert(message.header.command == .prepare); + assert(message.header.operation != .reserved); + assert(message.header.view == self.view); + assert(message.header.op == self.op); + assert(message.header.op <= self.op_prepare_max() or + vsr.Checkpoint.durable(self.op_checkpoint_next(), self.commit_max)); + + if (self.solo() and self.pipeline.queue.prepare_queue.count > 1) { + // In a cluster-of-one, the prepares must always be written to the WAL sequentially + // (never concurrently). This ensures that there will be no gaps in the WAL during + // crash recovery. + log.debug("{}: append: serializing append op={}", .{ + self.log_prefix(), + message.header.op, + }); + } else { + log.debug("{}: append: appending to journal op={}", .{ + self.log_prefix(), + message.header.op, + }); + + _ = self.write_prepare(message); + } + } + + /// Returns whether `b` succeeds `a` by having a newer view or same view and newer op. + fn ascending_viewstamps( + a: *const Header.Prepare, + b: *const Header.Prepare, + ) bool { + assert(a.command == .prepare); + assert(b.command == .prepare); + assert(a.operation != .reserved); + assert(b.operation != .reserved); + + if (a.view < b.view) { + // We do not assert b.op >= a.op, ops may be reordered during a view change. + return true; + } else if (a.view > b.view) { + // We do not assert b.op <= a.op, ops may be reordered during a view change. + return false; + } else if (a.op < b.op) { + assert(a.view == b.view); + return true; + } else if (a.op > b.op) { + assert(a.view == b.view); + return false; + } else { + unreachable; + } + } + + /// Choose a different replica each time if possible (excluding ourself). + /// + /// Currently this picks the target replica at random instead of doing something like + /// round-robin in order to avoid a resonance. + fn choose_any_other_replica(self: *Replica) u8 { + assert(!self.solo()); + comptime assert(constants.members_max * 2 < std.math.maxInt(u8)); + + // Carefully select any replica if we are a standby, + // and any different replica if we are active. + const pool_count = if (self.standby()) self.replica_count else self.replica_count - 1; + assert(pool_count > 0); + const shift = 1 + self.prng.int_inclusive(u8, pool_count - 1); + const other_replica = @mod(self.replica + shift, self.replica_count); + assert(other_replica != self.replica); + return other_replica; + } + + /// Commits, frees and pops as many prepares at the head of the pipeline as have quorum. + /// Can be called only when the replica is the primary. + /// Can be called only when the pipeline has at least one prepare. + fn commit_pipeline(self: *Replica) void { + assert(self.status == .normal); + assert(self.primary()); + assert(self.pipeline.queue.prepare_queue.count > 0); + assert(self.syncing == .idle); + + if (!self.state_machine_opened) { + assert(self.commit_stage == .idle); + return; + } + + // Guard against multiple concurrent invocations of commit_journal()/commit_pipeline(): + if (self.commit_stage != .idle) { + log.debug("{}: commit_pipeline: already committing ({s}; commit_min={})", .{ + self.log_prefix(), + @tagName(self.commit_stage), + self.commit_min, + }); + return; + } + + assert(self.commit_stage == .idle); + self.commit_dispatch_enter(); + } + + fn commit_journal(self: *Replica) void { + assert(self.status == .normal or self.status == .view_change or + (self.status == .recovering and self.solo())); + assert(!(self.status == .normal and self.primary())); + assert(self.commit_min <= self.commit_max); + assert(self.commit_min <= self.op); + maybe(self.commit_max > self.op); + + // We have already committed this far: + if (self.commit_max == self.commit_min) return; + + if (!self.state_machine_opened) { + assert(self.commit_stage == .idle); + return; + } + + if (self.syncing != .idle) return; + + // Guard against multiple concurrent invocations of commit_journal()/commit_pipeline(): + if (self.commit_stage != .idle) { + log.debug("{}: commit_journal: already committing ({s}; commit_min={})", .{ + self.log_prefix(), + @tagName(self.commit_stage), + self.commit_min, + }); + return; + } + + // We check the hash chain before we read each op, rather than once upfront, because + // it's possible for `commit_max` to change while we read asynchronously, after we + // validate the hash chain. + // + // We therefore cannot keep committing until we reach `commit_max`. We need to verify + // the hash chain before each read. Once verified (before the read) we can commit in the + // callback after the read, but if we see a change we need to stop committing any + // further ops, because `commit_max` may have been bumped and may refer to a different + // op. + + assert(self.commit_stage == .idle); + self.commit_dispatch_enter(); + } + + /// Commit flow. + /// + /// This is a manual desugaring of asynchronous function of the following shape: + /// + /// loop { + /// prefetch().await; + /// execute().await; + /// compact().await; + /// } + /// + /// - commit_dispatch_enter starts the loop. + /// - for asynchronous operations, return from the loop and arrange for + /// commit_dispatch_resume to be called when IO is done. + /// - commit_dispatch_resume restarts the loop from the middle. + /// - at the end of the loop, wrap around and try to commit the next prepare + /// - if there's nothing to commit, break out of the loop. + /// + /// Commit process can be cancelled if replica decides to state sync. Cancellation process: + /// - wait for 'write' IO to complete, + /// - stop the loop from progressing, + /// - wait until all in-flight 'read' IO is cancelled (grid.cancel), + /// - reset commit_stage (commit_dispatch_cancel). + fn commit_dispatch(self: *Replica) void { + assert(!self.commit_dispatch_entered); + self.commit_dispatch_entered = true; + + if (self.syncing == .canceling_commit) { + switch (self.commit_stage) { + .start, + .reply_setup, + .stall, + .checkpoint_durable, + .checkpoint_data, + .checkpoint_superblock, + => { + self.sync_dispatch(.canceling_grid); + return; + }, + .idle, + .check_prepare, + .prefetch, + .execute, + .compact, + => unreachable, + } + } + + // Safety counter: the loop supports fully synchronous commits, but checkpoints must be + // asynchronous. + for (0..constants.vsr_checkpoint_ops) |_| { + if (self.commit_stage == .idle) { + self.commit_stage = .start; + assert(self.commit_prepare == null); + if (self.commit_start() == .pending) return; + } + + if (self.commit_stage == .start) { + self.commit_stage = .check_prepare; + if (self.commit_prepare == null) break; + } + assert(self.commit_prepare != null); + + if (self.commit_stage == .check_prepare) { + self.commit_stage = .prefetch; + + self.commit_started = self.clock.monotonic(); + self.trace.start(.{ .replica_commit = .{ + .stage = self.commit_stage, + .op = self.commit_prepare.?.header.op, + } }); + if (self.commit_prefetch() == .pending) return; + } + + if (self.commit_stage == .prefetch) { + self.trace.stop(.{ .replica_commit = .{ .stage = self.commit_stage } }); + self.commit_stage = .stall; + self.trace.start(.{ .replica_commit = .{ + .stage = self.commit_stage, + .op = self.commit_prepare.?.header.op, + } }); + + if (self.commit_stall() == .pending) return; + } + + if (self.commit_stage == .stall) { + self.trace.stop(.{ .replica_commit = .{ .stage = self.commit_stage } }); + self.commit_stage = .reply_setup; + self.trace.start(.{ .replica_commit = .{ + .stage = self.commit_stage, + .op = self.commit_prepare.?.header.op, + } }); + if (self.commit_reply_setup() == .pending) return; + } + + if (self.commit_stage == .reply_setup) { + self.trace.stop(.{ .replica_commit = .{ .stage = self.commit_stage } }); + self.commit_stage = .execute; + self.trace.start(.{ .replica_commit = .{ + .stage = self.commit_stage, + .op = self.commit_prepare.?.header.op, + } }); + + self.commit_execute(); + } + + if (self.commit_stage == .execute) { + self.trace.stop(.{ .replica_commit = .{ .stage = self.commit_stage } }); + self.commit_stage = .checkpoint_durable; + self.trace.start(.{ .replica_commit = .{ + .stage = self.commit_stage, + .op = self.commit_prepare.?.header.op, + } }); + + if (self.commit_checkpoint_durable() == .pending) return; + } + + if (self.commit_stage == .checkpoint_durable) { + self.trace.stop(.{ .replica_commit = .{ .stage = self.commit_stage } }); + self.commit_stage = .compact; + self.trace.start(.{ .replica_commit = .{ + .stage = self.commit_stage, + .op = self.commit_prepare.?.header.op, + } }); + if (self.commit_compact() == .pending) return; + } + + if (self.commit_stage == .compact) { + self.trace.stop(.{ .replica_commit = .{ .stage = self.commit_stage } }); + self.commit_stage = .{ .checkpoint_data = .{} }; + self.trace.start(.{ .replica_commit = .{ + .stage = self.commit_stage, + .op = self.commit_prepare.?.header.op, + } }); + + if (self.commit_checkpoint_data() == .pending) return; + } + + if (self.commit_stage == .checkpoint_data) { + self.trace.stop(.{ .replica_commit = .{ .stage = self.commit_stage } }); + self.commit_stage = .checkpoint_superblock; + self.trace.start(.{ .replica_commit = .{ + .stage = self.commit_stage, + .op = self.commit_prepare.?.header.op, + } }); + + if (self.commit_checkpoint_superblock() == .pending) return; + } + + if (self.commit_stage == .checkpoint_superblock) { + self.trace.stop(.{ .replica_commit = .{ .stage = self.commit_stage } }); + self.commit_stage = .idle; + self.commit_finish(); + + assert(self.release.value <= + self.superblock.working.vsr_state.checkpoint.release.value); + if (self.release.value < + self.superblock.working.vsr_state.checkpoint.release.value) + { + // An upgrade has checkpointed, and that checkpoint is now durable. + // Deploy the new version! + self.release_transition(@src()); + self.commit_dispatch_entered = false; + return; + } + } + assert(self.commit_prepare == null); + assert(self.commit_stage == .idle); + } else unreachable; + + assert(self.commit_stage == .check_prepare); + assert(self.commit_prepare == null); + assert(self.commit_dispatch_entered); + assert(self.commit_started == null); + self.commit_stage = .idle; + self.commit_dispatch_entered = false; + + if (self.commit_min == self.op) { + // This is an optimization to expedite the view change before the `repair_timeout`: + if (self.status == .view_change and self.repairs_allowed()) self.repair(); + + if (self.status == .recovering) { + assert(self.solo()); + assert(self.commit_min == self.commit_max); + assert(self.commit_min == self.op); + self.transition_to_normal_from_recovering_status(); + } + } + } + + fn commit_dispatch_enter(self: *Replica) void { + assert(self.commit_stage == .idle); + self.commit_dispatch(); + } + + fn commit_dispatch_resume(self: *Replica) void { + assert(self.commit_stage != .idle); + assert(self.commit_dispatch_entered); + self.commit_dispatch_entered = false; + self.commit_dispatch(); + } + + fn commit_dispatch_cancel(self: *Replica) void { + assert(self.commit_stage != .idle); + assert(self.commit_dispatch_entered); + + if (self.commit_prepare) |prepare| self.message_bus.unref(prepare); + self.trace.cancel(.replica_commit); + self.commit_prepare = null; + self.commit_stage = .idle; + self.commit_dispatch_entered = false; + self.commit_started = null; + } + + fn commit_start(self: *Replica) enum { ready, pending } { + assert(self.commit_stage == .start); + if (self.status == .normal and self.primary()) { + self.commit_start_pipeline(); + return .ready; + } else { + if (self.commit_start_journal() == .pending) { + return .pending; + } + return .ready; + } + } + + fn commit_start_pipeline(self: *Replica) void { + assert(self.commit_stage == .start); + assert(self.commit_prepare == null); + assert(self.status == .normal); + assert(self.primary()); + assert(self.syncing == .idle); + + const prepare = self.pipeline.queue.prepare_queue.head_ptr() orelse + return; + + assert(self.commit_min == self.commit_max); + assert(self.commit_min + 1 == prepare.message.header.op); + assert(self.commit_min + self.pipeline.queue.prepare_queue.count == self.op); + assert(self.journal.has_header(prepare.message.header)); + + if (!prepare.ok_quorum_received) { + // Eventually handled by on_prepare_timeout(). + log.debug("{}: commit_start_pipeline: waiting for quorum", .{self.log_prefix()}); + return; + } + + const count = prepare.ok_from_all_replicas.count(); + assert(count >= self.quorum_replication); + assert(count <= self.replica_count); + + self.commit_prepare = prepare.message.ref(); + } + + fn commit_start_journal(self: *Replica) enum { pending, ready } { + assert(self.commit_stage == .start); + assert(self.commit_prepare == null); + assert(self.status == .normal or self.status == .view_change or + (self.status == .recovering and self.solo())); + assert(!(self.status == .normal and self.primary())); + assert(self.pipeline == .cache); + assert(self.commit_min <= self.commit_max); + assert(self.commit_min <= self.op); + maybe(self.commit_max <= self.op); + + // We may receive commit numbers for ops we do not yet have (`commit_max > self.op`): + // Even a naive state sync may fail to correct for this. + if (self.commit_min < self.commit_max and self.commit_min < self.op) { + const op = self.commit_min + 1; + const header = self.journal.header_with_op(op) orelse return .ready; + + // Assuming that the head op is correct, it is definitely safe to commit the next + // prepare if it is from the same view as the head --- the primary for that view + // made sure that the hash chain is valid. If it is from the different view, we + // additionally verify ourselves that the hash-chain is not broken + const valid_hash_chain_or_same_view = self.valid_hash_chain(@src()) or + (self.status == .normal and + header.view == self.journal.header_with_op(self.op).?.view); + + if (!valid_hash_chain_or_same_view) { + assert(!self.solo()); + return .ready; + } + + if (self.pipeline.cache.prepare_by_op_and_checksum(op, header.checksum)) |prepare| { + log.debug("{}: commit_start_journal: " ++ + "cached prepare op={} checksum={x:0>32}", .{ + self.log_prefix(), + op, + header.checksum, + }); + self.commit_prepare = prepare.ref(); + return .ready; + } else { + self.journal.read_prepare( + commit_start_journal_callback, + .{ + .op = op, + .checksum = header.checksum, + }, + ); + return .pending; + } + } else { + return .ready; + } + } + + fn commit_start_journal_callback( + self: *Replica, + prepare: ?*Message.Prepare, + options: Journal.Read.Options, + ) void { + assert(self.status == .normal or self.status == .view_change or + (self.status == .recovering and self.solo())); + assert(self.commit_stage == .start); + assert(self.commit_prepare == null); + assert(options.destination_replica == null); + + if (prepare == null) { + log.debug("{}: commit_start_journal_callback: prepare == null", .{ + self.log_prefix(), + }); + if (self.solo()) @panic("cannot recover corrupt prepare"); + return self.commit_dispatch_resume(); + } + + switch (self.status) { + .normal => {}, + .view_change => { + if (self.primary_index(self.view) != self.replica) { + log.debug( + "{}: commit_start_journal_callback: no longer primary view={}", + .{ self.log_prefix(), self.view }, + ); + assert(!self.solo()); + return self.commit_dispatch_resume(); + } + // Only the primary may commit during a view change before starting the new + // view. Fall through if this is indeed the case. + }, + .recovering => { + assert(self.solo()); + assert(self.primary_index(self.view) == self.replica); + }, + .recovering_head => unreachable, + } + + const op = self.commit_min + 1; + assert(prepare.?.header.op == op); + assert(self.journal.has_header(prepare.?.header)); + + self.commit_prepare = prepare.?.ref(); + return self.commit_dispatch_resume(); + } + + /// Begin the commit path that is common between `commit_pipeline` and `commit_journal`: + /// + /// 1. Prefetch. + /// 2. Commit_op: Update the state machine and the replica's commit_min/commit_max. + /// 3. Compact. + /// 4. Checkpoint: (Only called when `commit_min == op_checkpoint_next_trigger`). + /// 5. Done. Go to step 1 to repeat for the next op. + fn commit_prefetch(self: *Replica) enum { ready, pending } { + assert(self.state_machine_opened); + assert(self.status == .normal or self.status == .view_change or + (self.status == .recovering and self.solo())); + assert(self.commit_stage == .prefetch); + assert(self.commit_prepare.?.header.command == .prepare); + assert(self.commit_prepare.?.header.operation != .root); + assert(self.commit_prepare.?.header.operation != .reserved); + assert(self.commit_prepare.?.header.op == self.commit_min + 1); + assert(self.commit_prepare.?.header.op <= self.op); + assert(self.journal.has_header(self.commit_prepare.?.header)); + + const prepare = self.commit_prepare.?; + + if (prepare.header.size > self.request_size_limit) { + // Normally this would be caught during on_prepare(), but it is possible that we are + // replaying a message that we prepared before a restart, and the restart changed + // our batch_size_limit. + log.err("{}: commit_prefetch: op={} size={} size_limit={}", .{ + self.log_prefix(), + prepare.header.op, + prepare.header.size, + self.request_size_limit, + }); + @panic("Cannot commit prepare; batch limit too low."); + } + + // If we crash and recover from a checkpoint, use trigger+1 as the snapshot to query + // the LSM tree. This is because the checkpoint contains output tables from the + // compaction in the last bar before trigger, which creates tables with + // snapshot_min=trigger+1. Otherwise, we use the op number as the snapshot, as it is + // guaranteed to be the latest snapshot. + const snapshot = if (self.superblock.working.vsr_state.op_compacted(prepare.header.op)) + vsr.Checkpoint.trigger_for_checkpoint(self.op_checkpoint()).? + 1 + else + prepare.header.op; + + if (StateMachine.Operation.from_vsr(prepare.header.operation)) |prepare_operation| { + self.state_machine.prefetch_timestamp = prepare.header.timestamp; + self.state_machine.prefetch( + commit_prefetch_callback, + prepare.header.op, + snapshot, + prepare_operation, + prepare.body_used(), + ); + return .pending; + } else { + assert(prepare.header.operation.vsr_reserved()); + return .ready; + } + } + + fn commit_prefetch_callback(state_machine: *StateMachine) void { + const self: *Replica = @alignCast(@fieldParentPtr("state_machine", state_machine)); + assert(self.commit_stage == .prefetch); + assert(self.commit_prepare != null); + assert(self.commit_prepare.?.header.op == self.commit_min + 1); + + return self.commit_dispatch_resume(); + } + + fn commit_stall(self: *Replica) enum { ready, pending } { + assert(self.commit_stage == .stall); + assert(!self.commit_stall_timeout.ticking); + assert(self.commit_prepare.?.header.op == self.commit_min + 1); + + if (self.status != .normal) return .ready; + if (!self.primary()) return .ready; + if (self.solo()) return .ready; + + const commit_lag = commit_lag_max: { + var max: u64 = std.math.minInt(u64); + for ( + self.commit_mins[0..self.replica_count], + self.head_ops[0..self.replica_count], + ) |commit_min, op_head| { + const op = @min(op_head, self.commit_min); + // Don't stall on account of replicas that are more than + // three checkpoints behind, they may be down/partitioned. + // Default to three checkpoints as in that case we are + // certain that the lagging replica can't use WAL repair + // and must use state sync (see `on_view_set_checkpoint`). + if (self.commit_min -| commit_min <= self.commit_stall_lag_max) { + max = @max(max, op -| commit_min); + } + } + break :commit_lag_max max; + }; + + var pipeline_iterator = self.pipeline.queue.prepare_queue.iterator(); + const prepare = pipeline_iterator.next().?; // Skip the current commit. + assert(prepare.message == self.commit_prepare.?); + + const stall_ms = ms: { + if (commit_lag < self.commit_stall_lag_min) { + while (pipeline_iterator.next()) |queue_prepare| { + if (queue_prepare.ok_from_all_replicas.count() >= self.quorum_replication) { + break; + } + } else { + // When there are no other committed prepares in the + // pipeline, backups naturally get some breathing room + // i.e. they get a stall by virtue of the primary not + // having any more work to do. + break :ms 0; + } + + // There are other committed prepares in the pipeline, give + // backups some breathing room by explicitly stalling. + // NB: We do this with commit_stall_probability to achieve + // shorter than a tick resolution on average, *not* to + // randomize when stalls are injected. + if (self.prng.chance(self.commit_stall_probability)) { + break :ms constants.tick_ms; + } else { + break :ms 0; + } + } else { + + // "Stall 10ms for every quarter-checkpoint of commits lagged, + // but no longer than 40ms". + // + // TODO Once repair+sync is faster, tune this. + // TODO Choose the growth rate in a more principled way. This current + // configuration does seem to allow lagged replicas to recover + // automatically. It also reduced the chance of normal operations leading to + // lagged replicas, but it still happens sometimes. + const checkpoint_ratio = @divFloor( + constants.vsr_checkpoint_ops, + self.commit_stall_multiple_max, + ); + const stall_multiple = + std.math.clamp( + @divFloor(commit_lag, checkpoint_ratio), + 1, + self.commit_stall_multiple_max, + ); + break :ms stall_multiple * 10; + } + }; + + const stall_ticks = stall_ms / constants.tick_ms; + assert(stall_ms == 0 or stall_ms >= constants.tick_ms); + if (stall_ticks == 0) { + return .ready; + } else { + log.debug("{}: commit_stall op={} (oks={b} commit_lag={} stall_ticks={})", .{ + self.log_prefix(), + prepare.message.header.op, + prepare.ok_from_all_replicas.bits, + commit_lag, + stall_ticks, + }); + + self.commit_stall_timeout.after = stall_ticks; + self.commit_stall_timeout.start(); + return .pending; + } + } + + // Ensure that ClientReplies has at least one Write available. + fn commit_reply_setup(self: *Replica) enum { ready, pending } { + assert(self.commit_stage == .reply_setup); + if (self.client_replies.ready_sync()) return .ready; + self.client_replies.ready(commit_reply_setup_callback); + return .pending; + } + + fn commit_reply_setup_callback(client_replies: *ClientReplies) void { + const self: *Replica = @alignCast(@fieldParentPtr("client_replies", client_replies)); + assert(self.commit_stage == .reply_setup); + assert(self.commit_prepare != null); + assert(self.commit_prepare.?.header.op == self.commit_min + 1); + assert(self.client_replies.writes.available() > 0); + return self.commit_dispatch_resume(); + } + + fn commit_execute(self: *Replica) void { + self.execute_op(self.commit_prepare.?); + assert(self.commit_min == self.commit_prepare.?.header.op); + assert(self.commit_min <= self.commit_max); + + if (self.status == .normal and self.primary()) { + assert(!self.view_durable_updating()); + + if (self.pipeline.queue.pop_request()) |request| { + // Start preparing the next request in the queue (if any). + self.primary_pipeline_prepare(request); + } + + if (self.pulse_enabled() and + self.state_machine.pulse_needed(self.state_machine.prepare_timestamp)) + { + assert(self.upgrade_release == null); + self.send_request_pulse_to_self(); + } + + assert(self.commit_min == self.commit_max); + + if (self.pipeline.queue.prepare_queue.head_ptr()) |next| { + assert(next.message.header.op == self.commit_min + 1); + assert(next.message.header.op == self.commit_prepare.?.header.op + 1); + + if (self.solo()) { + // Write the next message in the queue. + // A cluster-of-one writes prepares sequentially to avoid gaps in the + // WAL caused by reordered writes. + log.debug("{}: append: appending to journal op={}", .{ + self.log_prefix(), + next.message.header.op, + }); + _ = self.write_prepare(next.message); + } + } + + if (self.upgrade_release) |upgrade_release| { + assert(self.release.value < upgrade_release.value); + assert(!self.pulse_enabled()); + + const release_next = self.release_for_next_checkpoint(); + if (release_next == null or release_next.?.value == self.release.value) { + self.send_request_upgrade_to_self(); + } + } + } + } + + fn commit_compact(self: *Replica) enum { pending } { + assert(self.commit_stage == .compact); + self.state_machine.compact(commit_compact_callback, self.commit_prepare.?.header.op); + return .pending; + } + + fn commit_checkpoint_durable_grid_callback(grid: *Grid) void { + const self: *Replica = @alignCast(@fieldParentPtr("grid", grid)); + assert(self.commit_stage == .checkpoint_durable); + assert(self.grid.free_set.checkpoint_durable); + assert(vsr.Checkpoint.durable(self.op_checkpoint(), self.commit_min)); + self.commit_dispatch_resume(); + } + + fn commit_compact_callback(state_machine: *StateMachine) void { + const self: *Replica = @alignCast(@fieldParentPtr("state_machine", state_machine)); + assert(self.commit_stage == .compact); + assert(self.op_checkpoint() == self.superblock.staging.vsr_state.checkpoint.header.op); + assert(self.op_checkpoint() == self.superblock.working.vsr_state.checkpoint.header.op); + + if (self.event_callback) |hook| hook(self, .compaction_completed); + return self.commit_dispatch_resume(); + } + + fn commit_checkpoint_durable(self: *Replica) enum { ready, pending } { + assert(self.commit_stage == .checkpoint_durable); + if (self.grid.free_set.checkpoint_durable) return .ready; + if (!vsr.Checkpoint.durable(self.op_checkpoint(), self.commit_min)) return .ready; + + // Send View so lagging replicas can proactively sync to this durable checkpoint. + if (self.status == .normal and self.primary()) self.primary_send_view(); + + // Checkpoint is guaranteed to be durable on a commit quorum when a replica is + // committing the (pipeline + 1)ᵗʰ prepare after checkpoint trigger. It might already be + // durable before this point (some part of the cluster may be lagging while a commit + // quorum may already be on the next checkpoint), but it is crucial for storage + // determinism that each replica marks it as durable at the same time. + if (vsr.Checkpoint.trigger_for_checkpoint(self.op_checkpoint())) |trigger| { + assert(self.commit_min == trigger + constants.pipeline_prepare_queue_max + 1); + } + + self.grid_scrubber.checkpoint_durable(); + self.grid.checkpoint_durable(commit_checkpoint_durable_grid_callback); + return .pending; + } + + fn commit_checkpoint_data(self: *Replica) enum { ready, pending } { + assert(self.commit_stage == .checkpoint_data); + assert(self.commit_stage.checkpoint_data.count() == 0); + + const op = self.commit_prepare.?.header.op; + assert(op == self.commit_min); + assert(op <= self.op_checkpoint_next_trigger()); + if (op < self.op_checkpoint_next_trigger()) { + return .ready; + } + + assert(op <= self.op); + assert((op + 1) % constants.lsm_compaction_ops == 0); + log.info("{}: commit_checkpoint_data: checkpoint_data start " ++ + "(checkpoint={}..{} commit_min={} op={} commit_max={} op_prepare_max={} " ++ + "free_set.acquired={} free_set.released={})", .{ + self.log_prefix(), + self.op_checkpoint(), + self.op_checkpoint_next(), + self.commit_min, + self.op, + self.commit_max, + self.op_prepare_max(), + self.grid.free_set.count_acquired(), + self.grid.free_set.count_released(), + }); + + if (self.event_callback) |hook| hook(self, .checkpoint_commenced); + + const chunks = self.client_sessions_checkpoint.encode_chunks(); + assert(chunks.len == 1); + + self.client_sessions_checkpoint.size = self.client_sessions.encode(chunks[0]); + assert(self.client_sessions_checkpoint.size == ClientSessions.encode_size); + + if (self.status == .normal and self.primary()) { + // Send a commit message promptly, rather than waiting for our commit timer. + // This is useful when this checkpoint is an upgrade, since we will need to + // restart into the new version. We want all the replicas to restart in + // parallel (as much possible) rather than in sequence. + self.send_commit(self.clock.monotonic()); + } + if (self.aof) |aof| { + self.trace.start(.replica_aof_checkpoint); + + aof.checkpoint(self, commit_checkpoint_data_aof_callback); + } else { + self.commit_checkpoint_data_callback_join(.aof); + } + self.state_machine.checkpoint(commit_checkpoint_data_state_machine_callback); + self.client_sessions_checkpoint + .checkpoint(commit_checkpoint_data_client_sessions_callback); + self.client_replies.checkpoint(commit_checkpoint_data_client_replies_callback); + + // The grid checkpoint must begin after the manifest/trailers have acquired all + // their blocks, since it encodes the free set: + self.grid.checkpoint(commit_checkpoint_data_grid_callback); + return .pending; + } + + fn commit_checkpoint_data_aof_callback(replica: *anyopaque) void { + const self: *Replica = @ptrCast(@alignCast(replica)); + assert(self.commit_stage == .checkpoint_data); + self.trace.stop(.replica_aof_checkpoint); + self.commit_checkpoint_data_callback_join(.aof); + } + + fn commit_checkpoint_data_state_machine_callback(state_machine: *StateMachine) void { + const self: *Replica = @alignCast(@fieldParentPtr("state_machine", state_machine)); + self.commit_checkpoint_data_callback_join(.state_machine); + } + + fn commit_checkpoint_data_client_sessions_callback( + client_sessions_checkpoint: *CheckpointTrailer, + ) void { + const self: *Replica = @alignCast( + @fieldParentPtr("client_sessions_checkpoint", client_sessions_checkpoint), + ); + assert(self.commit_stage == .checkpoint_data); + self.commit_checkpoint_data_callback_join(.client_sessions); + } + + fn commit_checkpoint_data_client_replies_callback(client_replies: *ClientReplies) void { + const self: *Replica = @alignCast(@fieldParentPtr("client_replies", client_replies)); + assert(self.commit_stage == .checkpoint_data); + self.commit_checkpoint_data_callback_join(.client_replies); + } + + fn commit_checkpoint_data_grid_callback(grid: *Grid) void { + const self: *Replica = @alignCast(@fieldParentPtr("grid", grid)); + assert(self.commit_stage == .checkpoint_data); + assert(self.commit_prepare.?.header.op <= self.op); + assert(self.commit_prepare.?.header.op == self.commit_min); + assert(self.grid.free_set.opened); + + self.commit_checkpoint_data_callback_join(.grid); + } + + fn commit_checkpoint_data_callback_join( + self: *Replica, + checkpoint_data: CommitStage.CheckpointData, + ) void { + assert(self.commit_stage == .checkpoint_data); + assert(!self.commit_stage.checkpoint_data.contains(checkpoint_data)); + self.commit_stage.checkpoint_data.insert(checkpoint_data); + if (self.commit_stage.checkpoint_data.count() == + CommitStage.CheckpointDataProgress.len) + { + log.info("{}: commit_checkpoint_data_callback_join: checkpoint_data done " ++ + "(op={} current_checkpoint={} next_checkpoint={})", .{ + self.log_prefix(), + self.op, + self.op_checkpoint(), + self.op_checkpoint_next(), + }); + self.grid.assert_only_repairing(); + + return self.commit_dispatch_resume(); + } + } + + fn commit_checkpoint_superblock(self: *Replica) enum { ready, pending } { + const commit_op = self.commit_prepare.?.header.op; + assert(commit_op == self.commit_min); + assert(commit_op <= self.op_checkpoint_next_trigger()); + if (commit_op < self.op_checkpoint_next_trigger()) { + return .ready; + } + + assert(self.grid.free_set.opened); + assert(self.state_machine_opened); + assert(self.commit_stage == .checkpoint_superblock); + assert(commit_op <= self.op); + assert(commit_op == self.op_checkpoint_next_trigger()); + assert(self.op_checkpoint_next_trigger() <= self.commit_max); + self.grid.assert_only_repairing(); + + // For the given WAL (journal_slot_count=8, lsm_compaction_ops=2, op=commit_min=7): + // + // A B C D E + // |01|23|45|67| + // + // The checkpoint is triggered at "E". + // At this point, ops 6 and 7 are in the in-memory immutable table. + // They will only be compacted to disk in the next bar. + // Therefore, only ops "A..D" are committed to disk. + // Thus, the SuperBlock's `commit_min` is set to 7-2=5. + const vsr_state_commit_min = self.op_checkpoint_next(); + + if (self.sync_content_done()) { + assert(self.sync_tables == null); + assert(self.grid_repair_tables.executing() == 0); + } + const sync_op_min, const sync_op_max = if (self.sync_content_done()) + .{ 0, 0 } + else + .{ + self.superblock.staging.vsr_state.sync_op_min, + self.superblock.staging.vsr_state.sync_op_max, + }; + + const storage_size: u64 = storage_size: { + var storage_size = vsr.superblock.data_file_size_min; + if (self.grid.free_set.highest_address_acquired()) |address| { + assert(address > 0); + assert(self.grid.free_set_checkpoint_blocks_acquired.size > 0); + maybe(self.grid.free_set_checkpoint_blocks_released.size == 0); + + storage_size += address * constants.block_size; + } else { + assert(self.grid.free_set_checkpoint_blocks_acquired.size == 0); + assert(self.grid.free_set_checkpoint_blocks_released.size == 0); + + assert(self.grid.free_set.count_released() == 0); + } + break :storage_size storage_size; + }; + + if (self.superblock.working.vsr_state.sync_op_max != 0 and sync_op_max == 0) { + log.info("{}: sync: done", .{self.log_prefix()}); + } + + if (self.status == .view_change and self.view == self.log_view) { + // Unconditionally update a potential primary's JV headers; current headers may + // contain truncated ops that must not be made durable. We can't update View + // headers for a potential primary because we could arrive here while the potential + // primary is still repairing (and thus may still have gaps in its journal). + assert(self.join_view_quorum); + self.update_join_view_headers(); + } else { + // Update view_headers to include at least one op from the future checkpoint. This + // ensures a replica never starts with its head op less than self.op_checkpoint(). + if (self.view_headers.array.get(0).op < self.op_checkpoint_next_trigger()) { + assert(self.status == .normal); + self.update_view_headers(); + } + } + + assert(self.view_headers.array.get(0).op >= self.op_checkpoint_next_trigger()); + + log.info("{}: commit_checkpoint_superblock: checkpoint_superblock start " ++ + "(op={} checkpoint={}..{} view_durable={}..{} " ++ + "log_view_durable={}..{})", .{ + self.log_prefix(), + self.op, + self.op_checkpoint(), + self.op_checkpoint_next(), + self.view_durable(), + self.view, + self.log_view_durable(), + self.log_view, + }); + self.superblock.checkpoint( + commit_checkpoint_superblock_callback, + &self.superblock_context, + .{ + .header = self.journal.header_with_op(vsr_state_commit_min).?.*, + .view_attributes = view_attributes: { + // view_headers for solo replicas do not include ops that are not durable in + // their journal. + break :view_attributes if (self.solo()) + null + else + .{ + .headers = &self.view_headers, + .view = self.view, + .log_view = self.log_view, + }; + }, + .commit_max = self.commit_max, + .sync_op_min = sync_op_min, + .sync_op_max = sync_op_max, + .manifest_references = self.state_machine.forest + .manifest_log.checkpoint_references(), + .free_set_references = .{ + .blocks_acquired = self.grid + .free_set_checkpoint_blocks_acquired.checkpoint_reference(), + .blocks_released = self.grid + .free_set_checkpoint_blocks_released.checkpoint_reference(), + }, + .client_sessions_reference = self + .client_sessions_checkpoint.checkpoint_reference(), + .storage_size = storage_size, + .release = self.release_for_next_checkpoint().?, + }, + ); + return .pending; + } + + fn commit_checkpoint_superblock_callback(superblock_context: *SuperBlock.Context) void { + const self: *Replica = + @alignCast(@fieldParentPtr("superblock_context", superblock_context)); + assert(self.status == .normal or self.status == .view_change or + (self.status == .recovering and self.solo())); + assert(self.commit_stage == .checkpoint_superblock); + assert(self.commit_prepare.?.header.op <= self.op); + assert(self.commit_prepare.?.header.op == self.commit_min); + + assert(self.op_checkpoint() == self.commit_min - constants.lsm_compaction_ops); + assert(self.op_checkpoint() == self.superblock.staging.vsr_state.checkpoint.header.op); + assert(self.op_checkpoint() == self.superblock.working.vsr_state.checkpoint.header.op); + + log.info( + "{}: commit_checkpoint_superblock_callback: " ++ + "checkpoint_superblock done (op={} new_checkpoint={})", + .{ self.log_prefix(), self.op, self.op_checkpoint() }, + ); + + self.grid.assert_only_repairing(); + + // Mark the current checkpoint as not durable, then release the blocks acquired for the + // ClientSessions and FreeSet checkpoints (to be freed when the *next* checkpoint + // becomes durable). + self.grid.mark_checkpoint_not_durable(); + self.grid.release(self.client_sessions_checkpoint + .block_addresses[0..self.client_sessions_checkpoint.block_count()]); + + assert(self.grid.free_set.count_released() >= + self.grid.free_set_checkpoint_blocks_acquired.block_count() + + self.grid.free_set_checkpoint_blocks_released.block_count() + + self.client_sessions_checkpoint.block_count()); + + // Send prepare_oks that may have been withheld by virtue of `op_prepare_ok_max`. + self.send_prepare_oks_after_checkpoint(); + + if (self.event_callback) |hook| hook(self, .checkpoint_completed); + return self.commit_dispatch_resume(); + } + + fn commit_finish(self: *Replica) void { + assert(self.commit_stage == .idle); + assert(self.commit_prepare.?.header.op == self.commit_min); + assert(self.commit_prepare.?.header.op < self.op_checkpoint_next_trigger()); + defer { + self.message_bus.unref(self.commit_prepare.?); + self.commit_started = null; + self.commit_prepare = null; + } + + // This is the timestamp from when the primary first saw the request to now. It + // includes compaction time, and will work and show view change latencies, etc. + // + // NB: When a request comes in, it may be blocked by CPU work (likely, compaction) and + // only get timestamped _after_ that work finishes. This adds some measurement error. + const commit_completion_time_request: Duration = .{ + .ns = @as(u64, @intCast(self.clock.realtime())) -| + self.commit_prepare.?.header.timestamp, + }; + const commit_completion_time_local = + self.commit_started.?.elapsed(self.clock.monotonic()); + + // Only time operations when: + // * Running with the real state machine - as otherwise there's a circular dependency, + // * and when the replica's status is .normal - otherwise things like WAL replay at + // startup will skew these numbers. + if (StateMachine.Operation == @import("../tigerbeetle.zig").Operation and + self.status == .normal) + { + const operation = self.commit_prepare.?.header.operation; + self.trace.timing( + .{ .replica_request_local = .from(operation) }, + commit_completion_time_local, + ); + self.trace.timing( + .{ .replica_request = .from(operation) }, + commit_completion_time_request, + ); + } + } + + // For each op, in addition to the primary, a randomly chosen backup also sends a reply + // to the client. All replicas initialize the PRNG with the same seed, so they arrive + // at the same random backup. This improves logical availability in the case where the + // the primary → client link is down. If it doesn't work, we have a fallback where a + // backup directly replies to client requests (see `ignore_request_message`). + // Selecting a random backup as opposed to using a deterministic function also guards us + // from subtle resonance issues wherein the same backup replies to the same client every + // time. This could happen if `active client count % replica count == 0`, and these clients' + // requests arrive at the primary in the same order every time. + fn execute_op_reply_to_client(self: *Replica, op: u64) bool { + if (self.replica == self.primary_index(self.view)) return true; + + if (self.replica_count == 1) { + assert(self.standby()); + return false; + } + + var prng = stdx.PRNG.from_seed(op); + const offset_random = prng.range_inclusive(u8, 1, self.replica_count - 1); + const backup_random = + (self.primary_index(self.view) + offset_random) % self.replica_count; + assert(backup_random != self.primary_index(self.view)); + return self.replica == backup_random; + } + + fn execute_op(self: *Replica, prepare: *const Message.Prepare) void { + // TODO Can we add more checks around allowing execute_op() during a view change? + assert(self.commit_stage == .execute); + assert(self.commit_prepare.? == prepare); + assert(self.status == .normal or self.status == .view_change or + (self.status == .recovering and self.solo())); + assert(self.client_replies.writes.available() > 0); + assert(self.upgrade_release == null or prepare.header.operation == .upgrade); + assert( + self.superblock.working.vsr_state.checkpoint.release.value == self.release.value, + ); + assert(prepare.header.command == .prepare); + assert(prepare.header.operation != .root); + assert(prepare.header.operation != .reserved); + assert(prepare.header.op == self.commit_min + 1); + assert(prepare.header.op <= self.op); + + // If we are a backup committing through `commit_journal()` then a view change may + // have happened since we last checked in `commit_journal_next()`. However, this would + // relate to subsequent ops, since by now we have already verified the hash chain for + // this commit. + + assert(self.journal.has_header(prepare.header)); + if (self.op_checkpoint() == self.commit_min) { + // op_checkpoint's slot may have been overwritten in the WAL — but we can + // always use the VSRState to anchor the hash chain. + assert(prepare.header.parent == + self.superblock.working.vsr_state.checkpoint.header.checksum); + } else { + if (self.journal.header_with_op(self.commit_min)) |header| { + assert(prepare.header.parent == header.checksum); + } else if (self.journal.header_for_op(self.commit_min)) |header| { + // self.commit_min may have been replaced by an op from the next log wrap. + assert(header.op == self.commit_min + constants.journal_slot_count); + } + } + + log.debug("{}: execute_op: " ++ + "executing view={} primary={} op={} checksum={x:0>32} ({s})", .{ + self.log_prefix(), + self.view, + self.primary_index(self.view) == self.replica, + prepare.header.op, + prepare.header.checksum, + prepare.header.operation.tag_name(StateMachine.Operation), + }); + + const reply = self.message_bus.get_message(.reply); + defer self.message_bus.unref(reply); + + log.debug("{}: execute_op: commit_timestamp={} prepare.header.timestamp={}", .{ + self.log_prefix(), + self.state_machine.commit_timestamp, + prepare.header.timestamp, + }); + assert(self.state_machine.commit_timestamp < prepare.header.timestamp or + self.aof_recovery); + + // Synchronously record this request in our AOF. This can be used for disaster recovery + // in the case of catastrophic storage failure. Internally, write() will only return + // once the data has been written to disk with O_DIRECT and O_SYNC. + // + // We run this here, instead of in state_machine, so we can have full access to the VSR + // header information. This way we can just log the Prepare in its entirety. + // + // A minor detail, but this is not a WAL. Hence the name being AOF - since it's similar + // to how Redis's Append Only File works. It's also technically possible for a request + // to be recorded by the AOF, with the client not having received a response + // (eg, a panic right after writing to the AOF before sending the response) but we + // consider this harmless due to our requirement for unique Account / Transfer IDs. + // + // It should be impossible for a client to receive a response without the request + // being logged by at least one replica. + if (self.aof) |aof| { + self.trace.start(.{ .replica_aof_write = .{ + .op = prepare.header.op, + } }); + aof.write(prepare) catch @panic("aof failure"); + self.trace.stop(.{ .replica_aof_write = .{ + .op = prepare.header.op, + } }); + } + + const reply_body_size = switch (prepare.header.operation) { + .reserved, .root => unreachable, + .register => self.execute_op_register(prepare, reply.buffer[@sizeOf(Header)..]), + .reconfigure => self.execute_op_reconfiguration( + prepare, + reply.buffer[@sizeOf(Header)..], + ), + .upgrade => self.execute_op_upgrade(prepare, reply.buffer[@sizeOf(Header)..]), + .noop => 0, + else => self.state_machine.commit( + prepare.header.client, + prepare.header.op, + prepare.header.timestamp, + prepare.header.operation.cast(StateMachine.Operation), + prepare.body_used(), + reply.buffer[@sizeOf(Header)..], + ), + }; + + assert(self.state_machine.commit_timestamp <= prepare.header.timestamp or + self.aof_recovery); + self.state_machine.commit_timestamp = prepare.header.timestamp; + + if (self.status == .normal and self.primary()) { + const pipeline_prepare = self.pipeline.queue.pop_prepare().?; + defer self.message_bus.unref(pipeline_prepare.message); + + assert(pipeline_prepare.message == prepare); + assert(pipeline_prepare.message.header.command == .prepare); + assert(pipeline_prepare.message.header.checksum == + self.commit_prepare.?.header.checksum); + assert(pipeline_prepare.message.header.op == self.commit_min + 1); + assert(pipeline_prepare.message.header.op == self.commit_max + 1); + assert(pipeline_prepare.ok_quorum_received); + } + + self.commit_min += 1; + assert(self.commit_min == prepare.header.op); + self.advance_commit_max(self.commit_min, @src()); + reply.header.* = .{ + .command = .reply, + .operation = prepare.header.operation, + .request_checksum = prepare.header.request_checksum, + .client = prepare.header.client, + .request = prepare.header.request, + .cluster = prepare.header.cluster, + .replica = prepare.header.replica, + .view = prepare.header.view, + .release = prepare.header.release, + .op = prepare.header.op, + .timestamp = prepare.header.timestamp, + .commit = prepare.header.op, + .size = @sizeOf(Header) + @as(u32, @intCast(reply_body_size)), + }; + assert(reply.header.epoch == 0); + + reply.header.set_checksum_body(reply.body_used()); + // See `send_reply_message_to_client` for why we compute the checksum twice. + reply.header.context = reply.header.calculate_checksum(); + reply.header.set_checksum(); + + const size_ceil = vsr.sector_ceil(reply.header.size); + @memset(reply.buffer[reply.header.size..size_ceil], 0); + + if (self.event_callback) |hook| { + hook(self, .{ .committed = .{ .prepare = prepare, .reply = reply } }); + } + + if (self.superblock.working.vsr_state.op_compacted(prepare.header.op)) { + // We are recovering from a checkpoint. Prior to the crash, the client table was + // updated with entries for one bar beyond the op_checkpoint. + assert(self.op_checkpoint() == + self.superblock.working.vsr_state.checkpoint.header.op); + if (self.client_sessions.get(prepare.header.client)) |entry| { + assert(entry.header.command == .reply); + assert(entry.header.op >= prepare.header.op); + } else { + if (prepare.header.client == 0) { + assert(prepare.header.operation == .pulse or + prepare.header.operation == .upgrade); + } else { + assert(self.client_sessions.count() == self.client_sessions.capacity()); + } + } + + log.debug( + "{}: execute_op: skip client table update: prepare.op={} checkpoint={}", + .{ self.log_prefix(), prepare.header.op, self.op_checkpoint() }, + ); + } else { + switch (reply.header.operation) { + .root => unreachable, + .register => self.client_table_entry_create(reply), + .pulse, .upgrade => assert(reply.header.client == 0), + else => self.client_table_entry_update(reply), + } + } + + if (self.execute_op_reply_to_client(prepare.header.op)) { + if (reply.header.client == 0) { + log.debug("{}: execute_op: no reply to client: {}", .{ + self.log_prefix(), + reply.header, + }); + } else { + log.debug("{}: execute_op: replying to client: {}", .{ + self.log_prefix(), + reply.header, + }); + self.send_reply_message_to_client(reply); + + const commit_execute_time_request: Duration = .{ + .ns = @as(u64, @intCast(self.clock.realtime())) -| + self.commit_prepare.?.header.timestamp, + }; + + if (StateMachine.Operation == @import("../tigerbeetle.zig").Operation and + self.status == .normal) + { + const operation = self.commit_prepare.?.header.operation; + self.trace.timing( + .{ .replica_request_execute = .from(operation) }, + commit_execute_time_request, + ); + } + } + } + } + + fn execute_op_register( + self: *Replica, + prepare: *const Message.Prepare, + output_buffer: *align(constants.cache_line_size) [constants.message_body_size_max]u8, + ) usize { + assert(self.commit_stage == .execute); + assert(self.commit_prepare.? == prepare); + assert(prepare.header.command == .prepare); + assert(prepare.header.operation == .register); + assert(prepare.header.op == self.commit_min + 1); + assert(prepare.header.op <= self.op); + + const result = std.mem.bytesAsValue( + vsr.RegisterResult, + output_buffer[0..@sizeOf(vsr.RegisterResult)], + ); + + assert(prepare.header.size == @sizeOf(vsr.Header) + @sizeOf(vsr.RegisterRequest)); + const register_request = std.mem.bytesAsValue( + vsr.RegisterRequest, + prepare.body_used()[0..@sizeOf(vsr.RegisterRequest)], + ); + assert(register_request.batch_size_limit > 0); + assert(register_request.batch_size_limit <= constants.message_body_size_max); + assert(register_request.batch_size_limit <= + self.request_size_limit - @sizeOf(vsr.Header)); + assert(stdx.zeroed(®ister_request.reserved)); + + result.* = .{ + .batch_size_limit = register_request.batch_size_limit, + }; + return @sizeOf(vsr.RegisterResult); + } + + // The actual "execution" was handled by the primary when the request was prepared. + // Primary makes use of local information to decide whether reconfiguration should be + // accepted. Here, we just copy over the result. + fn execute_op_reconfiguration( + self: *Replica, + prepare: *const Message.Prepare, + output_buffer: *align(constants.cache_line_size) [constants.message_body_size_max]u8, + ) usize { + assert(self.commit_stage == .execute); + assert(self.commit_prepare.? == prepare); + assert(prepare.header.command == .prepare); + assert(prepare.header.operation == .reconfigure); + assert( + prepare.header.size == @sizeOf(vsr.Header) + @sizeOf(vsr.ReconfigurationRequest), + ); + assert(prepare.header.op == self.commit_min + 1); + assert(prepare.header.op <= self.op); + + const reconfiguration_request = std.mem.bytesAsValue( + vsr.ReconfigurationRequest, + prepare.body_used()[0..@sizeOf(vsr.ReconfigurationRequest)], + ); + assert(reconfiguration_request.result != .reserved); + + const result = std.mem.bytesAsValue( + vsr.ReconfigurationResult, + output_buffer[0..@sizeOf(vsr.ReconfigurationResult)], + ); + + result.* = reconfiguration_request.result; + return @sizeOf(vsr.ReconfigurationResult); + } + + fn execute_op_upgrade( + self: *Replica, + prepare: *const Message.Prepare, + output_buffer: *align(constants.cache_line_size) [constants.message_body_size_max]u8, + ) usize { + maybe(self.upgrade_release == null); + assert(self.commit_stage == .execute); + assert(self.commit_prepare.? == prepare); + assert(self.superblock.working.vsr_state.checkpoint.release.value == + self.release.value); + assert(prepare.header.command == .prepare); + assert(prepare.header.operation == .upgrade); + assert(prepare.header.size == @sizeOf(vsr.Header) + @sizeOf(vsr.UpgradeRequest)); + assert(prepare.header.op == self.commit_min + 1); + assert(prepare.header.op <= self.op); + assert(prepare.header.client == 0); + + const request = std.mem.bytesAsValue( + vsr.UpgradeRequest, + prepare.body_used()[0..@sizeOf(vsr.UpgradeRequest)], + ); + assert(request.release.value >= self.release.value); + assert(stdx.zeroed(&request.reserved)); + + if (request.release.value == self.release.value) { + // The replica is replaying this upgrade request after restarting into the new + // version. + assert(self.upgrade_release == null); + assert(prepare.header.op <= + vsr.Checkpoint.trigger_for_checkpoint(self.op_checkpoint()).?); + + log.debug("{}: execute_op_upgrade: release={} (ignoring, already upgraded)", .{ + self.log_prefix(), + request.release, + }); + } else { + if (self.upgrade_release) |upgrade_release| { + assert(upgrade_release.value == request.release.value); + + log.debug( + "{}: execute_op_upgrade: release={} (ignoring, already upgrading)", + .{ self.log_prefix(), request.release }, + ); + } else { + if (self.pipeline == .queue) { + self.pipeline.queue.verify(); + if (self.status == .normal) { + assert(self.pipeline.queue.prepare_queue.count == 1); + assert(self.pipeline.queue.request_queue.empty()); + } + } + + log.debug("{}: execute_op_upgrade: release={}", .{ + self.log_prefix(), + request.release, + }); + + self.upgrade_release = request.release; + } + } + + // The cluster is sending this request to itself, so there is no reply. + _ = output_buffer; + return 0; + } + + /// Creates an entry in the client table when registering a new client session. + /// Asserts that the new session does not yet exist. + /// Evicts another entry deterministically, if necessary, to make space for the insert. + fn client_table_entry_create(self: *Replica, reply: *Message.Reply) void { + assert(reply.header.command == .reply); + assert(reply.header.operation == .register); + assert(reply.header.client > 0); + assert(reply.header.op == reply.header.commit); + assert(reply.header.size == @sizeOf(Header) + @sizeOf(vsr.RegisterResult)); + + const session = reply.header.commit; // The commit number becomes the session number. + const request = reply.header.request; + + // We reserved the `0` commit number for the cluster `.root` operation. + assert(session > 0); + assert(request == 0); + + // For correctness, it's critical that all replicas evict deterministically: + // We cannot depend on `HashMap.capacity()` since `HashMap.ensureTotalCapacity()` may + // change across versions of the Zig std lib. We therefore rely on + // `constants.clients_max`, which must be the same across all replicas, and must not + // change after initializing a cluster. + // We also do not depend on `HashMap.valueIterator()` being deterministic here. However, + // we do require that all entries have different commit numbers and are iterated. + // This ensures that we will always pick the entry with the oldest commit number. + // We also check that a client has only one entry in the hash map (or it's buggy). + const clients = self.client_sessions.count(); + assert(clients <= constants.clients_max); + if (clients == constants.clients_max) { + const evictee = self.client_sessions.evictee(); + self.client_sessions.remove(evictee); + + assert(self.client_sessions.count() == constants.clients_max - 1); + + log.warn("{}: client_table_entry_create: clients={}/{} evicting client={}", .{ + self.log_prefix(), + clients, + constants.clients_max, + evictee, + }); + + if (self.event_callback) |hook| { + hook(self, .{ .client_evicted = evictee }); + } + } + + log.debug("{}: client_table_entry_create: write (client={} session={} request={})", .{ + self.log_prefix(), + reply.header.client, + session, + request, + }); + + // Any duplicate .register requests should have received the same session number if the + // client table entry already existed, or been dropped if a session was being committed: + const reply_slot = self.client_sessions.put(session, reply.header); + assert(self.client_sessions.count() <= constants.clients_max); + + self.client_replies.write_reply(reply_slot, reply, .commit); + } + + fn client_table_entry_update(self: *Replica, reply: *Message.Reply) void { + assert(reply.header.command == .reply); + assert(reply.header.operation != .register); + assert(reply.header.client > 0); + assert(reply.header.op == reply.header.commit); + assert(reply.header.commit > 0); + assert(reply.header.request > 0); + + if (self.client_sessions.get(reply.header.client)) |entry| { + assert(entry.header.command == .reply); + assert(entry.header.op == entry.header.commit); + assert(entry.header.commit >= entry.session); + + assert(entry.header.client == reply.header.client); + assert(entry.header.request + 1 == reply.header.request); + assert(entry.header.op < reply.header.op); + assert(entry.header.commit < reply.header.commit); + assert(entry.header.release.value == reply.header.release.value); + + // TODO Use this reply's prepare to cross-check against the entry's prepare, if we + // still have access to the prepare in the journal (it may have been snapshotted). + + log.debug("{}: client_table_entry_update: client={} session={} request={}", .{ + self.log_prefix(), + reply.header.client, + entry.session, + reply.header.request, + }); + + entry.header = reply.header.*; + + const reply_slot = self.client_sessions.get_slot_for_header(reply.header).?; + if (entry.header.size == @sizeOf(Header)) { + self.client_replies.remove_reply(reply_slot); + } else { + self.client_replies.write_reply(reply_slot, reply, .commit); + } + } else { + // If no entry exists, then the session must have been evicted while being prepared. + // We can still send the reply, the next request will receive an eviction message. + } + } + + /// Construct a View message, including attached headers from the current log_view. + /// The caller owns the returned message, if any, which has exactly 1 reference. + fn create_view_message(self: *Replica, nonce: u128) *Message.View { + assert(self.status == .normal or self.status == .view_change); + assert(self.syncing != .updating_checkpoint); + assert(self.replica == self.primary_index(self.view)); + assert(self.commit_min <= self.op); + assert(self.view >= self.view_durable()); + assert(self.log_view >= self.log_view_durable()); + assert(self.log_view == self.view); + if (self.status == .normal) { + assert(self.commit_min == self.commit_max); + } else { + // Potential primaries may send a View message before committing up to commit_max. + // (see `repair`). + assert(self.status == .view_change); + assert(self.join_view_quorum); + assert(self.commit_min <= self.commit_max); + } + + self.primary_update_view_headers(); + assert(self.view_headers.command == .view); + assert(self.view_headers.array.get(0).op == self.op); + + const message = self.message_bus.get_message(.view); + defer self.message_bus.unref(message); + + message.header.* = .{ + .size = @sizeOf(Header) + @sizeOf(vsr.CheckpointState) + + @sizeOf(Header) * self.view_headers.array.count_as(u32), + .command = .view, + .cluster = self.cluster, + .replica = self.replica, + .view = self.view, + .checkpoint_op = self.op_checkpoint(), + .op = self.op, + .commit_max = self.commit_max, + .nonce = nonce, + }; + + stdx.copy_disjoint( + .exact, + u8, + message.body_used()[0..@sizeOf(vsr.CheckpointState)], + std.mem.asBytes(&self.superblock.working.vsr_state.checkpoint), + ); + comptime assert(@sizeOf(vsr.CheckpointState) % @sizeOf(Header) == 0); + stdx.copy_disjoint( + .exact, + u8, + message.body_used()[@sizeOf(vsr.CheckpointState)..], + std.mem.sliceAsBytes(self.view_headers.array.const_slice()), + ); + message.header.set_checksum_body(message.body_used()); + message.header.set_checksum(); + + assert(message.header.invalid() == null); + return message.ref(); + } + + fn update_view_headers(self: *Replica) void { + assert(self.status != .recovering_head); + assert(self.view == self.log_view); + assert(self.view_headers.command == .view); + + const op_range = self.update_view_headers_op_range(); + if (self.primary_index(self.view) == self.replica) { + assert(op_range.op_max == self.op); + } else { + assert(op_range.op_max == self.op_checkpoint_next_trigger()); + } + + self.view_headers.array.clear(); + + var op = op_range.op_max + 1; + while (op > 0 and + self.view_headers.array.count() < constants.view_change_headers_suffix_max) + { + op -= 1; + self.view_headers.append(self.journal.header_with_op(op).?); + } + assert(self.view_headers.array.count() + 2 <= constants.view_headers_max); + + // The View includes headers corresponding to the op_prepare_max for preceding + // checkpoints (as many as we have and can help repair, which is at most 2). + for ([_]u64{ + self.op_prepare_max() -| constants.vsr_checkpoint_ops, + self.op_prepare_max() -| constants.vsr_checkpoint_ops * 2, + }) |op_hook| { + if (op > op_hook and op_hook >= op_range.op_min) { + op = op_hook; + self.view_headers.append(self.journal.header_with_op(op).?); + } + } + assert(self.view_headers.array.count() >= @min( + constants.view_change_headers_suffix_max, + self.view_headers.array.get(0).op + 1, // +1 to include the head itself. + )); + self.view_headers.verify(); + } + + // The range of ops that we can include in view_headers, capacity permitting. + // The longest unbroken chain of prepares up to the head (for the primary), + // or commit_min (for backups). + fn update_view_headers_op_range(self: *const Replica) struct { + op_min: u64, + op_max: u64, + } { + assert(self.status != .recovering_head); + assert(self.view == self.log_view); + assert(self.view_headers.command == .view); + + if (self.primary_index(self.view) != self.replica) { + assert(self.status == .normal); + assert(self.backup()); + assert(self.commit_min == self.op_checkpoint_next_trigger()); + } + + const op_max = if (self.primary_index(self.view) == self.replica) + self.op + else + self.commit_min; + + const journal_start = self.op + 1 -| constants.journal_slot_count; + const header_break = self.journal.find_latest_headers_break_between( + journal_start, + op_max, + ); + const op_min = if (header_break) |b| b.op_max + 1 else journal_start; + + if (self.op_checkpoint() == 0 and header_break != null) { + // We get here only if we are a backup with a missing root op, advancing our + // checkpoint mid-repair. Primaries can never have a missing root op as repair + // ensures a primary's journal is clean before it transitions to .normal status. + assert(self.backup()); + assert(header_break.?.op_min == 0); + assert(header_break.?.op_max == 0); + + assert(op_min == 1); + } else { + assert(op_min <= self.op_repair_min()); + } + + return .{ .op_min = op_min, .op_max = op_max }; + } + + fn primary_update_view_headers(self: *Replica) void { + assert(self.status != .recovering_head); + assert(self.replica == self.primary_index(self.view)); + assert(self.view == self.log_view); + if (self.status == .recovering) assert(self.solo()); + self.view_headers.command = .view; + self.update_view_headers(); + } + + /// The caller owns the returned message, if any, which has exactly 1 reference. + fn create_message_from_header(self: *Replica, header: Header) *Message { + assert( + header.view == self.view or + header.command == .pong_client or + header.command == .eviction or + header.command == .get_view or + header.command == .get_headers or + header.command == .get_prepare or + header.command == .get_reply or + header.command == .reply or + header.command == .ping or header.command == .pong, + ); + assert(header.size == @sizeOf(Header)); + + const message = self.message_bus.pool.get_message(null); + defer self.message_bus.unref(message); + + message.header.* = header; + message.header.set_checksum_body(message.body_used()); + message.header.set_checksum(); + + return message.ref(); + } + + fn flush_loopback_queue(self: *Replica) void { + // There are five cases where a replica will send a message to itself: + // However, of these five cases, all but one call send_message_to_replica(). + // + // 1. In on_request(), the primary sends a synchronous prepare to itself, but this is + // done by calling on_prepare() directly, and subsequent prepare timeout retries will + // never resend to self. + // 2. In on_prepare(), after writing to storage, the primary sends a (typically) + // asynchronous prepare_ok to itself. + // 3. In transition_to_view_change_status(), the new primary sends a synchronous JV to + // itself. + // 4. In primary_start_view_as_the_new_primary(), the new primary sends itself a + // prepare_ok message for each uncommitted message. + // 5. In send_exit_view(), a replica sends itself a EV message. + if (self.loopback_queue) |message| { + defer self.message_bus.unref(message); + + assert(!self.standby()); + + assert(message.link.next == null); + self.loopback_queue = null; + assert(message.header.replica == self.replica); + self.on_message(message); + // We do not call flush_loopback_queue() within on_message() to avoid recursion. + } + // We expect that delivering a prepare_ok or join_view message to ourselves will + // not result in any further messages being added synchronously to the loopback queue. + assert(self.loopback_queue == null); + } + + fn ignore_ping_client(self: *Replica, message: *const Message.PingClient) bool { + assert(message.header.command == .ping_client); + assert(message.header.client != 0); + + if (self.standby()) { + log.warn("{}: on_ping_client: misdirected message (standby)", .{ + self.log_prefix(), + }); + return true; + } + + // NB: Introduced in 0.17.6, `session` was implicitly 0 before that. + if (self.client_sessions.get(message.header.client) == null and + message.header.session != 0) + { + if (self.status == .normal and self.primary() and + self.commit_min >= message.header.session) + { + log.mark.warn("{}: on_ping_client: no session (client={})", .{ + self.log_prefix(), + message.header.client, + }); + self.send_eviction_message_to_client(message.header.client, .no_session); + return true; + } + } + + if (message.header.release.value < self.release_client_min.value) { + log.warn("{}: on_ping_client: ignoring unsupported client version; too low" ++ + " (client={} version={}<{})", .{ + self.log_prefix(), + message.header.client, + message.header.release, + self.release_client_min, + }); + if (self.status == .normal and self.primary()) { + self.send_eviction_message_to_client( + message.header.client, + .client_release_too_low, + ); + } + + return true; + } + + if (message.header.release.value > self.release.value) { + log.warn("{}: on_ping_client: ignoring unsupported client version; too high " ++ + "(client={} version={}>{})", .{ + self.log_prefix(), + message.header.client, + message.header.release, + self.release, + }); + if (self.status == .normal and self.primary()) { + self.send_eviction_message_to_client( + message.header.client, + .client_release_too_high, + ); + } + return true; + } + + return false; + } + + fn ignore_prepare_ok(self: *Replica, message: *const Message.PrepareOk) bool { + assert(message.header.command == .prepare_ok); + assert(message.header.replica < self.replica_count); + + if (self.primary_index(message.header.view) == self.replica) { + assert(message.header.view <= self.view); + } + + if (self.status != .normal) { + log.debug("{}: on_prepare_ok: ignoring ({})", .{ + self.log_prefix(), + self.status, + }); + return true; + } + + if (message.header.view < self.view) { + log.debug("{}: on_prepare_ok: ignoring (older view)", .{self.log_prefix()}); + return true; + } + + if (message.header.view > self.view) { + // Another replica is treating us as the primary for a view we do not know about. + // This may be caused by a fault in the network topology. + log.warn("{}: on_prepare_ok: misdirected message (newer view)", .{ + self.log_prefix(), + }); + return true; + } + + if (self.backup()) { + log.warn("{}: on_prepare_ok: misdirected message (backup)", .{self.log_prefix()}); + return true; + } + + return false; + } + + fn ignore_repair_message(self: *Replica, message: *const Message) bool { + assert(message.header.command == .get_view or + message.header.command == .get_headers or + message.header.command == .get_prepare or + message.header.command == .get_reply or + message.header.command == .headers); + switch (message.header.command) { + .headers => assert(message.header.replica < self.replica_count), + else => {}, + } + + const command: []const u8 = @tagName(message.header.command); + + if (message.header.command == .get_headers or + message.header.command == .get_prepare or + message.header.command == .get_reply) + { + // A recovering_head/syncing replica can still assist others with WAL/Reply-repair, + // but does not itself install headers, since its head is unknown. + } else { + if (self.status != .normal and self.status != .view_change) { + log.debug("{}: on_{s}: ignoring ({})", .{ + self.log_prefix(), + command, + self.status, + }); + return true; + } + } + + if (message.header.command == .get_headers or + message.header.command == .get_prepare or + message.header.command == .get_reply or + message.header.command == .headers) + { + // A replica in a different view can assist WAL repair. + } else { + assert(message.header.command == .get_view); + + if (message.header.view < self.view) { + log.debug("{}: on_{s}: ignoring (older view)", .{ + self.log_prefix(), + command, + }); + return true; + } + + if (message.header.view > self.view) { + log.debug("{}: on_{s}: ignoring (newer view)", .{ + self.log_prefix(), + command, + }); + return true; + } + } + + if (self.ignore_repair_message_during_view_change(message)) return true; + + if (message.header.replica == self.replica) { + log.warn("{}: on_{s}: misdirected message (self)", .{ + self.log_prefix(), + command, + }); + return true; + } + + if (self.standby()) { + switch (message.header.command) { + .headers => {}, + .get_view, .get_headers, .get_prepare, .get_reply => { + log.warn("{}: on_{s}: misdirected message (standby)", .{ + self.log_prefix(), + command, + }); + return true; + }, + else => unreachable, + } + } + + if (self.primary_index(self.view) != self.replica) { + switch (message.header.command) { + // Only the primary may receive these messages: + .get_view => { + log.warn("{}: on_{s}: misdirected message (backup)", .{ + self.log_prefix(), + command, + }); + return true; + }, + .get_prepare, .headers, .get_headers, .get_reply => {}, + else => unreachable, + } + } + return false; + } + + fn ignore_repair_message_during_view_change(self: *Replica, message: *const Message) bool { + if (self.status != .view_change) return false; + + const command: []const u8 = @tagName(message.header.command); + + switch (message.header.command) { + .get_view => { + log.debug("{}: on_{s}: ignoring (view change)", .{ + self.log_prefix(), + command, + }); + return true; + }, + .headers => { + if (self.primary_index(self.view) != self.replica) { + log.debug("{}: on_{s}: ignoring (view change, received by backup)", .{ + self.log_prefix(), + command, + }); + return true; + } else if (!self.join_view_quorum) { + log.debug("{}: on_{s}: ignoring (view change, waiting for quorum)", .{ + self.log_prefix(), + command, + }); + return true; + } + }, + .get_headers, .get_prepare, .get_reply => { + // on_headers, on_prepare, and on_reply have the appropriate logic to handle + // incorrect headers, prepares, and replies. + return false; + }, + else => unreachable, + } + + return false; + } + + fn ignore_request_message(self: *Replica, message: *Message.Request) bool { + if (self.standby()) { + log.warn("{}: on_request: misdirected message (standby)", .{self.log_prefix()}); + return true; + } + + if (self.status != .normal) { + log.debug("{}: on_request: ignoring ({})", .{ + self.log_prefix(), + self.status, + }); + return true; + } + + // A buggy client may send a view higher than one the cluster has seen. Err on the side + // of safety and drop such requests. + if (message.header.view > self.view) { + log.debug("{}: on_request: ignoring (view={} header.view={})", .{ + self.log_prefix(), + self.view, + message.header.view, + }); + return true; + } + + // This check must precede any send_eviction_message_to_client(), since only the primary + // should send evictions. + if (self.backup()) { + self.ignore_request_message_backup(message); + return true; + } + + assert(self.primary()); + + if (message.header.release.value < self.release_client_min.value) { + log.warn("{}: on_request: ignoring unsupported client version; too low" ++ + " (client={} version={}<{})", .{ + self.log_prefix(), + message.header.client, + message.header.release, + self.release_client_min, + }); + self.send_eviction_message_to_client( + message.header.client, + .client_release_too_low, + ); + return true; + } + + if (message.header.release.value > self.release.value) { + log.warn("{}: on_request: ignoring unsupported client version; too high " ++ + "(client={} version={}>{})", .{ + self.log_prefix(), + message.header.client, + message.header.release, + self.release, + }); + self.send_eviction_message_to_client( + message.header.client, + .client_release_too_high, + ); + return true; + } + + if (message.header.size > self.request_size_limit) { + log.warn("{}: on_request: ignoring oversized request (client={} size={}>{})", .{ + self.log_prefix(), + message.header.client, + message.header.size, + self.request_size_limit, + }); + self.send_eviction_message_to_client( + message.header.client, + .invalid_request_body_size, + ); + return true; + } + + // Some possible causes: + // - client bug + // - client memory corruption + // - client/replica version mismatch + if (!message.header.operation.valid(StateMachine.Operation)) { + log.warn("{}: on_request: ignoring invalid operation (client={} operation={})", .{ + self.log_prefix(), + message.header.client, + @intFromEnum(message.header.operation), + }); + self.send_eviction_message_to_client( + message.header.client, + .invalid_request_operation, + ); + return true; + } + if (StateMachine.Operation.from_vsr(message.header.operation)) |operation| { + if (!self.state_machine.input_valid( + operation, + message.body_used(), + )) { + log.warn( + "{}: on_request: ignoring invalid body (operation={s}, body.len={})", + .{ + self.log_prefix(), + @tagName(operation), + message.body_used().len, + }, + ); + self.send_eviction_message_to_client( + message.header.client, + .invalid_request_body, + ); + return true; + } + } + + // For compatibility with clients <= 0.15.3, `Request.invalid_header()` + // considers a `.register` without body as valid, evicting the client with + // `client_release_too_low` instead of silently dropping the invalid request. + // + // This code is a safeguard against **malformed** requests that have the + // expected release number but lack a `RegisterRequest`. + // TODO: Remove this code once `invalid_header()` starts rejecting the request. + if (message.header.operation == .register and + message.header.size != @sizeOf(Header) + @sizeOf(vsr.RegisterRequest)) + { + log.warn("{}: on_request: ignoring register without body" ++ + " (client={} version={}<{})", .{ + self.log_prefix(), + message.header.client, + message.header.release, + self.release_client_min, + }); + self.send_eviction_message_to_client( + message.header.client, + .invalid_request_body_size, + ); + return true; + } + + if (self.view_durable_updating()) { + log.debug("{}: on_request: ignoring (still persisting view)", .{ + self.log_prefix(), + }); + return true; + } + + if (self.ignore_request_message_upgrade(message)) return true; + if (self.ignore_request_message_duplicate(message)) return true; + if (self.ignore_request_message_preparing(message)) return true; + + return false; + } + + // If backups recognize a client, they reply directly to duplicate requests, while newer + // requests are forwarded to the primary. Older requests are dropped. If they don't + // recognize a client (or the client may have been evicted), only register requests are + // forwarded to the primary. + // The key motivation here is to only forward requests to the primary if there is a positive + // reason to do so, otherwise we risk flooding the network with spurious request messages. + fn ignore_request_message_backup(self: *Replica, message: *Message.Request) void { + assert(self.status == .normal); + assert(self.backup()); + assert(message.header.command == .request); + + if (self.client_sessions.get(message.header.client)) |entry| { + assert(entry.header.command == .reply); + assert(entry.header.client == message.header.client); + assert(entry.header.client != 0); + + if (entry.header.request < message.header.request) { + log.debug("{}: on_request: forwarding new request to primary (view={})", .{ + self.log_prefix(), + self.view, + }); + self.send_message_to_replica(self.primary_index(self.view), message); + } else if (entry.header.request == message.header.request) { + if (entry.header.request_checksum == message.header.checksum) { + log.debug("{}: on_request: replying to duplicate request", .{ + self.log_prefix(), + }); + self.on_request_repeat_reply(message, entry); + } else { + log.err("{}: on_request: request collision (client bug)", .{ + self.log_prefix(), + }); + } + } else { + log.debug("{}: on_request: ignoring older request", .{self.log_prefix()}); + } + } else { + if (message.header.operation == .register) { + log.debug("{}: on_request: forwarding register to primary (view={})", .{ + self.log_prefix(), + self.view, + }); + self.send_message_to_replica(self.primary_index(self.view), message); + } + } + } + + fn ignore_request_message_upgrade(self: *Replica, message: *const Message.Request) bool { + assert(self.status == .normal); + assert(self.primary()); + assert(message.header.command == .request); + + if (message.header.operation == .upgrade) { + const upgrade_request = std.mem.bytesAsValue( + vsr.UpgradeRequest, + message.body_used()[0..@sizeOf(vsr.UpgradeRequest)], + ); + + if (upgrade_request.release.value == self.release.value) { + log.debug("{}: on_request: ignoring (upgrade to current version)", .{ + self.log_prefix(), + }); + return true; + } + + if (upgrade_request.release.value < self.release.value) { + log.warn("{}: on_request: ignoring (upgrade to old version)", .{ + self.log_prefix(), + }); + return true; + } + + if (self.upgrade_release) |upgrade_release| { + if (upgrade_request.release.value != upgrade_release.value) { + log.warn("{}: on_request: ignoring (upgrade to different version)", .{ + self.log_prefix(), + }); + return true; + } + } + } else { + if (self.upgrade_release) |_| { + // While we are trying to upgrade, ignore non-upgrade requests. + // + // The objective is to reach a checkpoint such that the last bar of messages + // immediately prior to the checkpoint trigger are noops (operation=upgrade) so + // that they will behave identically before and after the upgrade when they are + // replayed. + log.debug("{}: on_request: ignoring (upgrading)", .{self.log_prefix()}); + return true; + } + + // Even though `operation=upgrade` hasn't committed, it may be in the pipeline. + if (self.pipeline.queue.contains_operation(.upgrade)) { + log.debug("{}: on_request: ignoring (upgrade queued)", .{self.log_prefix()}); + return true; + } + } + return false; + } + + /// Returns whether the request is stale, or a duplicate of the latest committed request. + /// Resends the reply to the latest request if the request has been committed. + fn ignore_request_message_duplicate(self: *Replica, message: *const Message.Request) bool { + assert(self.status == .normal); + assert(self.primary()); + assert(self.syncing == .idle); + + assert(message.header.command == .request); + assert(message.header.view <= self.view); + assert(message.header.session == 0 or message.header.operation != .register); + assert(message.header.request == 0 or message.header.operation != .register); + + if (self.client_sessions.get(message.header.client)) |entry| { + assert(entry.header.command == .reply); + assert(entry.header.client == message.header.client); + assert(entry.header.client != 0); + + if (message.header.operation == .register) { + // Fall through below to check if we should resend the .register session reply. + } else if (entry.session > message.header.session) { + // The client must not reuse the ephemeral client ID when registering a new + // session. + // + // Alternatively, this could be caused by the following scenario: + // 1. Client `A` sends an `operation=register` to a fresh cluster. (`A₁`) + // 2. Cluster prepares + commits `A₁`, and sends the reply to `A`. + // 4. `A` receives the reply to `A₁`, and issues a second request (`A₂`). + // 5. `clients_max` other clients register, evicting `A`'s session. + // 6. An old retry (or replay) of `A₁` arrives at the cluster. + // 7. `A₁` is committed (for a second time, as a different op, evicting one of + // the other clients). + // 8. `A` sends a second request (`A₂`), but `A` has the session number from the + // first time `A₁` was committed. + log.mark.err("{}: on_request: ignoring older session", .{self.log_prefix()}); + self.send_eviction_message_to_client(message.header.client, .session_too_low); + return true; + } else if (entry.session < message.header.session) { + // This cannot be because of a partition since we check the client's view + // number. + log.err( + "{}: on_request: ignoring newer session (client bug)", + .{self.log_prefix()}, + ); + return true; + } + + if (entry.header.release.value != message.header.release.value) { + // Clients must not change releases mid-session. + log.err( + "{}: on_request: ignoring request from unexpected release" ++ + " expected={} found={} (client bug)", + .{ self.log_prefix(), entry.header.release, message.header.release }, + ); + self.send_eviction_message_to_client( + message.header.client, + .session_release_mismatch, + ); + return true; + } + + if (entry.header.request > message.header.request) { + log.debug("{}: on_request: ignoring older request", .{self.log_prefix()}); + return true; + } else if (entry.header.request == message.header.request) { + if (message.header.checksum == entry.header.request_checksum) { + assert(entry.header.operation == message.header.operation); + + log.debug("{}: on_request: replying to duplicate request", .{ + self.log_prefix(), + }); + self.on_request_repeat_reply(message, entry); + return true; + } else { + log.err("{}: on_request: request collision (client bug)", .{ + self.log_prefix(), + }); + return true; + } + } else if (entry.header.request + 1 == message.header.request) { + if (message.header.parent == entry.header.context) { + // The client has proved that they received our last reply. + log.debug("{}: on_request: new request", .{self.log_prefix()}); + return false; + } else { + // The client may have only one request inflight at a time. + log.err("{}: on_request: ignoring new request (client bug)", .{ + self.log_prefix(), + }); + return true; + } + } else { + // Caused by one of the following: + // - client bug, or + // - this primary is no longer the actual primary + log.err("{}: on_request: ignoring newer request (client|network bug)", .{ + self.log_prefix(), + }); + return true; + } + } else if (message.header.operation == .register) { + log.debug("{}: on_request: new session", .{self.log_prefix()}); + return false; + } else if (self.pipeline.queue.message_by_client(message.header.client)) |_| { + // The client registered with the previous primary, which committed and replied back + // to the client before the view change, after which the register operation was + // reloaded into the pipeline to be driven to completion by the new primary, which + // now receives a request from the client that appears to have no session. + // However, the session is about to be registered, so we must wait for it to commit. + log.debug( + "{}: on_request: waiting for session to commit (client={})", + .{ self.log_prefix(), message.header.client }, + ); + return true; + } else { + if (message.header.client == 0) { + assert(message.header.operation == .pulse or + message.header.operation == .upgrade); + assert(message.header.request == 0); + return false; + } else { + // We must have all commits to know whether a session has been evicted. For + // example, there is the risk of sending an eviction message (even as the + // primary) if we are partitioned and don't yet know about a session. We solve + // this by having clients include the view number and rejecting messages from + // clients with newer views. + log.mark.warn("{}: on_request: no session (client={})", .{ + self.log_prefix(), + message.header.client, + }); + self.send_eviction_message_to_client(message.header.client, .no_session); + return true; + } + } + } + + fn on_request_repeat_reply( + self: *Replica, + message: *const Message.Request, + entry: *const ClientSessions.Entry, + ) void { + assert(self.status == .normal); + + assert(message.header.command == .request); + assert(message.header.client > 0); + assert(message.header.view <= self.view); + assert(message.header.session == 0 or message.header.operation != .register); + assert(message.header.request == 0 or message.header.operation != .register); + assert(message.header.checksum == entry.header.request_checksum); + assert(message.header.request == entry.header.request); + + if (entry.header.size == @sizeOf(Header)) { + const reply = self.create_message_from_header(@bitCast(entry.header)) + .into(.reply).?; + defer self.message_bus.unref(reply); + + self.send_reply_message_to_client(reply); + return; + } + + const slot = self.client_sessions.get_slot_for_client(message.header.client).?; + if (self.client_replies.read_reply_sync(slot, entry)) |reply| { + on_request_repeat_reply_callback( + &self.client_replies, + &entry.header, + reply, + null, + ); + } else { + self.client_replies.read_reply( + slot, + entry, + on_request_repeat_reply_callback, + null, + ) catch |err| switch (err) { + error.Busy => { + log.debug("{}: on_request: ignoring (client_replies busy)", .{ + self.log_prefix(), + }); + }, + }; + } + } + + fn on_request_repeat_reply_callback( + client_replies: *ClientReplies, + reply_header: *const Header.Reply, + reply_: ?*Message.Reply, + destination_replica: ?u8, + ) void { + const self: *Replica = @alignCast(@fieldParentPtr("client_replies", client_replies)); + assert(reply_header.size > @sizeOf(Header)); + assert(destination_replica == null); + + const reply = reply_ orelse { + if (self.client_sessions.get_slot_for_header(reply_header)) |slot| { + self.client_replies.faulty.set(slot.index); + } else { + // The read may have been a repair for an older op, + // or a newer op that we haven't seen yet. + } + return; + }; + assert(reply.header.checksum == reply_header.checksum); + assert(reply.header.size > @sizeOf(Header)); + + log.debug("{}: on_request: repeat reply (client={} request={})", .{ + self.log_prefix(), + reply.header.client, + reply.header.request, + }); + + self.send_reply_message_to_client(reply); + } + + fn ignore_request_message_preparing(self: *Replica, message: *const Message.Request) bool { + assert(self.status == .normal); + assert(self.primary()); + + assert(message.header.command == .request); + assert(message.header.view <= self.view); + + if (self.pipeline.queue.message_by_client(message.header.client)) |pipeline_message| { + assert(pipeline_message.header.command == .request or + pipeline_message.header.command == .prepare); + assert(message.header.client != 0); + + switch (pipeline_message.header.into_any()) { + .request => |pipeline_message_header| { + assert(pipeline_message_header.client == message.header.client); + + if (pipeline_message.header.checksum == message.header.checksum) { + assert(pipeline_message_header.request == message.header.request); + log.debug("{}: on_request: ignoring (already queued)", .{ + self.log_prefix(), + }); + return true; + } + }, + .prepare => |pipeline_message_header| { + assert(pipeline_message_header.client == message.header.client); + + if (pipeline_message_header.request_checksum == message.header.checksum) { + assert(pipeline_message_header.op > self.commit_max); + assert(pipeline_message_header.request == message.header.request); + log.debug("{}: on_request: ignoring (already preparing)", .{ + self.log_prefix(), + }); + return true; + } + }, + else => unreachable, + } + + log.warn("{}: on_request: ignoring (client forked)", .{self.log_prefix()}); + return true; + } + + if (self.pipeline.queue.full()) { + log.debug("{}: on_request: ignoring (pipeline full)", .{self.log_prefix()}); + return true; + } + + return false; + } + + fn ignore_exit_view_message( + self: *const Replica, + message: *const Message.ExitView, + ) bool { + assert(message.header.command == .exit_view); + assert(message.header.replica < self.replica_count); + + if (self.standby()) { + log.warn("{}: on_exit_view: misdirected message (standby)", .{ + self.log_prefix(), + }); + return true; + } + + switch (self.status) { + .normal, + .view_change, + => {}, + .recovering => unreachable, // Single node clusters don't have view changes. + .recovering_head => { + log.debug("{}: on_exit_view: ignoring (status={})", .{ + self.log_prefix(), + self.status, + }); + return true; + }, + } + + if (self.syncing != .idle) { + log.debug("{}: on_exit_view: ignoring (sync_status={s})", .{ + self.log_prefix(), + @tagName(self.syncing), + }); + return true; + } + + if (message.header.view < self.view) { + log.debug("{}: on_exit_view: ignoring (older view)", .{self.log_prefix()}); + return true; + } + + return false; + } + + fn ignore_view_change_message(self: *const Replica, message: *const Message) bool { + assert(message.header.command == .join_view or + message.header.command == .view); + assert(self.status != .recovering); // Single node clusters don't have view changes. + assert(message.header.replica < self.replica_count); + + const command: []const u8 = @tagName(message.header.command); + + if (message.header.view < self.view) { + log.debug("{}: on_{s}: ignoring (older view)", .{ + self.log_prefix(), + command, + }); + return true; + } + + switch (message.header.into_any()) { + .view => |message_header| { + // This may be caused by faults in the network topology. + if (message.header.replica == self.replica) { + log.warn("{}: on_{s}: misdirected message (self)", .{ + self.log_prefix(), + command, + }); + return true; + } + + // Syncing replicas must be careful about receiving View messages, since they + // may have fast-forwarded their commit_max via their checkpoint target. + if (message_header.commit_max < self.op_checkpoint()) { + log.debug("{}: on_{s}: ignoring (older checkpoint)", .{ + self.log_prefix(), + command, + }); + return true; + } + }, + .join_view => { + assert(message.header.view > 0); // The initial view is already zero. + + if (self.standby()) { + log.warn("{}: on_{s}: misdirected message (standby)", .{ + self.log_prefix(), + command, + }); + return true; + } + + if (self.status == .recovering_head) { + log.debug("{}: on_{s}: ignoring (recovering_head)", .{ + self.log_prefix(), + command, + }); + return true; + } + + if (message.header.view == self.view and self.status == .normal) { + log.debug("{}: on_{s}: ignoring (view started)", .{ + self.log_prefix(), + command, + }); + return true; + } + + if (self.join_view_quorum) { + log.debug("{}: on_{s}: ignoring (quorum received already)", .{ + self.log_prefix(), + command, + }); + return true; + } + + if (self.primary_index(self.view) != self.replica) { + for (self.join_view_from_all_replicas) |jv| assert(jv == null); + + log.debug("{}: on_{s}: ignoring (backup awaiting View)", .{ + self.log_prefix(), + command, + }); + return true; + } + }, + else => unreachable, + } + + return false; + } + + /// Returns the index into the configuration of the primary for a given view. + pub fn primary_index(self: *const Replica, view: u32) u8 { + return @intCast(@mod(view, self.replica_count)); + } + + /// Returns whether the replica is the primary for the current view. + /// This may be used only when the replica status is normal. + pub fn primary(self: *const Replica) bool { + assert(self.status == .normal); + return self.primary_index(self.view) == self.replica; + } + + /// Returns whether the replica is a backup for the current view. + /// This may be used only when the replica status is normal. + fn backup(self: *const Replica) bool { + return !self.primary(); + } + + /// Returns whether the replica is a single-replica cluster. + /// + /// Single-replica clusters often are a special case (no view changes or + /// repairs, prepares are written to WAL sequentially). + /// + /// Note that a solo cluster might still have standby nodes. + pub fn solo(self: *const Replica) bool { + return self.replica_count == 1 and !self.standby(); + } + + /// Returns whether the replica is a standby. + /// + /// Standbys follow the cluster without participating in consensus. In particular, + /// standbys receive and replicate prepares, but never send prepare-oks. + pub fn standby(self: *const Replica) bool { + assert(self.replica < self.node_count); + return self.replica >= self.replica_count; + } + + /// Advances `op` to where we need to be before `header` can be processed as a prepare. + /// + /// This function temporarily violates the "replica.op must exist in WAL" invariant. + fn jump_to_newer_op_in_normal_status( + self: *Replica, + header: *const Header.Prepare, + ) void { + assert(self.status == .normal); + assert(self.backup()); + assert(header.view == self.view); + assert(header.op > self.op + 1); + // We may have learned of a higher `commit_max` through a commit message before jumping + // to a newer op that is less than `commit_max` but greater than `commit_min`: + assert(header.op > self.commit_min); + // Never overwrite an op that still needs to be checkpointed. + assert(header.op <= self.op_prepare_max() or + vsr.Checkpoint.durable(self.op_checkpoint_next(), self.commit_max)); + + log.debug("{}: jump_to_newer_op: advancing: op={}..{} checksum={x:0>32}..{x:0>32}", .{ + self.log_prefix(), + self.op, + header.op - 1, + self.journal.header_with_op(self.op).?.checksum, + header.parent, + }); + + self.op = header.op - 1; + assert(self.op >= self.commit_min); + assert(self.op + 1 == header.op); + assert(self.journal.header_with_op(self.op) == null); + } + + /// Returns whether the head op is certain. + /// + /// After recovering the WAL, there are 2 possible outcomes: + /// * All entries valid. The highest op is certain, and safe to set as `replica.op`. + /// * One or more entries are faulty. The highest op isn't certain — it may be one of the + /// broken entries. + /// + /// The replica must refrain from repairing any faulty slots until the highest op is known. + /// Otherwise, if we were to repair a slot while uncertain of `replica.op`: + /// + /// * we may nack an op that we shouldn't, or + /// * we may replace a prepared op that we were guaranteeing for the primary, potentially + /// forking the log. + /// + /// + /// Test for a fault the right of the current op. The fault might be our true op, and + /// sharing our current `replica.op` might cause the cluster's op to likewise regress. + /// + /// Note that for our purposes here, we only care about entries that were faulty during + /// WAL recovery, not ones that were found to be faulty after the fact (e.g. due to + /// `get_prepare`). + /// + /// Cases (`✓`: `replica.op_checkpoint`, `✗`: faulty, `o`: `replica.op`): + /// * ` ✓ o ✗ `: View change is unsafe. + /// * ` ✗ ✓ o `: View change is unsafe. + /// * ` ✓ ✗ o `: View change is safe. + /// * ` ✓ = o `: View change is unsafe if any slots are faulty. + /// (`replica.op_checkpoint` == `replica.op`). + fn op_head_certain(self: *const Replica) bool { + assert(self.status == .recovering); + assert(self.op >= self.op_checkpoint()); + assert(self.op <= self.op_prepare_max()); + + // Head is guaranteed to be certain; replica couldn't have prepared past prepare_max. + if (self.op == self.op_prepare_max()) return true; + + const slot_prepare_max = self.journal.slot_for_op(self.op_prepare_max()); + const slot_op_head = self.journal.slot_with_op(self.op).?; + + // For the op-head to be faulty, this must be a header that was restored from the + // superblock VSR headers atop a corrupt slot. We can't trust the head: that corrupt + // slot may have originally been op that is a wrap ahead. + if (self.journal.faulty.bit(slot_op_head)) { + log.warn("{}: op_head_certain: faulty head slot={}", .{ + self.log_prefix(), + slot_op_head, + }); + return false; + } + + // If faulty, this slot may hold either: + // - op=op_checkpoint, or + // - op=op_prepare_max + if (self.journal.faulty.bit(slot_prepare_max)) { + log.warn("{}: op_head_certain: faulty prepare_max slot={}", .{ + self.log_prefix(), + slot_prepare_max, + }); + return false; + } + + const slot_known_range = vsr.SlotRange{ + .head = self.journal.slot_for_op(self.op + 1), + .tail = self.journal.slot_for_op(self.op_prepare_max()), + }; + // Checked separately as SlotRange.contains doesn't handle empty ranges. + const range_empty = slot_known_range.head.index == + slot_known_range.tail.index; + + var iterator = self.journal.faulty.bits.iterator(.{ .kind = .set }); + while (iterator.next()) |index| { + if ((range_empty and index == slot_prepare_max.index) or + (!range_empty and slot_known_range.contains(.{ .index = index }))) + { + log.warn("{}: op_head_certain: faulty slot={}", .{ + self.log_prefix(), + index, + }); + return false; + } + } + return true; + } + + /// The op of the highest checkpointed prepare. + pub fn op_checkpoint(self: *const Replica) u64 { + return self.superblock.working.vsr_state.checkpoint.header.op; + } + + /// Like op_checkpoint, but takes into account the in-memory checkpoint during sync. + fn op_checkpoint_sync(self: *const Replica) u64 { + if (self.syncing != .updating_checkpoint) + return self.op_checkpoint() + else + return self.syncing.updating_checkpoint.header.op; + } + + /// Returns the op that will be `op_checkpoint` after the next checkpoint. + pub fn op_checkpoint_next(self: *const Replica) u64 { + assert(vsr.Checkpoint.valid(self.op_checkpoint())); + assert(self.op_checkpoint() <= self.commit_min); + assert(self.op_checkpoint() <= self.op or + self.status == .recovering or self.status == .recovering_head); + + const checkpoint_next = vsr.Checkpoint.checkpoint_after(self.op_checkpoint()); + assert(vsr.Checkpoint.valid(checkpoint_next)); + assert(checkpoint_next > self.op_checkpoint()); // The checkpoint always advances. + + return checkpoint_next; + } + + /// Returns the next op that will trigger a checkpoint. + /// + /// See `op_checkpoint_next` for more detail. + fn op_checkpoint_next_trigger(self: *const Replica) u64 { + return vsr.Checkpoint.trigger_for_checkpoint(self.op_checkpoint_next()).?; + } + + /// Returns the highest op that this replica can safely prepare to its WAL. + /// + /// Receiving and storing an op higher than `op_prepare_max()` is allowed only if the op + /// overwrites a message (or the slot of a message) that has already been committed. + pub fn op_prepare_max(self: *const Replica) u64 { + return vsr.Checkpoint.prepare_max_for_checkpoint(self.op_checkpoint_next()).?; + } + + /// Like prepare_max, but takes into account the in-memory checkpoint during sync. + fn op_prepare_max_sync(self: *const Replica) u64 { + if (self.syncing != .updating_checkpoint) return self.op_prepare_max(); + + return vsr.Checkpoint.prepare_max_for_checkpoint( + vsr.Checkpoint.checkpoint_after( + self.syncing.updating_checkpoint.header.op, + ), + ).?; + } + + /// Returns the highest op that this replica can safely prepare_ok. + /// + /// Sending prepare_ok for a particular op signifies that a replica has a sufficiently fresh + /// checkpoint. Specifically, if a replica is at checkpoint Cₙ, it withholds prepare_oks for + /// ops larger than Cₙ₊₁ + compaction_interval + pipeline_prepare_queue_max. + /// Committing past this op would allow a primary at checkpoint Cₙ₊₁ to overwrite ops from + /// the previous wrap, which is safe to do only if a commit quorum of replicas are on Cₙ₊₁. + /// + /// For example, assume the following constants: + /// slot_count=32, compaction_interval=4, pipeline_prepare_queue_max=4, checkpoint_ops=20. + /// + /// Further, assume: + /// * Primary R1 is at op_checkpoint=19, op=27, op_prepare_max=51, preparing op=28. + /// * Backup R2 is at op_checkpoint=0, op=22, op_prepare_max=31. + /// + /// R2 writes op=28 to its WAL but does *not* prepare_ok it, because that would allow R1 to + /// prepare op=32, overwriting op=0 from the previous wrap *before* op_checkpoint=19 is + /// durable on a commit quorum of replicas. Instead, R2 waits till it commits op=23 and + /// reaches op_checkpoint=19. Thereafter, it sends withheld prepare_oks for ops 28 → 31. + fn op_prepare_ok_max(self: *const Replica) u64 { + // No state sync, the grid and checkpoint can be trusted. + if (self.sync_grid_done()) { + return self.op_checkpoint_next_trigger() + constants.pipeline_prepare_queue_max; + } + + // State sync, but the grid *can* be trusted as we synced to + // a checkpoint that is durable on a quorum of replicas. + if (vsr.Checkpoint.durable(self.op_checkpoint(), self.commit_max)) { + return self.op_checkpoint_next_trigger() + constants.pipeline_prepare_queue_max; + } + + // State sync, but the grid *can't* be trusted as we synced to + // to a checkpoint that is *not* durable on a quorum of replicas. + // To avoid falsely contributing to checkpoint durability, we + // withhold some prepare_oks till we finish syncing all tables. + const op_checkpoint_trigger = + vsr.Checkpoint.trigger_for_checkpoint(self.op_checkpoint()).?; + return op_checkpoint_trigger + constants.pipeline_prepare_queue_max; + } + + /// Returns checkpoint id associated with the op. + /// + /// Specifically, returns the checkpoint id corresponding to the checkpoint with: + /// + /// prepare.op > checkpoint_op + /// prepare.op ≤ checkpoint_after(checkpoint_op) + /// + /// Returns `null` for ops which are too far in the past/future to know their checkpoint + /// ids. + fn checkpoint_id_for_op(self: *const Replica, op: u64) ?u128 { + const checkpoint_now = self.op_checkpoint_sync(); + const checkpoint_next_1 = vsr.Checkpoint.checkpoint_after(checkpoint_now); + const checkpoint_next_2 = vsr.Checkpoint.checkpoint_after(checkpoint_next_1); + + const checkpoint: *const vsr.CheckpointState = if (self.syncing == .updating_checkpoint) + &self.syncing.updating_checkpoint + else + &self.superblock.working.vsr_state.checkpoint; + + if (op + constants.vsr_checkpoint_ops <= checkpoint_now) { + // Case 1: op is from a too distant past for us to know its checkpoint id. + return null; + } + + if (op <= checkpoint_now) { + // Case 2: op is from the previous checkpoint whose id we still remember. + return checkpoint.grandparent_checkpoint_id; + } + + if (op <= checkpoint_next_1) { + // Case 3: op is in the current checkpoint. + return checkpoint.parent_checkpoint_id; + } + + if (op <= checkpoint_next_2) { + // Case 4: op is in the next checkpoint (which we have not checkpointed). + return vsr.checksum(std.mem.asBytes(checkpoint)); + } + + // Case 5: op is from the too far future for us to know anything! + return null; + } + + /// Returns the oldest op that the replica must/(is permitted to) repair. + /// + /// Safety condition: repairing an old op must not overwrite a newer op from the next wrap. + /// + /// Availability condition: each committed op must be present either in a quorum of WALs or + /// in a quorum of checkpoints. + /// + /// If op=prepare_ok_max+1 is committed, a quorum of replicas have moved to the *next* + /// prepare_ok_max, which in turn signals that the corresponding checkpoint is durably + /// present on a quorum of replicas. Repairing all ops since the latest durable checkpoint + /// satisfies both conditions. + /// + /// When called from status=recovering_head or status=recovering, the caller is responsible + /// for ensuring that replica.op is valid. + pub fn op_repair_min(self: *const Replica) u64 { + if (self.status == .recovering) assert(self.solo()); + assert(self.op >= self.op_checkpoint_sync()); + assert(self.op < (vsr.Checkpoint + .checkpoint_after(self.op_checkpoint_sync()) + constants.journal_slot_count)); + assert(self.op <= self.op_prepare_max_sync() or + vsr.Checkpoint.durable(self.op_checkpoint_next(), self.commit_max)); + + assert(self.commit_max >= self.op -| constants.pipeline_prepare_queue_max); + + const repair_min = repair_min: { + if (vsr.Checkpoint.durable(self.op_checkpoint_sync(), self.commit_max)) { + if (self.op == self.op_checkpoint_sync()) { + // Don't allow "op_repair_min > op_head". + // See https://github.com/tigerbeetle/tigerbeetle/pull/1589 for why + // this is required. + break :repair_min self.op_checkpoint_sync(); + } + + if (self.op > self.op_prepare_max_sync()) { + assert(vsr.Checkpoint.durable( + vsr.Checkpoint.checkpoint_after(self.op_checkpoint_sync()), + self.commit_max, + )); + break :repair_min (self.op + 1) -| constants.journal_slot_count; + } + + break :repair_min if (self.op_checkpoint_sync() == 0) + 0 + else + self.op_checkpoint_sync() + 1; + } else { + break :repair_min (self.op_checkpoint_sync() + 1) -| + constants.vsr_checkpoint_ops; + } + }; + + assert(repair_min <= self.op); + assert(repair_min <= self.commit_min + 1); + assert(self.op - repair_min < constants.journal_slot_count); + assert(self.checkpoint_id_for_op(repair_min) != null); + return repair_min; + } + + /// The replica repairs backwards from `commit_max`. But if `commit_max` is too high + /// (part of the next WAL wrap), then bound it such that uncommitted WAL entries are not + /// overwritten. + fn op_repair_max(self: *const Replica) u64 { + assert(self.status != .recovering_head); + assert(self.op >= self.op_checkpoint()); + assert(self.op <= self.op_prepare_max_sync() or + vsr.Checkpoint.durable(self.op_checkpoint_next(), self.commit_max)); + assert((self.op < vsr.Checkpoint + .checkpoint_after(self.op_checkpoint_sync()) + constants.journal_slot_count)); + assert(self.op <= self.commit_max + constants.pipeline_prepare_queue_max); + + const repair_max = @min(self.commit_max, @max(self.op_prepare_max_sync(), self.op)); + + assert(repair_max - self.op_repair_min() <= constants.journal_slot_count); + return repair_max; + } + + /// Panics if immediate neighbors in the same view would have a broken hash chain. + /// Assumes gaps and does not require that a precedes b. + fn panic_if_hash_chain_would_break_in_the_same_view( + self: *const Replica, + a: *const Header.Prepare, + b: *const Header.Prepare, + ) void { + assert(a.command == .prepare); + assert(b.command == .prepare); + assert(a.cluster == b.cluster); + if (a.view == b.view and a.op + 1 == b.op and a.checksum != b.parent) { + assert(a.valid_checksum()); + assert(b.valid_checksum()); + log.err("{}: panic_if_hash_chain_would_break: a: {}", .{ + self.log_prefix(), + a, + }); + log.err("{}: panic_if_hash_chain_would_break: b: {}", .{ + self.log_prefix(), + b, + }); + @panic("hash chain would break"); + } + } + + fn primary_pipeline_prepare(self: *Replica, request: Request) void { + assert(self.status == .normal); + assert(self.primary()); + assert(!self.view_durable_updating()); + assert(self.commit_min == self.commit_max); + assert(self.commit_max + self.pipeline.queue.prepare_queue.count == self.op); + assert(!self.pipeline.queue.prepare_queue.full()); + self.pipeline.queue.verify(); + + defer self.message_bus.unref(request.message); + + log.debug("{}: primary_pipeline_prepare: request checksum={x:0>32} client={}", .{ + self.log_prefix(), + request.message.header.checksum, + request.message.header.client, + }); + + if (request.message.header.previous_request_latency != 0) { + if (StateMachine.Operation == @import("../tigerbeetle.zig").Operation and + self.status == .normal) + { + const session_entry = self.client_sessions.get(request.message.header.client); + if (session_entry) |entry| { + const operation = entry.header.operation; + + // Starting from 0.17.0, the previous_request_latency field encodes + // microseconds and not nanoseconds. + const release_duration_us = vsr.Release.from(.{ + .major = 0, + .minor = 17, + .patch = 0, + }); + + const duration = if (request.message.header.release.value < + release_duration_us.value) + stdx.Duration{ .ns = request.message.header.previous_request_latency } + else + Duration.us(request.message.header.previous_request_latency); + + self.trace.timing( + .{ .client_request_round_trip = .from(operation) }, + duration, + ); + } + } + } + + // Guard against the wall clock going backwards by taking the max with timestamps + // issued: + self.state_machine.prepare_timestamp = @max( + // The cluster `commit_timestamp` may be ahead of our `prepare_timestamp` because + // this may be our first prepare as a recently elected primary: + @max( + self.state_machine.prepare_timestamp, + self.state_machine.commit_timestamp, + ) + 1, + @as(u64, @intCast(request.realtime)), + ); + assert(self.state_machine.prepare_timestamp > self.state_machine.commit_timestamp); + + switch (request.message.header.operation) { + .reserved, .root => unreachable, + .register => self.primary_prepare_register(request.message), + .reconfigure => self.primary_prepare_reconfiguration(request.message), + .upgrade => { + const upgrade_request = std.mem.bytesAsValue( + vsr.UpgradeRequest, + request.message.body_used()[0..@sizeOf(vsr.UpgradeRequest)], + ); + + if (self.release.value == upgrade_request.release.value) { + const op_checkpoint_trigger = + vsr.Checkpoint.trigger_for_checkpoint(self.op_checkpoint()).?; + assert(op_checkpoint_trigger > self.op + 1); + } + }, + .noop => {}, + else => { + self.state_machine.prepare( + request.message.header.operation.cast(StateMachine.Operation), + request.message.body_used(), + ); + }, + } + const prepare_timestamp = self.state_machine.prepare_timestamp; + + // Reuse the Request message as a Prepare message by replacing the header. + const message = request.message.base().build(.prepare); + + // Copy the header to the stack before overwriting it to avoid UB. + const request_header: Header.Request = request.message.header.*; + + const checkpoint_id = if (self.op + 1 <= self.op_checkpoint_next()) + self.superblock.working.vsr_state.checkpoint.parent_checkpoint_id + else + self.superblock.working.checkpoint_id(); + + const latest_entry = self.journal.header_with_op(self.op).?; + message.header.* = Header.Prepare{ + .checksum_body = request_header.checksum_body, + .cluster = self.cluster, + .size = request_header.size, + .view = self.view, + .release = request_header.release, + .command = .prepare, + .replica = self.replica, + .parent = latest_entry.checksum, + .client = request_header.client, + .request_checksum = request_header.checksum, + .checkpoint_id = checkpoint_id, + .op = self.op + 1, + .commit = self.commit_max, + .timestamp = timestamp: { + // When running in AOF recovery mode, the client must pass explicit timestamps. + if (self.aof_recovery) { + assert(request_header.timestamp != 0); + break :timestamp request_header.timestamp; + } else { + break :timestamp prepare_timestamp; + } + }, + .request = request_header.request, + .operation = request_header.operation, + }; + + switch (message.header.operation) { + .register, .reconfigure => message.header.set_checksum_body(message.body_used()), + else => if (constants.verify) { + assert(message.header.valid_checksum_body(message.body_used())); + }, + } + message.header.set_checksum(); + + const size_ceil = vsr.sector_ceil(message.header.size); + assert(stdx.zeroed(message.buffer[message.header.size..size_ceil])); + + log.debug("{}: primary_pipeline_prepare: prepare checksum={x:0>32} op={}", .{ + self.log_prefix(), + message.header.checksum, + message.header.op, + }); + + if (self.primary_pipeline_pending()) |_| { + // Do not restart the prepare timeout as it is already ticking for another prepare. + const previous = self.pipeline.queue.prepare_queue.tail_ptr().?; + assert(previous.message.header.checksum == message.header.parent); + assert(self.prepare_timeout.ticking); + assert(self.primary_abdicate_timeout.ticking); + } else { + assert(!self.prepare_timeout.ticking); + self.prepare_timeout.start(); + maybe(!self.primary_abdicate_timeout.ticking); + self.primary_abdicate_timeout.start(); + } + + if (!self.aof_recovery) { + assert(self.pulse_timeout.ticking); + self.pulse_timeout.reset(); + } + + self.pipeline.queue.push_prepare(message); + self.on_prepare(message); + + // We expect `on_prepare()` to increment `self.op` to match the primary's latest + // prepare: This is critical to ensure that pipelined prepares do not receive the same + // op number. + assert(self.op == message.header.op); + } + + fn primary_prepare_register(self: *Replica, request: *Message.Request) void { + assert(self.primary()); + assert(request.header.command == .request); + assert(request.header.operation == .register); + assert(request.header.request == 0); + + assert(request.header.size == @sizeOf(vsr.Header) + @sizeOf(vsr.RegisterRequest)); + + const batch_size_limit = self.request_size_limit - @sizeOf(vsr.Header); + assert(batch_size_limit > 0); + assert(batch_size_limit <= constants.message_body_size_max); + + const register_request = std.mem.bytesAsValue( + vsr.RegisterRequest, + request.body_used()[0..@sizeOf(vsr.RegisterRequest)], + ); + assert(register_request.batch_size_limit == 0); + assert(stdx.zeroed(®ister_request.reserved)); + + register_request.* = .{ + .batch_size_limit = batch_size_limit, + }; + } + + fn primary_prepare_reconfiguration( + self: *const Replica, + request: *Message.Request, + ) void { + assert(self.primary()); + assert(request.header.command == .request); + assert(request.header.operation == .reconfigure); + assert( + request.header.size == @sizeOf(vsr.Header) + @sizeOf(vsr.ReconfigurationRequest), + ); + const reconfiguration_request = std.mem.bytesAsValue( + vsr.ReconfigurationRequest, + request.body_used()[0..@sizeOf(vsr.ReconfigurationRequest)], + ); + reconfiguration_request.*.result = reconfiguration_request.validate(.{ + .members = &self.superblock.working.vsr_state.members, + .epoch = 0, + .replica_count = self.replica_count, + .standby_count = self.standby_count, + }); + assert(reconfiguration_request.result != .reserved); + } + + /// Returns the next prepare in the pipeline waiting for a quorum. + /// Returns null when the pipeline is empty. + /// Returns null when the pipeline is nonempty but all prepares have a quorum. + fn primary_pipeline_pending(self: *const Replica) ?*const Prepare { + assert(self.status == .normal); + assert(self.primary()); + + var prepares = self.pipeline.queue.prepare_queue.iterator(); + while (prepares.next_ptr()) |prepare| { + assert(prepare.message.header.command == .prepare); + if (!prepare.ok_quorum_received) { + return prepare; + } + } else { + return null; + } + } + + fn pipeline_prepare_by_op_and_checksum( + self: *Replica, + op: u64, + checksum: u128, + ) ?*Message.Prepare { + return switch (self.pipeline) { + .cache => |*cache| cache.prepare_by_op_and_checksum(op, checksum), + .queue => |*queue| if (queue.prepare_by_op_and_checksum(op, checksum)) |prepare| + prepare.message + else + null, + }; + } + + /// Repair. Each step happens in sequence — step n+1 executes when step n is done. + /// + /// 1. If we are a backup and have fallen too far behind the primary, initiate state sync. + /// 2. Advance the head op to `op_repair_max = min(op_prepare_max, commit_max)`. + /// To advance the head op we request+await a View. Either: + /// - the View's "hook" headers include op_prepare_max (if we are ≤1 wrap behind), or + /// - the View is too far ahead, so we will fall back from WAL repair to state sync. + /// 3. Acquire missing or disconnected headers in reverse chronological order, backwards + /// from op_repair_max. + /// A header is disconnected if it breaks the chain with its newer neighbor to the right. + /// 4. Repair missing or corrupt prepares in chronological order. + /// 5. Commit up to op_repair_max. If committing triggers a checkpoint, op_repair_max + /// increases, so go to step 1 and repeat. + fn repair(self: *Replica) void { + if (!self.journal_repair_timeout.ticking) { + log.debug("{}: repair: ignoring (optimistic, not ticking)", .{self.log_prefix()}); + return; + } + + if (self.syncing == .updating_checkpoint) return; + + if (self.grid.callback != .cancel) { + if (self.grid_repair_message_budget.next_destination(&self.prng)) |replica_index| { + self.send_get_blocks(replica_index); + } + } + + if (!self.state_machine_opened) return; + + assert(self.status == .normal or self.status == .view_change); + assert(self.repairs_allowed()); + + assert(self.op_checkpoint() <= self.op); + assert(self.op_checkpoint() <= self.commit_min); + assert(self.commit_min <= self.op); + assert(self.commit_min <= self.commit_max); + assert(self.commit_max >= self.op -| constants.pipeline_prepare_queue_max); + assert(self.journal.header_with_op(self.op) != null); + + self.sync_reclaim_tables(); + + // Request outstanding possibly committed headers to advance our op number: + // This handles the case of an idle cluster, where a backup will not otherwise advance. + // This is not required for correctness, but for durability. + if (self.op < self.op_repair_max() or + (self.status == .normal and self.op < self.view_headers.array.get(0).op)) + { + assert(!self.solo()); + assert(self.replica != self.primary_index(self.view)); + + log.debug( + "{}: repair: break: view={} break={}..{} " ++ + "(commit={}..{} op={} view_headers_op={})", + .{ + self.log_prefix(), + self.view, + self.op + 1, + self.op_repair_max(), + + self.commit_min, + self.commit_max, + self.op, + self.view_headers.array.get(0).op, + }, + ); + self.send_header_to_replica( + self.primary_index(self.view), + @bitCast(Header.GetView{ + .command = .get_view, + .cluster = self.cluster, + .replica = self.replica, + .view = self.view, + .nonce = self.nonce, + }), + ); + } + + if (self.op < self.op_repair_max()) { + const op_header_view = self.journal.header_with_op(self.op).?.view; + assert(op_header_view <= self.view); + if (op_header_view < self.view) { + // Wait for a View from the primary to make sure the op indeed hash-chains + // to the actual view state. + return; + } else { + // The op is from the current view, anything that hash chains to it is worth + // repairing. + } + } + + const repair_op_max: u64 = repair: { + // Every 50 timeouts, unconditionally repair. This + // allows backups to repair journal faults in an idle + // cluster where the the head op does not progress. + if (self.journal_repair_timeout.attempts % 50 == 0) break :repair self.op; + + // View changing replicas must unconditionally repair, + // as transitioning to normal status requires them to + // repair their journal. + if (self.status == .view_change) break :repair self.op; + + // Missing prepares/headers within a pipeline of ops + // from the head may arrive via normal replication, so + // wait for them instead of eagerly repairing. + break :repair self.op -| constants.pipeline_prepare_queue_max; + }; + + const header_break = self.journal.find_latest_headers_break_between( + self.op_repair_min(), + self.op, + ); + + // Request any missing or disconnected headers: + if (header_break) |range| { + assert(!self.solo()); + assert(range.op_min >= self.op_repair_min()); + assert(range.op_max < self.op); + + if (range.op_min <= repair_op_max) { + const op_min = range.op_min; + const op_max = @min(range.op_max, repair_op_max); + assert(op_min <= op_max); + + log.debug( + "{}: repair: break: view={} break={}..{} (commit={}..{} op={})", + .{ + self.log_prefix(), + self.view, + op_min, + op_max, + self.commit_min, + self.commit_max, + self.op, + }, + ); + self.send_header_to_replica( + self.choose_any_other_replica(), + @bitCast(Header.GetHeaders{ + .command = .get_headers, + .cluster = self.cluster, + .replica = self.replica, + // Pessimistically request extra headers. Requesting/sending extra + // headers is inexpensive, and it may save us extra round-trips to + // repair earlier breaks. + .op_min = op_min, + .op_max = op_max, + }), + ); + } + } + + // Iterate through [op_repair_min, self.op], but make sure to first iterate through + // [commit_min+1, self.op] and then [op_repair_min, commit_min]: + // - our first priority is to commit further, + // - afterwards, repair committed prepares which are at risk of getting evicted from + // the journal, to help repair any lagging replicas. + if (self.commit_min + 1 <= repair_op_max) { + self.repair_prepares_between(self.commit_min + 1, repair_op_max); + } + + if (self.op_repair_min() <= self.commit_min) { + self.repair_prepares_between(self.op_repair_min(), self.commit_min); + } + + self.repair_clean_out_of_bound_prepares(); + + if (self.commit_min < self.commit_max) { + // Try to the commit prepares we already have, even if we don't have all of them. + // This helps when a replica is recovering from a crash and has a mostly intact + // journal, with just some prepares missing. We do have the headers and know + // that they form a valid hashchain. Committing may discover more faulty prepares + // and drive further repairs. + assert(!self.solo()); + self.commit_journal(); + } + + if (self.client_replies.faulty.first_set()) |slot| { + // Repair replies. + const entry = &self.client_sessions.entries[slot]; + assert(self.client_sessions.entries_present.is_set(slot)); + assert(entry.session != 0); + assert(entry.header.size > @sizeOf(Header)); + + self.send_header_to_replica( + self.choose_any_other_replica(), + @bitCast(Header.GetReply{ + .command = .get_reply, + .cluster = self.cluster, + .replica = self.replica, + .reply_client = entry.header.client, + .reply_op = entry.header.op, + .reply_checksum = entry.header.checksum, + }), + ); + } + + if (self.status == .view_change and self.primary_index(self.view) == self.replica) { + if (!self.primary_journal_headers_repaired()) return; + + // Sending view messages to backups and committing up to commit_max can be + // performed concurrently. This is good for performance *and* availability, as + // it allows lagging backups to repair while the potential primary commits. + self.primary_send_view(); + + // Check staging as superblock.checkpoint() may currently be updating view/log_view. + if (self.log_view > self.superblock.staging.vsr_state.log_view) { + self.view_durable_update(); + } + if (!self.primary_journal_prepares_repaired()) return; + + if (self.commit_min == self.commit_max) { + if (self.commit_stage != .idle) { + // If we still have a commit running, we started it the last time we were + // primary, and its still running. Wait for it to finish before repairing + // the pipeline so that it doesn't wind up in the new pipeline. + assert(self.commit_prepare.?.header.op >= self.commit_min); + assert(self.commit_prepare.?.header.op <= self.commit_min + 1); + assert(self.commit_prepare.?.header.view < self.view); + return; + } + + // Repair the pipeline, which may discover faulty prepares and drive more + // repairs. + switch (self.primary_repair_pipeline()) { + // primary_repair_pipeline() is already working. + .busy => {}, + .done => self.primary_start_view_as_the_new_primary(), + } + } + } + } + + /// Decide whether or not to insert or update a header: + /// + /// A repair may never advance or replace `self.op` (critical for correctness): + /// + /// Repairs must always backfill in behind `self.op` but may never advance `self.op`. + /// Otherwise, a split-brain primary may reapply an op that was removed through a view + /// change, which could be committed by a higher `commit_max` number in a commit message. + /// + /// See this commit message for an example: + /// https://github.com/coilhq/tigerbeetle/commit/6119c7f759f924d09c088422d5c60ac6334d03de + /// + /// Our guiding principles around repairs in general: + /// + /// * The latest op makes sense of everything else and must not be replaced with a different + /// op or advanced except by the primary in the current view. + /// + /// * Do not jump to a view in normal status without receiving a View message. + /// + /// * Do not commit until the hash chain between `self.commit_min` and `self.op` is fully + /// connected, to ensure that all the ops in this range are correct. + /// + /// * Ensure that `self.commit_max` is never advanced for a newer view without first + /// receiving a View message, otherwise `self.commit_max` may refer to different ops. + /// + /// * Ensure that `self.op` is never advanced by a repair since repairs may occur in a view + /// change where the view has not yet started. + /// + /// * Do not assume that an existing op with a older viewstamp can be replaced by an op with + /// a newer viewstamp, but only compare ops in the same view or with reference to the chain. + /// See Figure 3.7 on page 41 in Diego Ongaro's Raft thesis for an example of where an op + /// with an older view number may be committed instead of an op with a newer view number: + /// http://web.stanford.edu/~ouster/cgi-bin/papers/OngaroPhD.pdf. + /// + /// * Do not replace an op belonging to the current WAL wrap with an op belonging to a + /// previous wrap. + /// + fn repair_header(self: *Replica, header: *const Header.Prepare) bool { + assert(self.status == .normal or self.status == .view_change); + assert(header.valid_checksum()); + assert(header.invalid() == null); + assert(header.command == .prepare); + if (self.syncing == .updating_checkpoint) return false; + + if (header.view > self.view) { + log.debug("{}: repair_header: op={} checksum={x:0>32} view={} (newer view)", .{ + self.log_prefix(), + header.op, + header.checksum, + header.view, + }); + return false; + } + + if (header.op > self.op) { + log.debug("{}: repair_header: op={} checksum={x:0>32} " ++ + "(advances hash chain head)", .{ + self.log_prefix(), + header.op, + header.checksum, + }); + return false; + } else if (header.op == self.op and !self.journal.has_header(header)) { + assert(self.journal.header_with_op(self.op) != null); + log.debug("{}: repair_header: op={} checksum={x:0>32} " ++ + "(changes hash chain head)", .{ + self.log_prefix(), + header.op, + header.checksum, + }); + return false; + } + + if (header.op < self.op_repair_min()) { + // Slots too far back belong to the next wrap of the log. + log.debug( + "{}: repair_header: op={} checksum={x:0>32} (precedes op_repair_min={})", + .{ self.log_prefix(), header.op, header.checksum, self.op_repair_min() }, + ); + return false; + } + + if (self.journal.has_header(header)) { + if (self.journal.has_prepare(header)) { + log.debug("{}: repair_header: op={} checksum={x:0>32} (checksum clean)", .{ + self.log_prefix(), + header.op, + header.checksum, + }); + return false; + } else { + log.debug("{}: repair_header: op={} checksum={x:0>32} (checksum dirty)", .{ + self.log_prefix(), + header.op, + header.checksum, + }); + } + } else if (self.journal.header_for_prepare(header)) |existing| { + if (existing.view == header.view) { + // The journal must have wrapped: + // We expect that the same view and op would have had the same checksum. + assert(existing.op != header.op); + if (existing.op > header.op) { + log.debug("{}: repair_header: op={} checksum={x:0>32} " ++ + "(same view, newer op)", .{ + self.log_prefix(), + header.op, + header.checksum, + }); + } else { + log.debug("{}: repair_header: op={} checksum={x:0>32} " ++ + "(same view, older op)", .{ + self.log_prefix(), + header.op, + header.checksum, + }); + } + } else { + assert(existing.view != header.view); + + log.debug("{}: repair_header: op={} checksum={x:0>32} (different view)", .{ + self.log_prefix(), + header.op, + header.checksum, + }); + } + } else { + log.debug("{}: repair_header: op={} checksum={x:0>32} (gap)", .{ + self.log_prefix(), + header.op, + header.checksum, + }); + } + + assert(header.op < self.op or + self.journal.header_with_op(self.op).?.checksum == header.checksum); + + if (self.journal.header_with_op(self.op).?.view == header.view) { + // Fast path for cases where the header being replaced is from the same view + // as the head. In this case, we can skip checking if our hash chain connects + // up till the head, as the primary for that view would have already done so in + // `on_prepare` (by invoking `panic_if_hash_chain_would_break_in_the_same_view`). + } else if (!self.repair_header_would_connect_hash_chain(header)) { + // We cannot replace this op until we are sure that this would not: + // 1. undermine any prior prepare_ok guarantee made to the primary, and + // 2. leak stale ops back into our in-memory headers (and so into a view change). + log.debug("{}: repair_header: op={} checksum={x:0>32} " ++ + "(disconnected from hash chain)", .{ + self.log_prefix(), + header.op, + header.checksum, + }); + return false; + } + + // If we already committed this op, the repair must be the identical message. + if (self.op_checkpoint() < header.op and header.op <= self.commit_min) { + if (self.journal.header_with_op(header.op)) |_| { + assert(self.journal.has_header(header)); + } + } + + if (header.op <= self.op_prepare_max()) { + assert(header.checkpoint_id == self.checkpoint_id_for_op(header.op).?); + } + assert(header.op + constants.journal_slot_count > self.op); + + self.journal.set_header_as_dirty(header); + return true; + } + + /// If we repair this header, would this connect the hash chain through to the latest op? + /// This offers a strong guarantee that may be used to replace an existing op. + /// + /// Here is an example of what could go wrong if we did not check for complete connection: + /// + /// 1. We do a prepare that's going to be committed. + /// 2. We do a stale prepare to the right, ignoring the hash chain break to the left. + /// 3. We do another stale prepare that replaces the first since it connects to the second. + /// + /// This would violate our quorum replication commitment to the primary. + /// The mistake in this example was not that we ignored the break to the left, which we must + /// do to repair reordered ops, but that we did not check for connection to the right. + fn repair_header_would_connect_hash_chain( + self: *const Replica, + header: *const Header.Prepare, + ) bool { + var entry = header; + + while (entry.op < self.op) { + if (self.journal.next_entry(entry)) |next| { + if (entry.checksum == next.parent) { + assert(entry.view <= next.view); + assert(entry.op + 1 == next.op); + entry = next; + } else { + return false; + } + } else { + return false; + } + } + + assert(entry.op == self.op); + assert(entry.checksum == self.journal.header_with_op(self.op).?.checksum); + return true; + } + + /// Primary must have no missing headers and no faulty prepares between op_repair_min + /// and self.op, to maintain the invariant that a replica can repair everything back + /// till op_repair_min + fn primary_journal_repaired(self: *const Replica) bool { + assert(self.status == .normal or self.status == .view_change); + assert(self.primary_index(self.view) == self.replica); + assert(self.view == self.log_view); + if (self.status == .view_change) assert(self.join_view_quorum); + + return self.primary_journal_headers_repaired() and + self.primary_journal_prepares_repaired(); + } + + fn primary_journal_prepares_repaired(self: *const Replica) bool { + assert(self.status == .normal or self.status == .view_change); + assert(self.primary_index(self.view) == self.replica); + assert(self.view == self.log_view); + if (self.status == .view_change) assert(self.join_view_quorum); + + for (self.op_repair_min()..self.op + 1) |op| { + const header = self.journal.header_with_op(op); + assert(header != null); + if (self.journal.dirty.bits.isSet(self.journal.slot_for_header(header.?).index)) { + return false; + } + } + return true; + } + + fn primary_journal_headers_repaired(self: *const Replica) bool { + assert(self.status == .normal or self.status == .view_change); + assert(self.primary_index(self.view) == self.replica); + assert(self.view == self.log_view); + if (self.status == .view_change) assert(self.join_view_quorum); + + return self.valid_hash_chain_between(self.op_repair_min(), self.op); + } + + /// Reads prepares into the pipeline (before we start the view as the new primary). + fn primary_repair_pipeline(self: *Replica) enum { done, busy } { + assert(self.status == .view_change); + assert(self.primary_index(self.view) == self.replica); + assert(self.commit_stage == .idle); + assert(self.commit_max == self.commit_min); + assert(self.commit_max <= self.op); + assert(self.pipeline == .cache); + assert(self.primary_journal_repaired()); + + if (self.pipeline_repairing) { + log.debug("{}: primary_repair_pipeline: already repairing...", .{ + self.log_prefix(), + }); + return .busy; + } + + if (self.primary_repair_pipeline_op()) |_| { + log.debug("{}: primary_repair_pipeline: repairing", .{self.log_prefix()}); + assert(!self.pipeline_repairing); + self.pipeline_repairing = true; + self.primary_repair_pipeline_read(); + return .busy; + } + + // All prepares needed to reconstruct the pipeline queue are now available in the cache. + return .done; + } + + fn primary_repair_pipeline_done(self: *Replica) PipelineQueue { + assert(self.status == .view_change); + assert(self.primary_index(self.view) == self.replica); + assert(self.commit_max == self.commit_min); + assert(self.commit_max <= self.op); + assert(self.commit_max >= self.op -| constants.pipeline_prepare_queue_max); + assert(self.pipeline == .cache); + assert(!self.pipeline_repairing); + assert(self.primary_repair_pipeline() == .done); + assert(self.primary_journal_repaired()); + + var pipeline_queue = PipelineQueue{ + .pipeline_request_queue_limit = self.pipeline_request_queue_limit, + }; + var op = self.commit_max + 1; + var parent = self.journal.header_with_op(self.commit_max).?.checksum; + while (op <= self.op) : (op += 1) { + const journal_header = self.journal.header_with_op(op).?; + assert(journal_header.op == op); + assert(journal_header.parent == parent); + + const prepare = + self.pipeline.cache.prepare_by_op_and_checksum(op, journal_header.checksum).?; + assert(prepare.header.op == op); + assert(prepare.header.op <= self.op); + assert(prepare.header.checksum == journal_header.checksum); + assert(prepare.header.parent == parent); + assert(self.journal.has_header(prepare.header)); + + pipeline_queue.push_prepare(prepare); + parent = prepare.header.checksum; + } + assert(self.commit_max + pipeline_queue.prepare_queue.count == self.op); + + pipeline_queue.verify(); + return pipeline_queue; + } + + /// Returns the next `op` number that needs to be read into the pipeline. + /// Returns null when all necessary prepares are in the pipeline cache. + fn primary_repair_pipeline_op(self: *const Replica) ?u64 { + assert(self.status == .view_change); + assert(self.primary_index(self.view) == self.replica); + assert(self.commit_stage == .idle); + assert(self.commit_max == self.commit_min); + assert(self.commit_max <= self.op); + assert(self.pipeline == .cache); + + var op = self.commit_max + 1; + while (op <= self.op) : (op += 1) { + const op_header = self.journal.header_with_op(op).?; + if (!self.pipeline.cache.contains_header(op_header)) { + return op; + } + } + return null; + } + + fn primary_repair_pipeline_read(self: *Replica) void { + assert(self.status == .view_change); + assert(self.primary_index(self.view) == self.replica); + assert(self.commit_stage == .idle); + assert(self.commit_max == self.commit_min); + assert(self.commit_max <= self.op); + assert(self.pipeline == .cache); + assert(self.pipeline_repairing); + + const op = self.primary_repair_pipeline_op().?; + const op_checksum = self.journal.header_with_op(op).?.checksum; + log.debug("{}: primary_repair_pipeline_read: op={} checksum={x:0>32}", .{ + self.log_prefix(), + op, + op_checksum, + }); + self.journal.read_prepare( + repair_pipeline_read_callback, + .{ + .op = op, + .checksum = op_checksum, + }, + ); + } + + fn repair_pipeline_read_callback( + self: *Replica, + prepare: ?*Message.Prepare, + options: Journal.Read.Options, + ) void { + assert(options.destination_replica == null); + + assert(self.pipeline_repairing); + self.pipeline_repairing = false; + + if (prepare == null) { + log.debug("{}: repair_pipeline_read_callback: prepare == null", .{ + self.log_prefix(), + }); + return; + } + + // Our state may have advanced significantly while we were reading from disk. + if (self.status != .view_change) { + assert(self.primary_index(self.view) != self.replica); + + log.debug("{}: repair_pipeline_read_callback: no longer in view change status", .{ + self.log_prefix(), + }); + return; + } + + if (self.primary_index(self.view) != self.replica) { + log.debug("{}: repair_pipeline_read_callback: no longer primary", .{ + self.log_prefix(), + }); + return; + } + + if (self.commit_min != self.commit_max or self.commit_stage != .idle) { + log.debug("{}: repair_pipeline_read_callback: no longer repairing", .{ + self.log_prefix(), + }); + return; + } + + if (self.journal.find_latest_headers_break_between(self.commit_max, self.op)) |range| { + log.debug("{}: repair_pipeline_read_callback: header break {}..{}", .{ + self.log_prefix(), + range.op_min, + range.op_max, + }); + return; + } + + // We are in a state where we should be repairing the pipeline (cf. the end of repair). + assert(self.status == .view_change); + assert(self.primary_index(self.view) == self.replica); + assert(self.commit_stage == .idle); + assert(self.commit_min == self.commit_max); + assert(self.valid_hash_chain_between(self.commit_max, self.op)); + + // But we still need to check that we are repairing the right prepare. + const op = self.primary_repair_pipeline_op() orelse { + log.debug("{}: repair_pipeline_read_callback: pipeline changed", .{ + self.log_prefix(), + }); + return; + }; + + assert(op > self.commit_max); + assert(op <= self.op); + + if (prepare.?.header.op != op) { + log.debug("{}: repair_pipeline_read_callback: op changed", .{self.log_prefix()}); + return; + } + + if (prepare.?.header.checksum != self.journal.header_with_op(op).?.checksum) { + log.debug("{}: repair_pipeline_read_callback: checksum changed", .{ + self.log_prefix(), + }); + return; + } + + log.debug("{}: repair_pipeline_read_callback: op={} checksum={x:0>32}", .{ + self.log_prefix(), + prepare.?.header.op, + prepare.?.header.checksum, + }); + + const prepare_evicted = self.pipeline.cache.insert(prepare.?.ref()); + if (prepare_evicted) |message_evicted| self.message_bus.unref(message_evicted); + + if (self.primary_repair_pipeline_op()) |_| { + assert(!self.pipeline_repairing); + self.pipeline_repairing = true; + self.primary_repair_pipeline_read(); + } else { + self.repair(); + } + } + + /// Attempt to repair prepares between [op_min, op_max], skipping over ops for which we + /// don't have a header and disregarding hash chain breaks. + fn repair_prepares_between(self: *Replica, op_min: u64, op_max: u64) void { + assert(self.status == .normal or self.status == .view_change); + assert(self.repairs_allowed()); + assert(op_min <= self.op); + assert(op_min >= self.op_repair_min()); + assert(op_min <= op_max); + assert(op_max <= self.op); + + // Request enough prepares to utilize our max IO depth: + var io_budget = self.journal.writes.available(); + if (io_budget == 0) { + log.debug("{}: repair_prepares: waiting for IOP", .{self.log_prefix()}); + return; + } + + for (op_min..op_max + 1) |op| { + const slot_with_op_maybe = self.journal.slot_with_op(op); + if (slot_with_op_maybe == null or self.journal.dirty.bit(slot_with_op_maybe.?)) { + // Rebroadcast outstanding `get_prepare` every `repair_timeout` tick. + // Continue to request prepares until our budget is depleted. + if (self.repair_prepare(op)) { + io_budget -= 1; + + if (io_budget == 0) { + log.debug("{}: repair_prepares: IO budget used", .{self.log_prefix()}); + break; + } + } else if (self.journal_repair_message_budget.available == 0) { + log.debug("{}: repair_prepares: repair budget used", .{ + self.log_prefix(), + }); + break; + } + } + } + } + + fn repair_clean_out_of_bound_prepares(self: *Replica) void { + assert(self.status == .normal or self.status == .view_change); + assert(self.repairs_allowed()); + maybe(self.journal.dirty.count > 0); + assert(self.op >= self.commit_min); + assert(self.op - self.commit_min <= constants.journal_slot_count); + + // Clean up out-of-bounds dirty slots so repair() can finish. + const slots_repaired = vsr.SlotRange{ + .head = self.journal.slot_for_op(self.op_repair_min()), + .tail = self.journal.slot_with_op(self.op).?, + }; + for (0..constants.journal_slot_count) |slot_index| { + const slot = self.journal.slot_for_op(slot_index); + if (slots_repaired.head.index == slots_repaired.tail.index or + slots_repaired.contains(slot)) + { + // In-bounds — handled by the repair_prepares_between invocations + // before this function is invoked. The slot is either already + // repaired, or we sent a get_prepare and are waiting for a reply. + } else { + // This op must be either: + // - less-than-or-equal-to `op_checkpoint` — we committed before + // checkpointing, but the entry in our WAL was found corrupt after + // recovering from a crash. + // - or (indistinguishably) this might originally have been an op greater + // than replica.op, which was truncated, but is now corrupt. + if (self.journal.dirty.bit(slot)) { + log.debug("{}: repair_prepares: remove slot={} " ++ + "(faulty, precedes checkpoint)", .{ + self.log_prefix(), + slot.index, + }); + self.journal.remove_entry(slot); + } + } + } + } + + /// During a view change, for uncommitted ops, which are few, we optimize for latency: + /// + /// * request a `prepare` from all backups in parallel, + /// * repair as soon as we get a `prepare` + /// + /// For committed ops, which represent the bulk of ops, we optimize for throughput: + /// + /// * have multiple requests in flight to prime the repair queue, + /// * rotate these requests across the cluster round-robin, + /// * to spread the load across connected peers, + /// * to take advantage of each peer's outgoing bandwidth, and + /// * to parallelize disk seeks and disk read bandwidth. + /// + /// This is effectively "many-to-one" repair, where a single replica recovers using the + /// resources of many replicas, for faster recovery. + fn repair_prepare(self: *Replica, op: u64) bool { + const slot_with_op_maybe = self.journal.slot_with_op(op); + const checksum = if (slot_with_op_maybe == null) + 0 + else + self.journal.header_with_op(op).?.checksum; + + assert(self.status == .normal or self.status == .view_change); + assert(self.repairs_allowed()); + assert(slot_with_op_maybe == null or self.journal.dirty.bit(slot_with_op_maybe.?)); + assert(self.journal.writes.available() > 0); + maybe(self.journal_repair_message_budget.available == 0); + + if (self.journal.header_with_op(op)) |header| { + // We may be appending to or repairing the journal concurrently. + // We do not want to re-request any of these prepares unnecessarily. + if (self.journal.writing(header) == .exact) { + log.debug("{}: repair_prepare: op={} checksum={x:0>32} (already writing)", .{ + self.log_prefix(), + op, + checksum, + }); + return false; + } + + // The message may be available in the local pipeline. + // For example (replica_count=3): + // 1. View=1: Replica 1 is primary, and prepares op 5. The local write fails. + // 2. Time passes. The view changes (e.g. due to a timeout)… + // 3. View=4: Replica 1 is primary again, and is repairing op 5 + // (which is still in the pipeline). + // + // Alternatively, we might have not started the write when we initially received the + // prepare because: + // - the journal already had another running write to the same slot, or + // - the journal had no IOPs available. + // + // Using the pipeline to repair is faster than a `get_prepare`. + // Also, messages in the pipeline are never corrupt. + if (self.pipeline_prepare_by_op_and_checksum(op, checksum)) |prepare| { + assert(prepare.header.op == op); + assert(prepare.header.checksum == checksum); + + if (self.solo()) { + // Solo replicas don't change views and rewrite prepares. + assert(self.journal.writing(header) == .none); + + // This op won't start writing until all ops in the pipeline preceding it + // have been written. + log.debug("{}: repair_prepare: op={} checksum={x:0>32} " ++ + "(serializing append)", .{ + self.log_prefix(), + op, + checksum, + }); + const pipeline_head = self.pipeline.queue.prepare_queue.head_ptr().?; + assert(pipeline_head.message.header.op < op); + return false; + } + + log.debug("{}: repair_prepare: op={} checksum={x:0>32} (from pipeline)", .{ + self.log_prefix(), + op, + checksum, + }); + _ = self.write_prepare(prepare); + return true; + } + } + + if (self.journal_repair_message_budget.decrement( + op, + self.clock.monotonic(), + &self.prng, + )) |replica_index| { + assert(replica_index != self.replica); + const get_prepare = Header.GetPrepare{ + .command = .get_prepare, + .cluster = self.cluster, + .replica = self.replica, + .view = if (slot_with_op_maybe == null) self.view else 0, + .prepare_op = op, + .prepare_checksum = checksum, + }; + const nature = if (op > self.commit_max) "uncommitted" else "committed"; + const reason = blk: { + if (self.journal.slot_with_op(op)) |slot| { + if (self.journal.faulty.bit(slot)) { + break :blk "faulty"; + } else { + break :blk "dirty"; + } + } else break :blk "not present"; + }; + + log.debug( + "{}: repair_prepare: op={} checksum={x:0>32} replica={} latency={}ms " ++ + "({s}, {s}, {s})", + .{ + self.log_prefix(), + op, + checksum, + replica_index, + self.journal_repair_message_budget.replicas_repair_latency[ + replica_index + ].to_ms(), + nature, + reason, + @tagName(self.status), + }, + ); + + if (self.status == .view_change) { + // Only the primary is allowed to do repairs in a view change. + assert(self.primary_index(self.view) == self.replica); + self.send_header_to_other_replicas(@bitCast(get_prepare)); + } else { + self.send_header_to_replica( + replica_index, + @bitCast(get_prepare), + ); + } + + return true; + } else { + return false; + } + } + + fn repairs_allowed(self: *const Replica) bool { + switch (self.status) { + .view_change => { + if (self.join_view_quorum) { + assert(self.primary_index(self.view) == self.replica); + return true; + } else { + return false; + } + }, + .normal => return true, + else => return false, + } + } + + // Determines if the repair can not make further progress. Used to decide to abandon WAL + // repair and decide to state sync. This is a semi heuristic: + // - if WAL repair is impossible, this function must eventually returns true. + // - but sometimes it may return true even if WAL repair could, in principle, succeed + // later. + fn repair_stuck(self: *const Replica) bool { + if (self.commit_min == self.commit_max) return false; + + // May as well wait for an in-progress checkpoint to complete — + // we would need to wait for it before sync starts anyhow, and the newer + // checkpoint might sidestep the need for sync anyhow. + if (self.commit_stage == .checkpoint_superblock) return false; + if (self.commit_stage == .checkpoint_data) return false; + + if (self.status == .recovering_head) return false; + + if (self.sync_wal_repair_progress.advanced) return false; + if (self.sync_wal_repair_progress.commit_min < self.commit_min) return false; + + const commit_next = self.commit_min + 1; + const commit_next_slot = self.journal.slot_with_op(commit_next); + + // "stuck" is not actually certain, merely likely. + const stuck_header = !self.valid_hash_chain(@src()); + + const stuck_prepare = + (commit_next_slot == null or self.journal.dirty.bit(commit_next_slot.?)); + + const stuck_grid = !self.grid.read_global_queue.empty(); + + return (stuck_header or stuck_prepare or stuck_grid); + } + + /// Replaces the header if the header is different and at least op_repair_min. + /// The caller must ensure that the header is trustworthy (part of the current view's log). + fn replace_header(self: *Replica, header: *const Header.Prepare) void { + assert(self.status == .normal or self.status == .view_change or + self.status == .recovering_head); + assert(self.op_checkpoint() <= self.commit_min); + + assert(header.valid_checksum()); + assert(header.invalid() == null); + assert(header.command == .prepare); + assert(header.view <= self.view); + assert(header.op <= self.op); // Never advance the op. + assert(header.op <= self.op_prepare_max_sync()); + + if (self.op_checkpoint_sync() < header.op and header.op <= self.commit_min) { + if (self.journal.header_with_op(header.op)) |_| { + assert(self.syncing == .updating_checkpoint or self.journal.has_header(header)); + } + } + + if (header.op == self.op_checkpoint() + 1) { + assert( + header.parent == self.superblock.working.vsr_state.checkpoint.header.checksum, + ); + } + + if (header.op < self.op_repair_min()) return; + + // We must not set an op as dirty if we already have it exactly because: + // 1. this would trigger a repair and delay the view change, or worse, + // 2. prevent repairs to another replica when we have the op. + if (!self.journal.has_header(header)) self.journal.set_header_as_dirty(header); + } + + /// Replicates from the primary to every other replica and standby. + /// Does not flood the network with prepares that have already committed. + /// TODO Use recent heartbeat data for next replica to leapfrog if faulty (optimization). + fn replicate(self: *Replica, message: *Message.Prepare) void { + assert(message.header.command == .prepare); + + // Older prepares should be replicated; if we missed such a prepare in the past, + // other replicas may be missing it too. + maybe(message.header.op < self.op); + maybe(message.header.op < self.commit_max); + maybe(message.header.view < self.view); + + // But each prepare should be replicated at most once, to avoid feedback loops. + assert(!self.journal.has_prepare(message.header)); + assert(message.header.op > self.commit_min); + + if (self.release.value < message.header.release.value and + self.replica == message.header.replica) + { + // Don't replicate messages on a newer release than us if we were the one who + // originally sent it. This can happen if our release backtracked due to being + // reformatted. + log.warn("{}: replicate: ignoring prepare from newer release", .{ + self.log_prefix(), + }); + return; + } + + if (self.status == .normal and self.primary()) { + self.send_message_to_other_replicas_and_standbys(message.base()); + } + } + + fn reset_quorum_messages( + self: *Replica, + messages: *JVQuorumMessages, + command: Command, + ) void { + assert(messages.len == constants.replicas_max); + var view: ?u32 = null; + var count: usize = 0; + for (messages, 0..) |*received, replica| { + if (received.*) |message| { + assert(replica < self.replica_count); + assert(message.header.command == command); + assert(message.header.replica == replica); + // We may have transitioned into a newer view: + // However, all messages in the quorum should have the same view. + assert(message.header.view <= self.view); + if (view) |v| { + assert(message.header.view == v); + } else { + view = message.header.view; + } + + self.message_bus.unref(message); + count += 1; + } + received.* = null; + } + assert(count <= self.replica_count); + log.debug("{}: reset {} {s} message(s) from view={?}", .{ + self.log_prefix(), + count, + @tagName(command), + view, + }); + } + + fn reset_quorum_counter(self: *Replica, counter: *QuorumCounter) void { + var counter_iterator = counter.iterate(); + while (counter_iterator.next()) |replica| { + assert(replica < self.replica_count); + } + + counter.* = quorum_counter_null; + assert(counter.empty()); + + var replica: usize = 0; + while (replica < self.replica_count) : (replica += 1) { + assert(!counter.is_set(replica)); + } + } + + fn reset_quorum_join_view(self: *Replica) void { + self.reset_quorum_messages(&self.join_view_from_all_replicas, .join_view); + self.join_view_quorum = false; + } + + fn reset_quorum_exit_view(self: *Replica) void { + self.reset_quorum_counter(&self.exit_view_from_all_replicas); + } + + fn send_prepare_ok(self: *Replica, header: *const Header.Prepare) void { + assert(header.command == .prepare); + assert(header.cluster == self.cluster); + assert(header.replica == self.primary_index(header.view)); + assert(header.view <= self.view); + assert(header.op <= self.op or header.view < self.view); + maybe(!self.sync_grid_done()); + + if (self.status != .normal) { + log.debug("{}: send_prepare_ok: not sending ({})", .{ + self.log_prefix(), + self.status, + }); + return; + } + + if (header.op > self.op) { + assert(header.view < self.view); + // An op may be reordered concurrently through a view change while being journalled: + log.debug("{}: send_prepare_ok: not sending (reordered)", .{self.log_prefix()}); + return; + } + + if (self.syncing != .idle) { + log.debug("{}: send_prepare_ok: not sending (sync_status={s})", .{ + self.log_prefix(), + @tagName(self.syncing), + }); + return; + } + if (header.op > self.op_prepare_ok_max()) { + if (self.sync_grid_done()) { + log.debug("{}: send_prepare_ok: not sending (falsely contributes to " ++ + "durability of the next checkpoint)", .{self.log_prefix()}); + } else { + log.debug( + "{}: send_prepare_ok: not sending (syncing replica falsely " ++ + "contributes to durability of the current checkpoint)", + .{self.log_prefix()}, + ); + } + return; + } + + assert(self.status == .normal); + // After a view change, replicas send prepare_oks for ops with older views. + // However, we only send to the primary of the current view (see below where we send). + assert(header.view <= self.view); + assert(header.op <= self.op); + + if (self.journal.has_prepare(header)) { + log.debug("{}: send_prepare_ok: op={} checksum={x:0>32}", .{ + self.log_prefix(), + header.op, + header.checksum, + }); + + if (self.standby()) return; + + const checkpoint_id = self.checkpoint_id_for_op(header.op) orelse { + log.debug("{}: send_prepare_ok: not sending (old)", .{self.log_prefix()}); + return; + }; + assert(checkpoint_id == header.checkpoint_id); + + // It is crucial that replicas stop accepting prepare messages from earlier views + // once they start the view change protocol. Without this constraint, the system + // could get into a state in which there are two active primaries: the old one, + // which hasn't failed but is merely slow or not well connected to the network, and + // the new one. If a replica sent a prepare_ok message to the old primary after + // sending its log to the new one, the old primary might commit an operation that + // the new primary doesn't learn about in the join_view messages. + + // We therefore only ever send to the primary of the current view, never to the + // primary of the prepare header's view: + self.send_header_to_replica( + self.primary_index(self.view), + @bitCast(Header.PrepareOk{ + .command = .prepare_ok, + .checkpoint_id = checkpoint_id, + .parent = header.parent, + .client = header.client, + .prepare_checksum = header.checksum, + .request = header.request, + .cluster = self.cluster, + .replica = self.replica, + .epoch = header.epoch, + .view = self.view, + .op = header.op, + .commit_min = self.commit_min, + .timestamp = header.timestamp, + .operation = header.operation, + }), + ); + } else { + log.debug("{}: send_prepare_ok: not sending (dirty)", .{self.log_prefix()}); + return; + } + } + + fn send_prepare_oks_after_view_change(self: *Replica) void { + assert(self.status == .normal); + self.send_prepare_oks_from(self.commit_max + 1); + } + + fn send_prepare_oks_after_syncing_tables(self: *Replica) void { + assert(self.status == .normal or self.status == .view_change or + self.status == .recovering_head); + assert(self.syncing == .idle); + assert(self.sync_tables == null); + + const op_checkpoint_trigger = + vsr.Checkpoint.trigger_for_checkpoint(self.op_checkpoint()).?; + self.send_prepare_oks_from(@max( + self.commit_max + 1, + op_checkpoint_trigger + constants.pipeline_prepare_queue_max + 1, + )); + } + + fn send_prepare_oks_after_checkpoint(self: *Replica) void { + assert(self.status == .normal or self.status == .view_change or + (self.status == .recovering and self.solo())); + + const op_checkpoint_trigger = + vsr.Checkpoint.trigger_for_checkpoint(self.op_checkpoint()).?; + assert(self.commit_min == op_checkpoint_trigger); + self.send_prepare_oks_from(@max( + self.commit_max + 1, + op_checkpoint_trigger + constants.pipeline_prepare_queue_max + 1, + )); + } + + fn send_prepare_oks_from(self: *Replica, op_: u64) void { + var op = op_; + while (op <= self.op) : (op += 1) { + // We may have breaks or stale headers in our uncommitted chain here. However: + // * being able to send what we have will allow the pipeline to commit earlier, and + // * the primary will drop any prepare_ok for a prepare not in the pipeline. + // This is safe only because the primary can verify against the prepare checksum. + if (self.journal.header_with_op(op)) |header| { + self.send_prepare_ok(header); + if (self.loopback_queue != null) assert(self.journal.has_prepare(header)); + self.flush_loopback_queue(); + } + } + } + + fn send_exit_view(self: *Replica) void { + assert(self.status == .normal or self.status == .view_change); + assert(!self.solo()); + + if (self.standby()) return; + + const header = Header.ExitView{ + .command = .exit_view, + .cluster = self.cluster, + .replica = self.replica, + .view = self.view, + }; + + self.send_header_to_other_replicas(header.frame_const().*); + + if (!self.exit_view_from_all_replicas.is_set(self.replica)) { + self.send_header_to_replica(self.replica, header.frame_const().*); + return self.flush_loopback_queue(); + } + } + + fn send_join_view(self: *Replica) void { + assert(self.status == .view_change); + assert(!self.solo()); + assert(self.view > self.log_view); + assert(self.view >= self.view_durable()); + assert(self.log_view >= self.log_view_durable()); + assert(!self.join_view_quorum); + // The JV headers are already up to date, either via: + // - transition_to_view_change_status(), or + // - superblock's view_headers (after recovery). + assert(self.view_headers.command == .join_view); + assert(self.view_headers.array.get(0).op >= self.op); + self.view_headers.verify(); + + const BitSet = stdx.BitSetType(128); + comptime assert(BitSet.Word == + @FieldType(Header.JoinView, "present_bitset")); + comptime assert(BitSet.Word == + @FieldType(Header.JoinView, "nack_bitset")); + + // Collect nack and presence bits for the headers, so that the new primary can run CTRL + // protocol to truncate uncommitted headers. When: + // - a header has quorum of nacks -- the header is truncated + // - a header isn't truncated and is present -- the header gets into the next view + // - a header is neither truncated nor present -- the primary waits for more + // JV messages to decide whether to keep or truncate the header. + var nacks: BitSet = .{}; + var present: BitSet = .{}; + for (self.view_headers.array.const_slice(), 0..) |*header, i| { + const slot = self.journal.slot_for_op(header.op); + const journal_header = self.journal.header_with_op(header.op); + const dirty = self.journal.dirty.bit(slot); + const faulty = self.journal.faulty.bit(slot); + + // Nack bit case 1: We don't have a prepare at all, and that's not due to a fault. + if (journal_header == null and !faulty) { + nacks.set(i); + } + + // We should only access header.checksum if the JV header is valid. + if (vsr.Headers.jv_header_type(header) == .valid) { + // Nack bit case 2: We have this header in memory, but haven't persisted it to + // disk yet. + if (journal_header != null and journal_header.?.checksum == header.checksum and + dirty and !faulty) + { + nacks.set(i); + } + // Nack bit case 3: We have a _different_ prepare — safe to nack even if it is + // faulty. + if (journal_header != null and journal_header.?.checksum != header.checksum) { + nacks.set(i); + } + + // Presence bit: the prepare is on disk, is being written to disk, or is cached + // in memory. These conditions mirror logic in `on_get_prepare` and imply + // that we can help the new primary to repair this prepare. + if ((self.journal.prepare_inhabited[slot.index] and + self.journal.prepare_checksums[slot.index] == header.checksum) or + self.journal.writing(header) == .exact or + self.pipeline_prepare_by_op_and_checksum( + header.op, + header.checksum, + ) != null) + { + if (journal_header != null) { + assert(journal_header.?.checksum == header.checksum); + } + maybe(nacks.is_set(i)); + present.set(i); + } + } else { + assert(vsr.Headers.jv_header_type(header) == .blank); + assert(!present.is_set(i)); + if (nacks.is_set(i)) assert(!faulty); + } + } + + const message = self.message_bus.get_message(.join_view); + defer self.message_bus.unref(message); + + message.header.* = .{ + .size = @sizeOf(Header) * (1 + self.view_headers.array.count_as(u32)), + .command = .join_view, + .cluster = self.cluster, + .replica = self.replica, + .view = self.view, + // The latest normal view (as specified in the 2012 paper) is different to the view + // number contained in the prepare headers we include in the body. The former shows + // how recent a view change the replica participated in, which may be much higher. + // We use the `request` field to send this in addition to the current view number: + .log_view = self.log_view, + .checkpoint_op = self.op_checkpoint(), + // This is usually the head op, but it may be farther ahead if we are lagging behind + // a checkpoint. (In which case the op is inherited from the View). + .op = self.view_headers.array.get(0).op, + // For command=view, commit_min=commit_max. + // For command=join_view, the new primary uses this op to trust extra headers + // from non-canonical JVs. + .commit_min = self.commit_min, + // Signal which headers correspond to definitely not-prepared messages. + .nack_bitset = nacks.bits, + // Signal which headers correspond to locally available prepares. + .present_bitset = present.bits, + }; + + stdx.copy_disjoint( + .exact, + Header.Prepare, + std.mem.bytesAsSlice(Header.Prepare, message.body_used()), + self.view_headers.array.const_slice(), + ); + message.header.set_checksum_body(message.body_used()); + message.header.set_checksum(); + + assert(message.header.op >= self.op); + // Each replica must advertise its own commit number, so that the new primary can know + // which headers must be replaced in its log. Otherwise, a gap in the log may prevent + // the new primary from repairing its log, resulting in the log being forked if the new + // primary also discards uncommitted operations. + // It is also safe not to use `commit_max` here because the new primary will assume that + // operations after the highest `commit_min` may yet have been committed before the old + // primary crashed. The new primary will use the NACK protocol to be sure of a discard. + assert(message.header.commit_min == self.commit_min); + JVQuorum.verify_message(message); + + if (self.standby()) return; + + self.send_message_to_other_replicas(message); + + if (self.replica == self.primary_index(self.view) and + self.join_view_from_all_replicas[self.replica] == null) + { + self.send_message_to_replica(self.replica, message); + return self.flush_loopback_queue(); + } + } + + fn send_eviction_message_to_client( + self: *Replica, + client: u128, + reason: vsr.Header.Eviction.Reason, + ) void { + assert(self.status == .normal); + assert(self.primary()); + + log.warn("{}: sending eviction message to client={} reason={s}", .{ + self.log_prefix(), + client, + @tagName(reason), + }); + + self.send_header_to_client(client, @bitCast(Header.Eviction{ + .command = .eviction, + .cluster = self.cluster, + .release = self.release, + .replica = self.replica, + .view = self.log_view_durable(), + .client = client, + .reason = reason, + })); + } + + fn send_reply_message_to_client(self: *Replica, reply: *Message.Reply) void { + assert(reply.header.command == .reply); + assert(reply.header.view <= self.view); + maybe(reply.header.view > self.log_view_durable()); + + assert(reply.header.client != 0); + + // If the request committed in a different view than the one it was originally prepared + // in, we must inform the client about this newer view before we send it a reply. + // Otherwise, the client might send a next request to the old primary, which would + // observe a broken hash chain. + // + // To do this, if our durable log view is fresher than the reply's view, we externalize + // that to the client, and use the `context` field for hash chaining. + + if (reply.header.view >= self.log_view_durable()) { + // Hot path: We don't update header view if it is fresher than the view we can + // safely externalize to the client. + self.send_message_to_client_base(reply.header.client, reply.base()); + return; + } + // TODO(client_release): drop cold path after #2821 is in (0.16.34 or later). + + const reply_copy = self.message_bus.get_message(.reply); + defer self.message_bus.unref(reply_copy); + + // Copy the message and update the view. + // We could optimize this by using in-place modification if `reply.references == 1`. + // We don't bother, as that complicates reasoning on the call-site, and this is + // a cold path anyway. + stdx.copy_disjoint( + .inexact, + u8, + reply_copy.buffer, + reply.buffer[0..reply.header.size], + ); + reply_copy.header.view = self.log_view_durable(); + reply_copy.header.set_checksum(); + + self.send_message_to_client_base(reply.header.client, reply_copy.base()); + } + + fn send_header_to_client(self: *Replica, client: u128, header: Header) void { + assert(header.cluster == self.cluster); + assert(header.view <= self.log_view_durable()); + assert(header.command == .pong_client or header.command == .eviction); + + const message = self.create_message_from_header(header); + defer self.message_bus.unref(message); + + self.send_message_to_client_base(client, message); + } + + fn send_message_to_client_base(self: *Replica, client: u128, message: *Message) void { + assert(message.header.command == .pong_client or + message.header.command == .eviction or + message.header.command == .reply); + + // Switch on the header type so that we don't log opaque bytes for the per-command data. + switch (message.header.into_any()) { + inline else => |header| { + log.debug("{}: sending {s} to client {}: {}", .{ + self.log_prefix(), + @tagName(message.header.command), + client, + header, + }); + }, + } + + // We set the view for outgoing messages such that we don't externalize one for which + // view change hasn't yet completed (see call sites). This avoids the following + // scenarios which could occur if a partitioned replica leaks a higher view to the + // client, and the client uses this view for subsequent requests: + // * Subsequent new requests are ignored by the cluster, locking out the client. + // * Subequent duplicate requests cause the primary to crash, since we expect + // the request's view to be smaller than the primary's view (see + // `ignore_request_message_duplicate` and `ignore_request_message_preparing`), + switch (message.header.into_any()) { + .eviction => |header| { + assert(self.primary()); + assert(header.release.value <= self.release.value); + assert(header.view <= self.log_view_durable()); + }, + .reply => |header| { + assert(!self.standby()); + assert(header.op <= self.op_checkpoint_next_trigger()); + assert(header.release.value <= self.release.value); + // For the case where a backup is replying to a client directly, the prepare's + // view could exceed the backup's durable log view. This is still safe, since + // the prepare's view is <= the primary's durable log view. + maybe(header.view > self.log_view_durable()); + }, + .pong_client => |header| { + assert(!self.standby()); + assert(header.release.value == self.release.value); + assert(header.view <= self.log_view_durable()); + }, + + .reserved, + + // Deprecated messages are always `invalid()`. + .deprecated_12, + .deprecated_21, + .deprecated_22, + .deprecated_23, + + .request, + .prepare, + .prepare_ok, + .exit_view, + .join_view, + .view, + .headers, + .ping, + .pong, + .ping_client, + .commit, + .get_view, + .get_headers, + .get_prepare, + .get_reply, + .get_blocks, + .block, + => unreachable, + } + + self.trace.count(.{ .replica_messages_out = .{ + .command = message.header.command, + } }, 1); + + self.message_bus.send_message_to_client(client, message); + + if (self.event_callback) |hook| hook(self, .{ .message_sent = message }); + } + + fn send_header_to_other_replicas(self: *Replica, header: Header) void { + const message = self.create_message_from_header(header); + defer self.message_bus.unref(message); + + self.send_message_to_other_replicas_base(message); + } + + fn send_header_to_other_replicas_and_standbys(self: *Replica, header: Header) void { + const message = self.create_message_from_header(header); + defer self.message_bus.unref(message); + + self.send_message_to_other_replicas_and_standbys(message); + } + + fn send_header_to_replica(self: *Replica, replica: u8, header: Header) void { + const message = self.create_message_from_header(header); + defer self.message_bus.unref(message); + + self.send_message_to_replica_base(replica, message); + } + + /// `message` is a `*MessageType(command)`. + fn send_message_to_other_replicas(self: *Replica, message: anytype) void { + assert(@typeInfo(@TypeOf(message)) == .pointer); + assert(!@typeInfo(@TypeOf(message)).pointer.is_const); + + self.send_message_to_other_replicas_base(message.base()); + } + + fn send_message_to_other_replicas_and_standbys(self: *Replica, message: *Message) void { + for (0..self.node_count) |replica_usize| { + const replica: u8 = @intCast(replica_usize); + if (replica != self.replica) { + self.send_message_to_replica_base(replica, message); + } + } + } + + fn send_message_to_other_replicas_base(self: *Replica, message: *Message) void { + for (0..self.replica_count) |replica_usize| { + const replica: u8 = @intCast(replica_usize); + if (replica != self.replica) { + self.send_message_to_replica_base(replica, message); + } + } + } + + /// `message` is a `*MessageType(command)`. + fn send_message_to_replica(self: *Replica, replica: u8, message: anytype) void { + assert(@typeInfo(@TypeOf(message)) == .pointer); + assert(!@typeInfo(@TypeOf(message)).pointer.is_const); + + self.send_message_to_replica_base(replica, message.base()); + } + + fn send_message_to_replica_base(self: *Replica, replica: u8, message: *Message) void { + // Switch on the header type so that we don't log opaque bytes for the per-command data. + switch (message.header.into_any()) { + inline else => |header| { + log.debug("{}: sending {s} to replica {}: {}", .{ + self.log_prefix(), + @tagName(message.header.command), + replica, + header, + }); + }, + } + + if (message.header.invalid()) |reason| { + log.warn("{}: send_message_to_replica: invalid ({s})", .{ + self.log_prefix(), + reason, + }); + @panic("send_message_to_replica: invalid message"); + } + + assert(message.header.cluster == self.cluster); + + if (message.header.command == .block) { + assert(message.header.protocol <= vsr.Version); + } else { + assert(message.header.protocol == vsr.Version); + } + + // TODO According to message.header.command, assert on the destination replica. + switch (message.header.into_any()) { + .eviction, + .reserved, + // Deprecated messages are always `invalid()`. + .deprecated_12, + .deprecated_21, + .deprecated_22, + .deprecated_23, + => unreachable, + + .request => { + assert(!self.standby()); + // Do not assert message.header.replica because we forward .request messages. + assert(self.status == .normal); + // Backups forwarding .request may have a smaller release than the client. + maybe(message.header.release.value > self.release.value); + }, + .prepare => |header| { + maybe(self.standby()); + assert(self.replica != replica); + // Do not assert message.header.replica because we forward .prepare messages. + // Backups replicating .prepare may have a smaller release than the primary. + if (header.replica == self.replica) { + assert(header.release.value <= self.release.value); + assert(message.header.view <= self.view); + } + assert(header.operation != .reserved); + }, + .prepare_ok => |header| { + assert(!self.standby()); + assert(self.status == .normal); + assert(self.syncing == .idle); + assert(header.view == self.view); + assert(header.op <= self.op_prepare_ok_max()); + // We must only ever send a prepare_ok to the latest primary of the active view: + // We must never straddle views by sending to a primary in an older view. + // Otherwise, we would be enabling a partitioned primary to commit. + assert(replica == self.primary_index(self.view)); + assert(header.replica == self.replica); + assert(header.release.value == vsr.Release.zero.value); + }, + .reply => |header| { + assert(!self.standby()); + assert(header.view <= self.view); + assert(header.op <= self.op_checkpoint_next_trigger()); + assert(header.release.value <= self.release.value); + }, + .exit_view => |header| { + assert(!self.standby()); + assert(self.status == .normal or self.status == .view_change); + assert(header.view == self.view); + assert(header.replica == self.replica); + assert(header.release.value == vsr.Release.zero.value); + }, + .join_view => |header| { + assert(!self.standby()); + assert(self.status == .view_change); + assert(self.view > self.log_view); + assert(!self.join_view_quorum); + assert(header.view == self.view); + assert(header.replica == self.replica); + maybe(header.op == self.op); + assert(header.op >= self.op); + assert(header.commit_min == self.commit_min); + assert(header.checkpoint_op == self.op_checkpoint()); + assert(header.log_view == self.log_view); + assert(header.release.value == vsr.Release.zero.value); + }, + .view => |header| { + assert(!self.standby()); + assert(self.status == .normal or self.status == .view_change); + assert(self.replica == self.primary_index(self.view)); + assert(self.syncing == .idle); + assert(header.view == self.view); + assert(header.replica == self.replica); + assert(header.replica != replica); + assert(header.commit_max == self.commit_max); + assert(header.checkpoint_op == self.op_checkpoint()); + assert(header.release.value == vsr.Release.zero.value); + }, + .headers => |header| { + assert(!self.standby()); + assert(header.view == self.view); + assert(header.replica == self.replica); + assert(header.replica != replica); + assert(header.release.value == vsr.Release.zero.value); + }, + .ping => |header| { + maybe(self.standby()); + assert(header.replica == self.replica); + assert(header.replica != replica); + assert(header.release.value == self.release.value); + }, + .pong => |header| { + maybe(self.standby()); + assert(self.status == .normal or self.status == .view_change); + assert(header.replica == self.replica); + assert(header.replica != replica); + assert(header.release.value == self.release.value); + }, + .ping_client => unreachable, + .pong_client => unreachable, + .commit => |header| { + assert(!self.standby()); + assert(self.status == .normal); + assert(self.primary()); + assert(self.syncing == .idle); + assert(header.view == self.view); + assert(header.replica == self.replica); + assert(header.replica != replica); + assert(header.release.value == vsr.Release.zero.value); + }, + .get_view => |header| { + maybe(self.standby()); + assert(header.view >= self.view); + assert(header.replica == self.replica); + assert(header.replica != replica); + assert(self.primary_index(message.header.view) == replica); + assert(header.release.value == vsr.Release.zero.value); + }, + .get_headers => |header| { + maybe(self.standby()); + assert(header.replica == self.replica); + assert(header.replica != replica); + assert(header.release.value == vsr.Release.zero.value); + }, + .get_prepare => |header| { + maybe(self.standby()); + assert(header.replica == self.replica); + assert(header.replica != replica); + assert(header.release.value == vsr.Release.zero.value); + }, + .get_reply => |header| { + assert(header.replica == self.replica); + assert(header.replica != replica); + assert(header.release.value == vsr.Release.zero.value); + }, + .get_blocks => |header| { + maybe(self.standby()); + assert(header.replica == self.replica); + assert(header.replica != replica); + assert(header.release.value == vsr.Release.zero.value); + }, + .block => |header| { + assert(!self.standby()); + assert(header.release.value <= self.release.value); + }, + } + // Critical: + // Do not advertise a view/log_view before it is durable. We only need perform these + // checks if we authored the message, not if we're simply forwarding a message along. + // See view_durable()/log_view_durable(). + if (replica != self.replica and message.header.replica == self.replica) { + if (message.header.view > self.view_durable() and + message.header.command != .get_view) + { + // Pings are used for syncing time, so they must not be + // blocked on persisting view. + assert(message.header.command != .ping); + assert(message.header.command != .pong); + + log.debug("{}: send_message_to_replica: dropped {s} " ++ + "(view_durable={} message.view={})", .{ + self.log_prefix(), + @tagName(message.header.command), + self.view_durable(), + message.header.view, + }); + return; + } + + // For JVs, EVs, and prepare_oks we must wait for the log_view to be durable: + // - A JV includes the log_view. + // - A View or a prepare_ok imply the log_view. + if (message.header.command == .join_view or + message.header.command == .view or + message.header.command == .prepare_ok) + { + if (self.log_view_durable() < self.log_view) { + log.debug("{}: send_message_to_replica: dropped {s} " ++ + "(log_view_durable={} log_view={})", .{ + self.log_prefix(), + @tagName(message.header.command), + self.log_view_durable(), + self.log_view, + }); + return; + } + assert(message.header.command != .join_view or std.mem.eql( + u8, + message.body_used(), + std.mem.sliceAsBytes(self.superblock.working.view_headers().slice), + )); + } + } + + if (replica == self.replica) { + assert(self.loopback_queue == null); + self.loopback_queue = message.ref(); + } else { + self.trace.count(.{ .replica_messages_out = .{ + .command = message.header.command, + } }, 1); + + if (self.event_callback) |hook| hook(self, .{ .message_sent = message }); + + self.message_bus.send_message_to_replica(replica, message); + } + } + + /// The highest durable view. + /// A replica must not advertise a view higher than its durable view. + /// + /// The advertised `view` must never backtrack after a crash. + /// This ensures the old primary is isolated — if a backup's view backtracks, it could + /// ack a prepare to the old primary, forking the log. See VRR §8.2 for more detail. + /// + /// Equivalent to `superblock.working.vsr_state.view`. + fn view_durable(self: *const Replica) u32 { + return self.superblock.working.vsr_state.view; + } + + /// The highest durable log_view. + /// A replica must not advertise a log_view (in a JV) higher than its durable log_view. + /// + /// A replica's advertised `log_view` must never backtrack after a crash. + /// (`log_view` is only advertised within JV messages). + /// + /// To understand why, consider the following replica logs, where: + /// + /// - numbers in replica rows denote the version of the op, and + /// - a= self.log_view); + assert(self.view >= self.view_durable()); + assert(self.log_view >= self.log_view_durable()); + assert( + self.log_view > self.log_view_durable() or + self.view > self.view_durable() or + self.syncing == .updating_checkpoint, + ); + assert(self.view_headers.array.count() > 0); + assert(self.view_headers.array.get(0).view <= self.log_view); + assert(self.commit_max >= self.op -| constants.pipeline_prepare_queue_max); + + if (self.view_durable_updating()) return; + + log.debug("{}: view_durable_update: view_durable={}..{} log_view_durable={}..{}", .{ + self.log_prefix(), + self.view_durable(), + self.view, + self.log_view_durable(), + self.log_view, + }); + + self.superblock.view_change( + view_durable_update_callback, + &self.superblock_context_view_change, + .{ + .commit_max = self.commit_max, + .view = self.view, + .log_view = self.log_view, + .headers = &self.view_headers, + .sync_checkpoint = switch (self.syncing) { + .updating_checkpoint => |*checkpoint_state| .{ + .checkpoint = checkpoint_state, + .sync_op_min = sync_op_min: { + const syncing_already = + self.superblock.staging.vsr_state.sync_op_max > 0; + const sync_min_old = self.superblock.staging.vsr_state.sync_op_min; + + const sync_min_new = if (vsr.Checkpoint.trigger_for_checkpoint( + self.op_checkpoint(), + )) |trigger| + // +1 because sync_op_min is inclusive, but (when + // !syncing_already) `vsr_state.checkpoint.commit_min` itself + // does not need to be synced. + trigger + 1 + else + 0; + + break :sync_op_min if (syncing_already) + @min(sync_min_old, sync_min_new) + else + sync_min_new; + }, + .sync_op_max = vsr.Checkpoint.trigger_for_checkpoint( + checkpoint_state.header.op, + ).?, + }, + else => null, + }, + }, + ); + assert(self.view_durable_updating()); + } + + fn primary_send_view(self: *Replica) void { + assert(self.status == .normal or self.status == .view_change); + assert(self.replica == self.primary_index(self.view)); + assert(self.primary_journal_headers_repaired()); + + // Only replies to `get_view` need a nonce, + // to guarantee freshness of the message. + const nonce = 0; + + const view_message = self.create_view_message(nonce); + defer self.message_bus.unref(view_message); + + assert(view_message.header.command == .view); + assert(view_message.header.nonce == 0); + + self.send_message_to_other_replicas(view_message); + } + + fn view_durable_update_callback(context: *SuperBlock.Context) void { + const self: *Replica = + @alignCast(@fieldParentPtr("superblock_context_view_change", context)); + assert(self.status == .normal or self.status == .view_change or + (self.status == .recovering and self.solo())); + assert(!self.view_durable_updating()); + assert(self.superblock.working.vsr_state.view <= self.view); + assert(self.superblock.working.vsr_state.log_view <= self.log_view); + assert(self.superblock.working.vsr_state.checkpoint.header.op <= self.commit_min); + assert(self.superblock.working.vsr_state.commit_max <= self.commit_max); + + log.debug("{}: view_durable_update_callback: " ++ + "(view_durable={} log_view_durable={})", .{ + self.log_prefix(), + self.view_durable(), + self.log_view_durable(), + }); + + assert(self.view_durable() <= self.view); + assert(self.log_view_durable() <= self.view_durable()); + assert(self.log_view_durable() <= self.log_view); + + switch (self.syncing) { + .updating_checkpoint => |checkpoint| { + if (checkpoint.header.op == self.op_checkpoint()) { + self.sync_superblock_update_finish(); + assert(self.syncing == .idle); + if (self.release.value < + self.superblock.working.vsr_state.checkpoint.release.value) + { + // sync_superblock_update_finish triggered `release_transition`, + // short-circuit for VOPR. + assert(Forest.Storage == TestStorage); + return; + } + } + }, + else => {}, + } + + // The view/log_view incremented while the previous view-change update was being saved. + // Check staging as superblock.checkpoint() may currently be updating view/log_view. + const update = self.superblock.staging.vsr_state.log_view < self.log_view or + self.superblock.staging.vsr_state.view < self.view; + const update_jv = update and self.log_view < self.view; + const update_view = update and self.log_view == self.view and + (self.replica != self.primary_index(self.view) or self.status == .normal); + assert(!(update_jv and update_view)); + + const update_checkpoint = self.syncing == .updating_checkpoint and + self.syncing.updating_checkpoint.header.op > self.op_checkpoint(); + + if (update_jv or update_view or update_checkpoint) self.view_durable_update(); + + // Reset EV timeout in case the view-durable update took a long time. + if (self.view_change_status_timeout.ticking) self.view_change_status_timeout.reset(); + + // Trigger work that was deferred until after the view-change update. + switch (self.status) { + .normal => { + assert(self.log_view == self.view); + if (self.primary_index(self.view) == self.replica) { + self.primary_send_view(); + } else { + self.send_prepare_oks_after_view_change(); + } + }, + .view_change => { + if (self.log_view < self.view) { + if (!self.join_view_quorum) self.send_join_view(); + } else { + assert(self.log_view == self.view); + // Potential primaries that have updated View headers can send View + // messages (see `repair`) + if (self.primary_index(self.view) == self.replica and + self.view_headers.command == .view) + { + self.primary_send_view(); + } + } + }, + .recovering => {}, + .recovering_head => unreachable, + } + } + + fn set_op_and_commit_max( + self: *Replica, + op: u64, + commit_max: u64, + source: SourceLocation, + ) void { + assert(self.status == .view_change or self.status == .normal or + self.status == .recovering_head); + + assert(op <= self.op_prepare_max_sync()); + maybe(op >= self.commit_max); + maybe(op >= commit_max); + + // Uncommitted ops may not survive a view change, but never truncate committed ops. + // However, it is safe to truncate committed ops past prepare_max, since we are + // guaranteed to not have sent a prepare_ok for them (see `op_prepare_ok_max`). + if (op < @min(self.op, self.op_prepare_max_sync())) { + assert(op >= @max(commit_max, self.commit_max)); + assert(self.op <= op + constants.pipeline_prepare_queue_max); + } + + // We expect that our commit numbers may also be greater even than `commit_max` because + // we may be the old primary joining towards the end of the view change and we may have + // committed `op` already. + // However, this is bounded by pipelining. + // The intersection property only requires that all possibly committed operations must + // survive into the new view so that they can then be committed by the new primary. + // This guarantees that if the old primary possibly committed the operation, then the + // new primary will also commit the operation. + if (commit_max < self.commit_max and self.commit_min == self.commit_max) { + log.debug("{}: {s}: k={} < commit_max={} and commit_min == commit_max", .{ + self.log_prefix(), + source.fn_name, + commit_max, + self.commit_max, + }); + } + + assert(self.commit_min <= self.commit_max); + maybe(self.op < self.commit_max); + + const previous_op = self.op; + const previous_commit_max = self.commit_max; + + self.op = op; + self.journal.remove_entries_from(self.op + 1); + + // Crucially, we must never rewind `commit_max` (and then `commit_min`) because + // `commit_min` represents what we have already applied to our state machine: + self.commit_max = @max(self.commit_max, commit_max); + assert(self.commit_max >= self.commit_min); + assert(self.commit_max >= self.op -| constants.pipeline_prepare_queue_max); + + log.debug("{}: {s}: view={} op={}..{} commit_max={}..{}", .{ + self.log_prefix(), + source.fn_name, + self.view, + previous_op, + self.op, + previous_commit_max, + self.commit_max, + }); + } + + /// Load the new view's headers from the JV quorum. + /// + /// The iteration order of JVs for repair does not impact the final result. + /// In other words, you can't end up in a situation with a JV quorum like: + /// + /// replica headers commit_min + /// 0 4 5 _ _ 8 4 (new primary; handling JV quorum) + /// 1 4 _ 6 _ 8 4 + /// 2 4 _ _ 7 8 4 + /// 3 (4 5 6 7 8) 8 (didn't participate in view change) + /// 4 (4 5 6 7 8) 8 (didn't participate in view change) + /// + /// where the new primary's headers depends on which of replica 1 and 2's JV is used + /// for repair before the other (i.e. whether they repair op 6 or 7 first). + /// + /// For the above case to occur, replicas 0, 1, and 2 must all share the highest `log_view`. + /// And since they share the latest `log_view`, ops 5,6,7 were just installed by + /// `replace_header`, which is order-independent (it doesn't use the hash chain). + /// + /// (If replica 0's log_view was greater than 1/2's, then replica 0 must have all + /// headers from previous views. Which means 6,7 are from the current view. But since + /// replica 0 doesn't have 6/7, then replica 1/2 must share the latest log_view. ∎) + fn primary_set_log_from_join_view_messages(self: *Replica) void { + assert(self.status == .view_change); + assert(self.view > self.log_view); + assert(self.primary_index(self.view) == self.replica); + assert(!self.solo()); + assert(self.syncing == .idle); + assert(self.commit_max <= self.op_prepare_max()); + assert(self.join_view_quorum); + assert(self.join_view_from_all_replicas[self.replica] != null); + JVQuorum.verify(self.join_view_from_all_replicas); + + const jvs_all = JVQuorum.jvs_all(self.join_view_from_all_replicas); + assert(jvs_all.count() >= self.quorum_view_change); + + for (jvs_all.const_slice()) |message| { + assert(message.header.op <= self.op_prepare_max()); + } + + // The `prepare_timestamp` prevents a primary's own clock from running backwards. + // Therefore, `prepare_timestamp`: + // 1. is advanced if behind the cluster, but never reset if ahead of the cluster, i.e. + // 2. may not always reflect the timestamp of the latest prepared op, and + // 3. should be advanced before discarding the timestamps of any uncommitted headers. + const timestamp_max = JVQuorum.timestamp_max(self.join_view_from_all_replicas); + if (self.state_machine.prepare_timestamp < timestamp_max) { + self.state_machine.prepare_timestamp = timestamp_max; + } + + var quorum_headers = JVQuorum.quorum_headers( + self.join_view_from_all_replicas, + .{ + .quorum_nack_prepare = self.quorum_nack_prepare, + .quorum_view_change = self.quorum_view_change, + .replica_count = self.replica_count, + }, + ).complete_valid; + + const header_head = quorum_headers.next().?; + assert(header_head.op >= self.op_checkpoint()); + assert(header_head.op >= self.commit_min); + assert(header_head.op >= self.commit_max); + assert(header_head.op <= self.op_prepare_max()); + for (jvs_all.const_slice()) |jv| assert(header_head.op >= jv.header.commit_min); + + assert(self.commit_min >= + self.join_view_from_all_replicas[self.replica].?.header.commit_min); + + const commit_max = JVQuorum.commit_max(self.join_view_from_all_replicas); + maybe(self.commit_min > commit_max); + maybe(self.commit_max > commit_max); + { + // "`replica.op` exists" invariant may be broken briefly between + // set_op_and_commit_max() and replace_header(). + self.set_op_and_commit_max(header_head.op, commit_max, @src()); + assert(self.commit_max <= self.op_prepare_max()); + assert(self.commit_max <= self.op); + maybe(self.journal.header_with_op(self.op) == null); + self.replace_header(header_head); + assert(self.journal.header_with_op(self.op) != null); + } + + while (quorum_headers.next()) |header| { + assert(header.op < header_head.op); + self.replace_header(header); + } + assert(self.journal.header_with_op(self.commit_max) != null); + + const jvs_uncanonical = + JVQuorum.jvs_uncanonical(self.join_view_from_all_replicas); + for (jvs_uncanonical.const_slice()) |message| { + const message_headers = message_body_as_view_headers(message.base_const()); + for (message_headers.slice) |*header| { + if (vsr.Headers.jv_header_type(header) != .valid) continue; + + // We must trust headers that other replicas have committed, because + // repair_header() will not repair a header if the hash chain has a gap. + if (header.op <= message.header.commit_min) { + log.debug( + "{}: on_join_view: committed: replica={} op={} checksum={x:0>32}", + .{ + self.log_prefix(), + message.header.replica, + header.op, + header.checksum, + }, + ); + self.replace_header(header); + } else { + _ = self.repair_header(header); + } + } + } + } + + fn primary_log_join_view_quorum( + self: *const Replica, + comptime context: []const u8, + ) void { + assert(self.status == .view_change); + assert(self.primary_index(self.view) == self.replica); + assert(self.view > self.log_view); + + const jvs_all = JVQuorum.jvs_all(self.join_view_from_all_replicas); + for (jvs_all.const_slice()) |jv| { + log.debug( + "{}: {s}: jv: replica={} log_view={} op={} commit_min={} checkpoint={}", + .{ + self.log_prefix(), + context, + jv.header.replica, + jv.header.log_view, + jv.header.op, + jv.header.commit_min, + jv.header.checkpoint_op, + }, + ); + + const BitSet = stdx.BitSetType(128); + const jv_headers = message_body_as_view_headers(jv.base_const()); + const jv_nacks = BitSet{ .bits = jv.header.nack_bitset }; + const jv_present = BitSet{ .bits = jv.header.present_bitset }; + for (jv_headers.slice, 0..) |*header, i| { + log.debug("{}: {s}: jv: header: " ++ + "replica={} op={} checksum={x:0>32} nack={} present={} type={s}", .{ + self.log_prefix(), + context, + jv.header.replica, + header.op, + header.checksum, + jv_nacks.is_set(i), + jv_present.is_set(i), + @tagName(vsr.Headers.jv_header_type(header)), + }); + } + } + } + + fn primary_start_view_as_the_new_primary(self: *Replica) void { + assert(self.status == .view_change); + assert(self.primary_index(self.view) == self.replica); + assert(self.syncing == .idle); + assert(self.view == self.log_view); + assert(self.join_view_quorum); + assert(!self.pipeline_repairing); + assert(self.primary_repair_pipeline() == .done); + assert(self.primary_journal_repaired()); + + assert(self.commit_min == self.commit_max); + assert(self.commit_max <= self.op); + + { + const pipeline_queue = self.primary_repair_pipeline_done(); + assert(pipeline_queue.request_queue.empty()); + assert(pipeline_queue.prepare_queue.count + self.commit_max == self.op); + if (!pipeline_queue.prepare_queue.empty()) { + const prepares = &pipeline_queue.prepare_queue; + assert(prepares.head_ptr_const().?.message.header.op == self.commit_max + 1); + assert(prepares.tail_ptr_const().?.message.header.op == self.op); + } + + var pipeline_prepares = pipeline_queue.prepare_queue.iterator(); + while (pipeline_prepares.next()) |prepare| { + assert(self.journal.has_header(prepare.message.header)); + assert(!prepare.ok_quorum_received); + assert(prepare.ok_from_all_replicas.empty()); + + log.debug("{}: view_as_the_new_primary: pipeline " ++ + "(op={} checksum={x:0>32} parent={x:0>32})", .{ + self.log_prefix(), + prepare.message.header.op, + prepare.message.header.checksum, + prepare.message.header.parent, + }); + } + + self.pipeline.cache.deinit(self.message_bus.pool); + self.pipeline = .{ .queue = pipeline_queue }; + self.pipeline.queue.verify(); + } + + self.transition_to_normal_from_view_change_status(self.view); + + assert(self.status == .normal); + assert(self.primary()); + + // Send prepare_ok messages to ourself to contribute to the pipeline. + self.send_prepare_oks_after_view_change(); + } + + fn transition_to_recovering_head_from_recovering_status(self: *Replica) void { + assert(!self.solo()); + assert(self.status == .recovering); + assert(self.commit_stage == .idle); + assert(self.syncing == .idle); + assert(self.pipeline == .cache); + assert(self.journal.header_with_op(self.op) != null); + if (self.log_view < self.view) { + assert(self.op < self.commit_min); + } + + self.status = .recovering_head; + + assert(!self.prepare_timeout.ticking); + assert(!self.primary_abdicate_timeout.ticking); + assert(!self.exit_view_message_timeout.ticking); + assert(!self.exit_view_window_timeout.ticking); + assert(!self.commit_message_timeout.ticking); + assert(!self.view_change_status_timeout.ticking); + assert(!self.join_view_message_timeout.ticking); + assert(!self.get_view_message_timeout.ticking); + assert(!self.repair_sync_timeout.ticking); + assert(!self.journal_repair_timeout.ticking); + assert(!self.pulse_timeout.ticking); + assert(!self.upgrade_timeout.ticking); + + self.ping_timeout.start(); + self.grid_repair_timeout.start(); + self.grid_scrub_timeout.start(); + + log.warn("{}: transition_to_recovering_head_from_recovering_status: " ++ + "op_checkpoint={} commit_min={} op_head={} log_view={} view={}", .{ + self.log_prefix(), + self.op_checkpoint(), + self.commit_min, + self.op, + self.log_view, + self.view, + }); + } + + fn transition_to_normal_from_recovering_status(self: *Replica) void { + assert(self.status == .recovering); + assert(self.view == self.log_view); + assert(self.commit_max >= self.op -| constants.pipeline_prepare_queue_max); + assert(self.commit_stage == .idle); + assert(self.journal.header_with_op(self.op) != null); + assert(self.pipeline == .cache); + assert(self.view_headers.command == .view); + assert(self.log_view >= self.superblock.working.vsr_state.checkpoint.header.view); + + self.status = .normal; + self.commit_fault.reset(self.clock.monotonic()); + + assert(!self.prepare_timeout.ticking); + assert(!self.primary_abdicate_timeout.ticking); + assert(!self.exit_view_message_timeout.ticking); + assert(!self.exit_view_window_timeout.ticking); + assert(!self.commit_message_timeout.ticking); + assert(!self.view_change_status_timeout.ticking); + assert(!self.join_view_message_timeout.ticking); + assert(!self.get_view_message_timeout.ticking); + assert(!self.repair_sync_timeout.ticking); + assert(!self.journal_repair_timeout.ticking); + assert(!self.pulse_timeout.ticking); + assert(!self.upgrade_timeout.ticking); + + if (self.primary()) { + assert(self.solo()); + log.info( + "{}: transition_to_normal_from_recovering_status: view={} primary", + .{ + self.log_prefix(), + self.view, + }, + ); + + self.ping_timeout.start(); + self.exit_view_message_timeout.start(); + self.commit_message_timeout.start(); + self.journal_repair_timeout.start(); + self.grid_repair_timeout.start(); + self.grid_scrub_timeout.start(); + if (!self.aof_recovery) self.pulse_timeout.start(); + self.upgrade_timeout.start(); + + self.pipeline.cache.deinit(self.message_bus.pool); + self.pipeline = .{ .queue = .{ + .pipeline_request_queue_limit = self.pipeline_request_queue_limit, + } }; + } else { + log.info( + "{}: transition_to_normal_from_recovering_status: view={} backup", + .{ + self.log_prefix(), + self.view, + }, + ); + + self.ping_timeout.start(); + self.exit_view_message_timeout.start(); + self.journal_repair_timeout.start(); + self.repair_sync_timeout.start(); + self.grid_repair_timeout.start(); + self.grid_scrub_timeout.start(); + } + } + + fn transition_to_normal_from_recovering_head_status(self: *Replica, view_new: u32) void { + assert(!self.solo()); + assert(self.status == .recovering_head); + assert(self.view >= self.log_view); + assert(self.view <= view_new); + assert(self.replica != self.primary_index(view_new)); + assert(self.commit_max >= self.op -| constants.pipeline_prepare_queue_max); + assert(self.commit_stage == .idle); + assert(self.journal.header_with_op(self.op) != null); + assert(self.pipeline == .cache); + assert(self.view_headers.command == .view); + defer assert(self.log_view >= self.superblock.working.vsr_state.checkpoint.header.view); + + log.info( + "{}: transition_to_normal_from_recovering_head_status: view={}..{} backup", + .{ + self.log_prefix(), + self.view, + view_new, + }, + ); + + self.status = .normal; + self.commit_fault.reset(self.clock.monotonic()); + + if (self.log_view == view_new) { + // Recovering to the same view we lost the head in. + assert(self.view == view_new); + } else { + self.view = view_new; + self.log_view = view_new; + self.view_durable_update(); + } + + assert(self.backup()); + assert(!self.prepare_timeout.ticking); + assert(!self.primary_abdicate_timeout.ticking); + assert(!self.exit_view_window_timeout.ticking); + assert(!self.commit_message_timeout.ticking); + assert(!self.view_change_status_timeout.ticking); + assert(!self.join_view_message_timeout.ticking); + assert(!self.get_view_message_timeout.ticking); + assert(!self.repair_sync_timeout.ticking); + assert(!self.pulse_timeout.ticking); + assert(!self.upgrade_timeout.ticking); + + self.ping_timeout.start(); + self.exit_view_message_timeout.start(); + self.journal_repair_timeout.start(); + self.repair_sync_timeout.start(); + self.grid_repair_timeout.start(); + self.grid_scrub_timeout.start(); + } + + fn transition_to_normal_from_view_change_status(self: *Replica, view_new: u32) void { + // In the VRR paper it's possible to transition from normal to normal for the same view. + // For example, this could happen after a state sync triggered by an op jump. + assert(self.status == .view_change); + assert(self.commit_max >= self.op -| constants.pipeline_prepare_queue_max); + assert(view_new >= self.view); + assert(self.journal.header_with_op(self.op) != null); + assert(!self.primary_abdicating); + assert(self.view_headers.command == .view); + + self.status = .normal; + self.commit_fault.reset(self.clock.monotonic()); + + if (self.primary()) { + log.info( + "{}: transition_to_normal_from_view_change_status: view={}..{} primary", + .{ self.log_prefix(), self.view, view_new }, + ); + + assert(!self.prepare_timeout.ticking); + assert(!self.primary_abdicate_timeout.ticking); + assert(!self.repair_sync_timeout.ticking); + assert(!self.pulse_timeout.ticking); + assert(!self.upgrade_timeout.ticking); + assert(!self.pipeline_repairing); + assert(self.pipeline == .queue); + assert(self.view == view_new); + assert(self.log_view == view_new); + assert(self.commit_min == self.commit_max); + assert(self.primary_journal_repaired()); + + assert(self.log_view > self.log_view_durable() or + self.log_view == self.superblock.staging.vsr_state.log_view); + + self.ping_timeout.start(); + self.commit_message_timeout.start(); + self.exit_view_window_timeout.stop(); + self.exit_view_message_timeout.start(); + self.view_change_status_timeout.stop(); + self.join_view_message_timeout.stop(); + self.get_view_message_timeout.stop(); + if (!self.aof_recovery) self.pulse_timeout.start(); + self.upgrade_timeout.start(); + + // Do not reset the pipeline as there may be uncommitted ops to drive to completion. + if (self.pipeline.queue.prepare_queue.count > 0) { + self.prepare_timeout.start(); + self.primary_abdicate_timeout.start(); + } + } else { + log.info("{}: transition_to_normal_from_view_change_status: view={}..{} backup", .{ + self.log_prefix(), + self.view, + view_new, + }); + + assert(!self.prepare_timeout.ticking); + assert(!self.primary_abdicate_timeout.ticking); + assert(!self.repair_sync_timeout.ticking); + assert(!self.upgrade_timeout.ticking); + assert(self.get_view_message_timeout.ticking); + assert(self.pipeline == .cache); + + if (self.log_view == view_new and self.view == view_new) { + // We recovered into the same view we crashed in, with a detour through + // status=recovering_head. + } else { + self.view = view_new; + self.log_view = view_new; + self.view_durable_update(); + } + + self.ping_timeout.start(); + self.commit_message_timeout.stop(); + self.exit_view_window_timeout.stop(); + self.exit_view_message_timeout.start(); + self.view_change_status_timeout.stop(); + self.join_view_message_timeout.stop(); + self.get_view_message_timeout.stop(); + self.repair_sync_timeout.start(); + } + + self.journal_repair_timeout.start(); + self.grid_repair_timeout.start(); + self.grid_scrub_timeout.start(); + + self.heartbeat_timestamp = 0; + self.reset_quorum_exit_view(); + self.reset_quorum_join_view(); + + // Err on the side of not injecting commit stalls as a new primary, + // as opposed to using potentially outdated data to inject stalls + // (see `commit_stall`). + self.commit_mins = @splat(0); + self.head_ops = @splat(0); + + assert(self.join_view_quorum == false); + } + + /// A replica i that notices the need for a view change advances its view, sets its status + /// to view_change, and sends a ⟨JoinView v, i⟩ message to all the other replicas, + /// where v identifies the new view. A replica notices the need for a view change either + /// based on its own timer, or because it receives a ExitView or JoinView + /// message for a view with a larger number than its own view. + fn transition_to_view_change_status(self: *Replica, view_new_min: u32) void { + assert(self.status == .normal or + self.status == .view_change or + self.status == .recovering); + assert(view_new_min >= self.log_view); + assert(view_new_min >= self.view); + assert(view_new_min > self.view or self.status == .recovering); + assert(view_new_min > self.log_view); + assert(self.commit_max >= self.op -| constants.pipeline_prepare_queue_max); + defer assert(self.view_headers.command == .join_view); + + const view_new = view: { + if (self.syncing == .idle or + self.primary_index(view_new_min) != self.replica) + { + break :view view_new_min; + } else { + // A syncing replica is not eligible to be primary. + break :view view_new_min + 1; + } + }; + + log.info("{}: transition_to_view_change_status: view={}..{} status={}..{}", .{ + self.log_prefix(), + self.view, + view_new, + self.status, + Status.view_change, + }); + + if (self.status == .normal or + (self.status == .recovering and self.log_view == self.view) or + (self.status == .view_change and self.log_view == self.view)) + { + self.update_join_view_headers(); + } + + self.view_headers.verify(); + assert(self.view_headers.command == .join_view); + assert(self.view_headers.array.get(self.view_headers.array.count() - 1).op <= + self.commit_max); + + const status_before = self.status; + self.status = .view_change; + if (self.view == view_new) { + assert(status_before == .recovering); + } else { + assert(view_new > self.view); + self.view = view_new; + self.view_durable_update(); + } + + if (self.pipeline == .queue) { + var queue: PipelineQueue = self.pipeline.queue; + self.pipeline = .{ .cache = PipelineCache.init_from_queue(&queue) }; + queue.deinit(self.message_bus.pool); + } + + self.ping_timeout.start(); + self.commit_message_timeout.stop(); + self.exit_view_window_timeout.stop(); + self.exit_view_message_timeout.start(); + self.view_change_status_timeout.start(); + self.join_view_message_timeout.start(); + self.repair_sync_timeout.stop(); + self.prepare_timeout.stop(); + self.primary_abdicate_timeout.stop(); + self.pulse_timeout.stop(); + self.grid_repair_timeout.start(); + self.grid_scrub_timeout.start(); + self.upgrade_timeout.stop(); + self.journal_repair_timeout.stop(); + + if (self.primary_index(self.view) == self.replica) { + self.get_view_message_timeout.stop(); + } else { + self.get_view_message_timeout.start(); + } + + // Do not reset quorum counters only on entering a view, assuming that the view will be + // followed only by a single subsequent view change to the next view, because multiple + // successive view changes can fail, e.g. after a view change timeout. + // We must therefore reset our counters here to avoid counting messages from an older + // view, which would violate the quorum intersection property essential for correctness. + self.heartbeat_timestamp = 0; + self.primary_abdicating = false; + self.reset_quorum_exit_view(); + self.reset_quorum_join_view(); + + assert(self.join_view_quorum == false); + + self.send_join_view(); + } + + fn update_join_view_headers(self: *Replica) void { + // Either: + // - Transition from normal status. + // - Recovering from normal status. + // - Retired primary that didn't finish repair. + assert(self.status == .normal or + (self.status == .recovering and self.log_view == self.view) or + (self.status == .view_change and self.log_view == self.view)); + + const primary_repairing = + self.status == .view_change and self.log_view == self.view; + if (primary_repairing) { + assert(self.primary_index(self.view) == self.replica); + assert(self.join_view_quorum); + } + + assert(self.view == self.log_view); + + // The JV headers include: + // - all available cluster-uncommitted ops, and + // - the highest cluster-committed op (if available). + // We cannot safely go beyond that in all cases for fear of concealing a break: + // - During a prior view-change we might have only accepted a single header from the + // JV: "header.op = op_prepare_max", and then not completed any + // repair. + // - Similarly, we might have receive a catch-up View message and only installed a + // single (checkpoint trigger) hook header. + // + // JV headers are stitched together from the journal and existing view headers (they + // might belong to the next log wrap), to guarantee that a JV with log_view=v includes + // all uncommitted ops with views view_headers_op_max) break :header null; + + const header = &self.view_headers.array.const_slice()[view_headers_op_max - op]; + assert(header.op == op); + break :header switch (vsr.Headers.jv_header_type(header)) { + .valid => header, + .blank => null, + }; + }; + + if (header_journal != null and header_view != null) { + assert(header_journal.?.op == header_view.?.op); + assert(header_journal.?.view == header_view.?.view); + assert(header_journal.?.checksum == header_view.?.checksum); + } + + if (header_journal == null and header_view == null) { + assert(view_headers_updated.array.count() > 0); + assert(op != self.op); + view_headers_updated.append_blank(op); + } else { + if (header_journal) |h| { + view_headers_updated.append(h); + } else { + // Transition from normal status, but the View headers were part of the next + // wrap, so we didn't install them to our journal, and we didn't catch up. + // We will reuse the View headers as our JV headers to ensure that + // participating in another view-change won't allow the op to backtrack. + assert(self.log_view == self.view); + view_headers_updated.append(header_view.?); + } + } + + if (op <= self.commit_max) break; + op -= 1; + } else unreachable; + + assert(op <= self.commit_max); + assert(op == self.commit_max or self.commit_max > self.op); + + self.view_headers = view_headers_updated; + self.view_headers.verify(); + } + + /// Transition from "not syncing" to "syncing". + fn sync_start_from_committing(self: *Replica) void { + assert(!self.solo()); + assert(self.status != .recovering); + assert(self.syncing == .idle); + + log.debug("{}: sync_start_from_committing " ++ + "(commit_stage={s} checkpoint_op={} checkpoint_id={x:0>32})", .{ + self.log_prefix(), + @tagName(self.commit_stage), + self.op_checkpoint(), + self.superblock.staging.checkpoint_id(), + }); + + // Abort grid operations. + // Wait for non-grid operations to finish. + switch (self.commit_stage) { + // The transition which follows these stages is synchronous: + .check_prepare, + .execute, + => unreachable, + + // Uninterruptible states: + .start, + .reply_setup, + .stall, + .checkpoint_durable, + .checkpoint_data, + .checkpoint_superblock, + => self.sync_dispatch(.canceling_commit), + + .idle, // (StateMachine.open() may be running.) + .prefetch, + .compact, + => self.sync_dispatch(.canceling_grid), + } + } + + /// sync_dispatch() is called between every sync-state transition. + fn sync_dispatch(self: *Replica, state_new: SyncStage) void { + assert(!self.solo()); + assert((self.sync_tables == null) == (self.sync_tables_op_range == null)); + assert(SyncStage.valid_transition(self.syncing, state_new)); + if (self.op < self.commit_min) assert(self.status == .recovering_head); + + const state_old = self.syncing; + self.syncing = state_new; + + log.debug("{}: sync_dispatch: {s}..{s}", .{ + self.log_prefix(), + @tagName(state_old), + @tagName(self.syncing), + }); + + if (self.event_callback) |hook| hook(self, .sync_stage_changed); + + switch (self.syncing) { + .idle => {}, + .canceling_commit => {}, // Waiting for an uninterruptible commit step. + .canceling_grid => { + self.grid.cancel(sync_cancel_grid_callback); + self.grid.blocks_missing.sync_jump_commence(); + + assert(!self.grid.blocks_missing.repairing_tables()); + assert(self.grid.read_global_queue.empty()); + }, + .updating_checkpoint => self.sync_superblock_update_start(), + } + } + + fn sync_cancel_grid_callback(grid: *Grid) void { + const self: *Replica = @alignCast(@fieldParentPtr("grid", grid)); + assert(self.syncing == .canceling_grid); + assert(self.sync_view != null); + assert(!self.grid.blocks_missing.repairing_blocks()); + assert(self.grid.read_queue.empty()); + assert(self.grid.read_global_queue.empty()); + assert(self.grid.write_queue.empty()); + assert(self.grid.read_iops.executing() == 0); + assert(self.grid.write_iops.executing() == 0); + + if (self.commit_stage == .idle) { + assert(self.commit_prepare == null); + } else { + self.commit_dispatch_cancel(); + } + + var grid_reads = self.grid_reads.iterate(); + while (grid_reads.next()) |grid_read| { + assert(grid_read.message.base().references == 1); + + self.message_bus.unref(grid_read.message); + self.grid_reads.release(grid_read); + } + + self.grid_scrubber.cancel(); + + var grid_repair_writes = self.grid_repair_writes.iterate(); + while (grid_repair_writes.next()) |write| { + self.grid_repair_writes.release(write); + // The write is canceled, but a read may have acquired a reference to it from the + // write queue in the mean time. + const write_index = self.grid_repair_writes.index(write); + self.grid.block_unref(self.grid_repair_write_blocks[write_index]); + self.grid_repair_write_blocks[write_index] = self.grid.get_block(); + } + + // Resume View/sync flow. + const message = self.sync_view.?; + self.sync_view = null; + defer self.message_bus.unref(message); + + assert(message.header.command == .view); + const checkpoint = view_message_checkpoint(message); + self.sync_dispatch(.{ .updating_checkpoint = checkpoint.* }); + + self.on_view_set_journal(message); + } + + fn sync_superblock_update_start(self: *Replica) void { + assert(!self.solo()); + assert(self.syncing == .updating_checkpoint); + assert(self.superblock.working.vsr_state.checkpoint.header.op < + self.syncing.updating_checkpoint.header.op); + assert(self.commit_stage == .idle); + assert(self.grid.read_global_queue.empty()); + assert(self.grid.write_queue.empty()); + assert(!self.grid.blocks_missing.repairing_blocks()); + assert(self.grid_repair_writes.executing() == 0); + maybe(self.state_machine_opened); + maybe(self.view_durable_updating()); + + self.state_machine_opened = false; + self.state_machine.reset(); + + self.grid.free_set.reset(); + self.grid.free_set_checkpoint_blocks_acquired.reset(); + self.grid.free_set_checkpoint_blocks_released.reset(); + + self.client_sessions_checkpoint.reset(); + self.client_sessions.reset(); + + if (self.aof) |aof| aof.sync(); + // Faulty bits will be set in client_sessions_open_callback(). + while (self.client_replies.faulty.first_set()) |slot| { + self.client_replies.faulty.unset(slot); + } + + const sync_op_max = + vsr.Checkpoint.trigger_for_checkpoint(self.syncing.updating_checkpoint.header.op).?; + + assert((self.sync_tables == null) == (self.sync_tables_op_range == null)); + if (self.sync_tables_op_range) |op_range| { + // We were already syncing tables. + // We continue syncing the prior range until its done, to avoid redoing work. + assert(op_range.min >= self.superblock.working.vsr_state.sync_op_min); + assert(op_range.max < sync_op_max); + } else { + // Even though we didn't have a table sync in progress, there still may be a state + // sync that has not been marked complete. Thus, to avoid needing to re-sync any + // ops, we set the table sync range now, rather than after writing the superblock. + maybe(self.superblock.working.vsr_state.sync_op_max > 0); + + self.sync_tables = .{}; + self.sync_tables_op_range = .{ + .min = min: { + if (vsr.Checkpoint.trigger_for_checkpoint(self.op_checkpoint())) |trigger| { + break :min trigger + 1; + } else { + break :min 0; + } + }, + .max = sync_op_max, + }; + } + } + + fn sync_superblock_update_finish(self: *Replica) void { + assert(self.commit_stage == .idle); + assert(self.grid.read_global_queue.empty()); + assert(self.grid.write_queue.empty()); + assert(!self.grid.blocks_missing.repairing_blocks()); + assert(self.grid_repair_writes.executing() == 0); + assert(self.syncing == .updating_checkpoint); + assert(!self.state_machine_opened); + assert(self.release.value <= + self.superblock.working.vsr_state.checkpoint.release.value); + assert(self.sync_tables != null); + assert(self.sync_tables_op_range != null); + + const checkpoint_state: *const vsr.CheckpointState = &self.syncing.updating_checkpoint; + + assert(self.superblock.working.vsr_state.checkpoint.header.checksum == + checkpoint_state.header.checksum); + assert(self.superblock.staging.vsr_state.checkpoint.header.checksum == + checkpoint_state.header.checksum); + assert(stdx.equal_bytes( + vsr.CheckpointState, + &self.superblock.working.vsr_state.checkpoint, + checkpoint_state, + )); + + assert(self.commit_min == self.superblock.working.vsr_state.checkpoint.header.op); + + self.sync_dispatch(.idle); + + if (self.release.value < + self.superblock.working.vsr_state.checkpoint.release.value) + { + maybe(self.upgrade_release == null); + self.release_transition(@src()); + return; + } + + if (self.upgrade_release) |_| { + // If `upgrade_release` is non-null, then: + // - The replica just synced a single checkpoint. (We do not assert this via + // sync_op_min/sync_op_max, since we may have synced a single checkpoint multiple + // times.) + // - An `operation=upgrade` was committed during the last bar of the checkpoint we + // just synced. + // - But at least one op (+1) of the last bar was *not* an `operation=upgrade`. + // (If all of the last bar was `operation=upgrade`, then the new superblock's + // release would have increased. + // - We were very close to reaching the checkpoint via WAL replay – close enough to + // have executed at least one (but not all) of the upgrades in that last bar. + // As we replay the bar immediately after this checkpoint, we will set + // `upgrade_release` "again", so we reset it now to keep the assertions simple. + assert(self.superblock.working.vsr_state.checkpoint.header.operation != .upgrade); + + self.upgrade_release = null; + } + + assert(self.commit_min == self.op_checkpoint()); + + // The head op must be in the Journal and there should not be a break between the + // checkpoint header and the Journal. + assert(self.op >= self.op_checkpoint()); + + // We just replaced our superblock, so many outstanding get_prepares/get_blocks are + // probably not useful, and we are likely to need to repair soon (and quickly) in order + // to start committing again. + self.journal_repair_message_budget.refill(); + if (self.journal_repair_timeout.ticking) { + self.journal_repair_timeout.reset_with_jitter(&self.prng); + } + + self.grid_repair_message_budget.refill(); + self.grid_repair_timeout.reset_with_jitter(&self.prng); + + log.info("{}: sync: ops={}..{}/{}..{}", .{ + self.log_prefix(), + self.sync_tables_op_range.?.min, + self.sync_tables_op_range.?.max, + self.superblock.working.vsr_state.sync_op_min, + self.superblock.working.vsr_state.sync_op_max, + }); + + self.grid.open(grid_open_callback); + self.message_bus.resume_receive(); + assert(self.op <= self.op_prepare_max()); + } + + /// We have just: + /// - finished superblock sync, + /// - replaced our superblock, + /// - repaired the manifest blocks, + /// - and opened the state machine. + /// Now we sync: + /// - the missed LSM table blocks (index/data). + fn sync_content(self: *Replica) void { + assert(self.syncing == .idle); + assert(self.state_machine_opened); + assert(self.superblock.working.vsr_state.sync_op_max > 0); + assert(self.sync_tables != null); + assert(self.sync_tables_op_range != null); + assert(!self.grid.blocks_missing.repairing_tables()); + maybe(self.grid_repair_tables.executing() == 0); + + const snapshot_from_commit = vsr.Snapshot.readable_at_commit; + { + // Log an approximation of how much sync work there is to do. + // Note that this isn't completely accurate: + // - It doesn't consider that tables might be added/removed by local compaction. + // - It counts any tables which are already queued in GridBlocksMissing as complete. + const sections = [_]struct { + tables: ForestTableIterator, + sync_op_min: u64, + sync_op_max: u64, + }{ + .{ + .tables = self.sync_tables.?, + .sync_op_min = self.sync_tables_op_range.?.min, + .sync_op_max = self.sync_tables_op_range.?.max, + }, + .{ + .tables = ForestTableIterator{}, + .sync_op_min = self.sync_tables_op_range.?.max + 1, + .sync_op_max = self.superblock.working.vsr_state.sync_op_max, + }, + }; + const sections_count = @as(u32, 1) + + @intFromBool(sections[0].sync_op_max != sections[1].sync_op_max); + + var table_count: u32 = 0; + var table_count_by_level: [constants.lsm_levels]u32 = @splat(0); + for (sections[0..sections_count]) |section| { + const sync_op_max = section.sync_op_max; + const sync_op_min = section.sync_op_min; + assert(sync_op_min >= self.superblock.working.vsr_state.sync_op_min); + + var tables = section.tables; + while (tables.next(&self.state_machine.forest)) |table_info| { + if (table_info.snapshot_min >= snapshot_from_commit(sync_op_min) and + table_info.snapshot_min <= snapshot_from_commit(sync_op_max)) + { + table_count += 1; + table_count_by_level[table_info.label.level] += 1; + } + } + } + + log.info( + "{}: sync: {} tables (by level: {any})", + .{ self.log_prefix(), table_count, table_count_by_level }, + ); + } + + if (self.grid.blocks_missing.state == .sync_jump) { + var grid_repair_tables: [constants.grid_missing_tables_max]*Grid.RepairTable = + @splat(undefined); + var grid_repair_tables_count: u32 = 0; + + // Cancel sync for any tables that don't belong in this new checkpoint. + var repair_tables = self.grid_repair_tables.iterate(); + while (repair_tables.next()) |table| { + assert(table.table_info.snapshot_min >= + snapshot_from_commit(self.sync_tables_op_range.?.min)); + assert(table.table_info.snapshot_min <= + snapshot_from_commit(self.sync_tables_op_range.?.max)); + + if (!self.state_machine.forest.contains_table(&table.table_info)) { + grid_repair_tables[grid_repair_tables_count] = table; + grid_repair_tables_count += 1; + } + } + self.grid.blocks_missing.sync_tables_cancel( + grid_repair_tables[0..grid_repair_tables_count], + &self.grid.free_set, + ); + self.sync_reclaim_tables(); + } else { + // We are starting table-sync right out of recovery. + assert(self.grid_repair_tables.executing() == 0); + assert(self.sync_tables_op_range.?.max == + self.superblock.working.vsr_state.sync_op_max); + } + assert(self.grid.blocks_missing.state == .repairing); + // Client replies are synced in lockstep with client sessions in + // `client_sessions_open_callback`. + } + + pub fn sync_content_done(self: *const Replica) bool { + return self.sync_client_replies_done() and self.sync_grid_done(); + } + + fn sync_client_replies_done(self: *const Replica) bool { + // Trailers/manifest haven't yet been synced. + if (!self.state_machine_opened) return false; + + for (0..constants.clients_max) |entry_slot| { + if (!self.client_sessions.entries_present.is_set(entry_slot)) continue; + + const entry = &self.client_sessions.entries[entry_slot]; + if (entry.header.op >= self.superblock.working.vsr_state.sync_op_min and + entry.header.op <= self.superblock.working.vsr_state.sync_op_max) + { + if (!self.client_replies.reply_durable(.{ .index = entry_slot })) { + return false; + } + } + } + + return true; + } + + fn sync_grid_done(self: *const Replica) bool { + // Trailers/manifest haven't yet been synced. + if (!self.state_machine_opened) return false; + + return self.sync_tables == null and self.grid_repair_tables.executing() == 0; + } + + /// State sync finished, and we must repair all of the tables we missed. + fn sync_enqueue_tables(self: *Replica) void { + assert(self.syncing == .idle); + assert(self.sync_tables != null); + assert(self.sync_tables_op_range != null); + assert(self.state_machine_opened); + assert(self.superblock.working.vsr_state.sync_op_max > 0); + assert(self.grid_repair_tables.available() > 0); + + const snapshot_from_commit = vsr.Snapshot.readable_at_commit; + const sync_op_max_next = self.superblock.working.vsr_state.sync_op_max; + const sync_op_max = self.sync_tables_op_range.?.max; + const sync_op_min = self.sync_tables_op_range.?.min; + assert(sync_op_min >= self.superblock.working.vsr_state.sync_op_min); + + while (self.sync_tables.?.next(&self.state_machine.forest)) |table_info| { + assert(self.grid_repair_tables.available() > 0); + assert(table_info.label.event == .reserved); + + if (table_info.snapshot_min >= snapshot_from_commit(sync_op_min) and + table_info.snapshot_min <= snapshot_from_commit(sync_op_max)) + { + log.debug("{}: sync_enqueue_tables: request " ++ + "address={} checksum={x:0>32} level={} snapshot_min={} ({}..{})", .{ + self.log_prefix(), + table_info.address, + table_info.checksum, + table_info.label.level, + table_info.snapshot_min, + snapshot_from_commit(sync_op_min), + snapshot_from_commit(sync_op_max), + }); + + const table: *Grid.RepairTable = self.grid_repair_tables.acquire().?; + const table_bitset: *std.DynamicBitSetUnmanaged = + &self.grid_repair_table_bitsets[self.grid_repair_tables.index(table)]; + + const enqueue_result = + self.grid.blocks_missing.sync_table(table, table_bitset, &table_info); + + switch (enqueue_result) { + .insert => self.trace.start(.{ .replica_sync_table = .{ + .index = self.grid_repair_tables.index(table), + } }), + .duplicate => { + // Duplicates are only possible due to move-table. + assert(table_info.label.level > 0); + + self.grid_repair_tables.release(table); + }, + } + + if (self.grid_repair_tables.available() == 0) break; + } else { + // Verify that we already have any table that is not within the sync range. + if (table_info.snapshot_min < snapshot_from_commit(sync_op_min) or + table_info.snapshot_min > snapshot_from_commit(sync_op_max_next)) + { + self.grid.verify_table( + table_info.address, + table_info.checksum, + ); + } + } + } + + if (self.grid_repair_tables.executing() == 0) { + assert(self.sync_tables.?.next(&self.state_machine.forest) == null); + + log.info("{}: sync_enqueue_tables: all tables synced (commit={}..{}/{})", .{ + self.log_prefix(), + sync_op_min, + sync_op_max, + self.superblock.working.vsr_state.sync_op_max, + }); + + assert(sync_op_max <= self.superblock.working.vsr_state.sync_op_max); + if (sync_op_max < self.superblock.working.vsr_state.sync_op_max) { + // We completed a previous state sync, but have since replaced our superblock + // again, so there is still more table sync to be done. + self.sync_tables = .{}; + self.sync_tables_op_range = .{ + .min = self.sync_tables_op_range.?.max + 1, + .max = self.superblock.working.vsr_state.sync_op_max, + }; + self.sync_enqueue_tables(); // Recursion depth never exceeds one. + } else { + self.sync_tables = null; + self.sync_tables_op_range = null; + + // Send prepare_oks that may have been withheld by virtue of + // `op_prepare_ok_max`. + self.send_prepare_oks_after_syncing_tables(); + } + } + } + + fn sync_reclaim_tables(self: *Replica) void { + assert((self.sync_tables == null) == (self.sync_tables_op_range == null)); + + while (self.grid.blocks_missing.reclaim_table()) |table| { + log.info( + "{}: sync_reclaim_tables: table synced or canceled: " ++ + "address={} checksum={x:0>32} wrote={}/{?}", + .{ + self.log_prefix(), + table.table_info.address, + table.table_info.checksum, + table.table_blocks_written, + table.table_blocks_total, + }, + ); + + self.grid_repair_tables.release(table); + self.trace.stop(.{ .replica_sync_table = .{ + .index = self.grid_repair_tables.index(table), + } }); + } + assert(self.grid_repair_tables.available() <= constants.grid_missing_tables_max); + + if (self.syncing == .idle and + self.state_machine_opened and + self.sync_tables != null) + { + assert(self.grid.callback != .cancel); + + if (self.grid_repair_tables.available() > 0) { + self.sync_enqueue_tables(); + } + } + } + + fn release_transition(self: *Replica, source: SourceLocation) void { + const release_target = self.superblock.working.vsr_state.checkpoint.release; + assert(release_target.value != self.release.value); + + if (self.release.value > release_target.value) { + // Downgrading to old release. + // The replica just started in the newest available release, but discovered that its + // superblock has not upgraded to that release yet. + assert(self.commit_min == self.op_checkpoint()); + assert(self.journal.status == .init); + } + + if (self.release.value < release_target.value) { + // Upgrading to new release. + // We checkpointed or state-synced an upgrade. + // + // Even though we are upgrading, our target version is not necessarily available in + // our binary. (In this case, release_execute() is responsible for error-ing out.) + maybe(self.release.value == self.multiversion.releases_bundled().first().value); + assert(self.commit_min == self.op_checkpoint() or + self.commit_min == vsr.Checkpoint.trigger_for_checkpoint(self.op_checkpoint())); + maybe(self.journal.status == .init); + } + + log.info("{}: release_transition: release={}..{} (reason={s})", .{ + self.log_prefix(), + self.release, + release_target, + source.fn_name, + }); + + self.multiversion.release_execute(release_target); + // At this point, depending on the implementation of release_execute(): + // - For testing/cluster.zig: `self` is no longer valid – the replica has been + // deinitialized and re-opened on the new version. + // - For tigerbeetle/main.zig: This is unreachable (release_execute() will not return). + } + + /// Returns the next checkpoint's `CheckpointState.release`. + fn release_for_next_checkpoint(self: *const Replica) ?vsr.Release { + assert(self.release.value == + self.superblock.working.vsr_state.checkpoint.release.value); + + if (self.commit_min < self.op_checkpoint_next_trigger()) { + return null; + } + + var found_upgrade: usize = 0; + for (self.op_checkpoint_next() + 1..self.op_checkpoint_next_trigger() + 1) |op| { + const header = self.journal.header_for_op(op).?; + assert(header.operation != .reserved); + + if (header.operation == .upgrade) { + found_upgrade += 1; + } else { + // Only allow the next checkpoint's release to advance if the entire last bar + // preceding the checkpoint trigger consists of operation=upgrade. + // + // Otherwise we would risk the following: + // 1. Execute op=X in the state machine on version v1. + // 2. Upgrade, checkpoint, restart. + // 3. Replay op=X when recovering from checkpoint on v2. + // If v1 and v2 produce different results when executing op=X, then an assertion + // will trip (as v2's reply doesn't match v1's in the client sessions). + assert(found_upgrade == 0); + maybe(self.upgrade_release != null); + return self.release; + } + } + assert(found_upgrade == constants.lsm_compaction_ops); + assert(self.upgrade_release != null); + return self.upgrade_release.?; + } + + /// Whether it is safe to commit or send prepare_ok messages. + /// Returns true if the hash chain is valid: + /// - connects to the checkpoint + /// - connects to the head + /// - the head is up to date for the current view. + /// This is a stronger guarantee than `valid_hash_chain_between()` below. + fn valid_hash_chain(self: *const Replica, source: SourceLocation) bool { + assert(self.op_checkpoint() <= self.commit_min); + assert(self.op_checkpoint() <= self.op); + + // If we know we could validate the hash chain even further, then wait until we can: + // This is partial defense-in-depth in case `self.op` is ever advanced by a reordered + // op. + if (self.op < self.op_repair_max()) { + log.debug( + "{}: {s}: waiting for repair (op={} < op_repair_max={}, commit_max={})", + .{ + self.log_prefix(), + source.fn_name, + self.op, + self.op_repair_max(), + self.commit_max, + }, + ); + return false; + } + + if (self.op == self.op_checkpoint()) { + // The head op almost always exceeds op_checkpoint because the + // previous checkpoint trigger is ahead of op_checkpoint by a bar. + // + // However, state sync arrives at the op_checkpoint unconventionally – + // the ops between the checkpoint and the previous checkpoint trigger may not be + // in our journal yet. + log.debug("{}: {s}: recently synced; waiting for ops (op=checkpoint={})", .{ + self.log_prefix(), + source.fn_name, + self.op, + }); + return false; + } + + // When commit_min=op_checkpoint, the checkpoint may be missing. + // valid_hash_chain_between() will still verify that we are connected. + const op_verify_min = @max(self.commit_min, self.op_checkpoint() + 1); + assert(op_verify_min <= self.commit_min + 1); + + // We must validate the hash chain as far as possible, since `self.op` may disclose a + // fork: + if (!self.valid_hash_chain_between(op_verify_min, self.op)) { + log.debug("{}: {s}: waiting for repair (hash chain)", .{ + self.log_prefix(), + source.fn_name, + }); + return false; + } + + return true; + } + + /// Returns true if all operations are present, correctly ordered and connected by hash + /// chain, between `op_min` and `op_max` (both inclusive). + fn valid_hash_chain_between(self: *const Replica, op_min: u64, op_max: u64) bool { + assert(op_min <= op_max); + assert(op_max >= self.op_checkpoint()); + + // If we use anything less than self.op then we may commit ops for a forked hash chain + // that have since been reordered by a new primary. + assert(op_max == self.op); + var b = self.journal.header_with_op(op_max).?; + + var op = op_max; + while (op > op_min) { + op -= 1; + + if (self.journal.header_with_op(op)) |a| { + assert(a.op + 1 == b.op); + if (a.checksum == b.parent) { + assert(ascending_viewstamps(a, b)); + b = a; + } else { + log.debug("{}: valid_hash_chain_between: break: A: {}", .{ + self.log_prefix(), + a, + }); + log.debug("{}: valid_hash_chain_between: break: B: {}", .{ + self.log_prefix(), + b, + }); + return false; + } + } else { + log.debug("{}: valid_hash_chain_between: missing op={}", .{ + self.log_prefix(), + op, + }); + return false; + } + } + assert(b.op == op_min); + + // The op immediately after the checkpoint always connects to the checkpoint. + if (op_min <= self.op_checkpoint() + 1 and op_max > self.op_checkpoint()) { + assert(self.superblock.working.vsr_state.checkpoint.header.op == + self.op_checkpoint()); + assert(self.superblock.working.vsr_state.checkpoint.header.checksum == + self.journal.header_with_op(self.op_checkpoint() + 1).?.parent); + } + + return true; + } + + fn jump_view(self: *Replica, header: *const Header) void { + assert(self.sync_view == null); + + if (header.view < self.view) return; + if (header.replica >= self.replica_count) return; // Ignore messages from standbys. + + const to: Status = switch (header.command) { + .prepare, .commit => .normal, + // When we are recovering_head we can't participate in a view-change anyway. + // But there is a chance that the primary is actually running, despite the JV/EV. + .join_view, + .exit_view, + // For pings, we don't actually know where the new view is started or not. + // Conservatively transition to view change: at worst, we'll send a larger JV + // instead of a RV. + .ping, + .pong, + => if (self.status == .recovering_head) Status.normal else .view_change, + // on_view() handles the (possible) transition to view-change manually, before + // transitioning to normal. + .view => return, + else => return, + }; + + if (self.standby()) { + // Standbys don't participate in view changes, so switching to `.view_change` is + // useless. This also prevents an isolated replica from locking a standby into a + // view higher than that of the rest of the cluster. + if (to != .normal) return; + } + + // Compare status transitions and decide whether to view jump or ignore: + switch (self.status) { + .normal => switch (to) { + // If the transition is to `.normal`, then ignore if for the same view: + .normal => if (header.view == self.view) return, + // If the transition is to `.view_change`, then ignore if the view has started: + .view_change => if (header.view == self.view) return, + else => unreachable, + }, + .view_change => switch (to) { + // This is an interesting special case: + // If the transition is to `.normal` in the same view, then we missed the + // View message and we must also consider this a view jump: + // If we don't handle this below then our `view_change_status_timeout` will fire + // and we will disrupt the cluster with another view change for a newer view. + .normal => {}, + // If the transition is to `.view_change`, then ignore if for the same view: + .view_change => if (header.view == self.view) return, + else => unreachable, + }, + // We need a View from any other replica — don't request it from ourselves. + .recovering_head => if (self.primary_index(header.view) == self.replica) return, + .recovering => return, + } + + switch (to) { + .normal => { + if (header.view == self.view) { + assert(self.status == .view_change or self.status == .recovering_head); + + log.debug("{}: jump_view: waiting to exit view change", .{ + self.log_prefix(), + }); + } else { + assert(header.view > self.view); + assert(self.status == .view_change or self.status == .recovering_head or + self.status == .normal); + + log.debug("{}: jump_view: waiting to jump to newer view ({}..{})", .{ + self.log_prefix(), + self.view, + header.view, + }); + } + + // TODO Debounce and decouple this from `on_message()` by moving into `tick()`: + // (Using get_view_message_timeout). + log.debug("{}: jump_view: requesting View message", .{ + self.log_prefix(), + }); + self.send_header_to_replica( + self.primary_index(header.view), + @bitCast(Header.GetView{ + .command = .get_view, + .cluster = self.cluster, + .replica = self.replica, + .view = header.view, + .nonce = self.nonce, + }), + ); + }, + .view_change => { + assert(self.status == .normal or self.status == .view_change); + assert(self.view < header.view); + assert(!self.standby()); + + if (header.view == self.view + 1) { + log.debug("{}: jump_view: jumping to view change", .{self.log_prefix()}); + } else { + log.debug("{}: jump_view: jumping to next view change", .{ + self.log_prefix(), + }); + } + self.transition_to_view_change_status(header.view); + }, + else => unreachable, + } + } + + // Criteria for caching: + // - The primary does not update the cache since it is (or will be) reconstructing its + // pipeline. + // - Cache uncommitted ops, since it will avoid a WAL read in the common case. + fn cache_prepare(self: *Replica, message: *Message.Prepare) void { + assert(self.status == .normal); + assert(self.primary_index(self.view) != self.replica); + assert(self.pipeline == .cache); + assert(self.commit_min < message.header.op); + + const prepare_evicted = self.pipeline.cache.insert(message.ref()); + if (prepare_evicted) |m| self.message_bus.unref(m); + } + + fn write_prepare(self: *Replica, message: *Message.Prepare) bool { + assert(self.status == .normal or self.status == .view_change); + assert(self.status == .normal or self.primary_index(self.view) == self.replica); + assert(self.status == .normal or self.join_view_quorum); + assert(message.base().references > 0); + assert(message.header.command == .prepare); + assert(message.header.operation != .reserved); + assert(message.header.view <= self.view); + assert(message.header.op <= self.op); + assert(message.header.op >= self.op_repair_min()); + + if (!self.journal.has_header(message.header)) { + log.debug("{}: write_prepare: ignoring op={} checksum={x:0>32} (header changed)", .{ + self.log_prefix(), + message.header.op, + message.header.checksum, + }); + return false; + } + + switch (self.journal.writing(message.header)) { + .none => {}, + .slot, .exact => |reason| { + log.debug( + "{}: write_prepare: ignoring op={} checksum={x:0>32} (already writing {s})", + .{ + self.log_prefix(), + message.header.op, + message.header.checksum, + @tagName(reason), + }, + ); + return false; + }, + } + + self.journal.write_prepare(write_prepare_callback, message); + + return true; + } + + fn write_prepare_callback(self: *Replica, wrote: ?*Message.Prepare) void { + self.message_bus.resume_receive(); + + // `null` indicates that we did not complete the write for some reason. + const message = wrote orelse return; + + self.send_prepare_ok(message.header); + self.flush_loopback_queue(); + } + + fn send_get_blocks(self: *Replica, destination_replica_index: u8) void { + assert(self.grid_repair_timeout.ticking); + assert(self.grid.callback != .cancel); + maybe(self.state_machine_opened); + assert(destination_replica_index != self.replica); + + if (!self.solo()) { + assert(self.grid_repair_message_budget.budget_available( + destination_replica_index, + ) >= constants.grid_repair_request_max); + } + + if (self.grid.blocks_missing.faulty_blocks.count() == 0 and + self.grid.read_global_queue.count() == 0) return; + + var message = self.message_bus.get_message(.get_blocks); + defer self.message_bus.unref(message); + + const requests_buffer = std.mem.bytesAsSlice( + vsr.BlockRequest, + message.buffer[@sizeOf(Header)..], + )[0..constants.grid_repair_request_max]; + assert(requests_buffer.len > 0); + var requests_count: u32 = 0; + + // Prioritize requests for blocks with stalled Grid reads, + // so that commit/compaction can continue. We divide the + // buffer up between `read_global_queue` and + // `blocks_missing.faulty_blocks` so that we always request + // blocks from both queues. Surplus from `blocks_missing.faulty_blocks` + // may be used by `read_global_queue`. + const request_faults_count_max = requests_buffer.len - @min( + @divFloor(requests_buffer.len, 2), + self.grid.blocks_missing.faulty_blocks.count(), + ); + assert(request_faults_count_max > 0); + assert(request_faults_count_max <= requests_buffer.len); + assert(request_faults_count_max >= @divFloor(requests_buffer.len, 2)); + + const now = self.clock.monotonic(); + + var grid_faults = self.grid.read_global_queue.iterate(); + while (grid_faults.next()) |read_fault| { + if (requests_count >= request_faults_count_max) break; + + const block_identifier = vsr.BlockReference{ + .address = read_fault.address, + .checksum = read_fault.checksum, + }; + + if (self.grid_repair_message_budget.decrement( + block_identifier, + destination_replica_index, + now, + )) { + requests_buffer[requests_count] = .{ + .block_address = read_fault.address, + .block_checksum = read_fault.checksum, + }; + requests_count += 1; + } + } + + const faulty_blocks_count = self.grid.blocks_missing.faulty_blocks.count(); + const faulty_index_offset = self.prng.int_inclusive(usize, faulty_blocks_count -| 1); + for (0..faulty_blocks_count) |index| { + if (requests_count >= requests_buffer.len) break; + + if (self.grid.blocks_missing.fault_at_index( + (faulty_index_offset + index) % faulty_blocks_count, + )) |missing_request| { + const block_identifier = vsr.BlockReference{ + .address = missing_request.block_address, + .checksum = missing_request.block_checksum, + }; + + if (self.grid_repair_message_budget.decrement( + block_identifier, + destination_replica_index, + now, + )) { + requests_buffer[requests_count] = missing_request; + requests_count += 1; + } + } + } + + assert(requests_count <= constants.grid_repair_request_max); + if (requests_count == 0) return; + assert(!self.solo()); + + for (requests_buffer[0..requests_count]) |*request| { + assert(!self.grid.free_set.is_free(request.block_address)); + + log.debug("{}: send_get_blocks: request address={} checksum={x:0>32}", .{ + self.log_prefix(), + request.block_address, + request.block_checksum, + }); + } + + message.header.* = .{ + .command = .get_blocks, + .cluster = self.cluster, + .replica = self.replica, + .size = @sizeOf(Header) + requests_count * @sizeOf(vsr.BlockRequest), + }; + message.header.set_checksum_body(message.body_used()); + message.header.set_checksum(); + + self.send_message_to_replica(destination_replica_index, message); + } + + fn send_commit(self: *Replica, now: Instant) void { + assert(self.status == .normal); + assert(self.primary()); + assert(self.commit_min == self.commit_max); + + // Signal even during abdication, to maintain the invariant that + // a replica doesn't let commit_fault to be red without an action. + // It wouldn't be wrong to _not_ signal, but keeping the two code + // paths orthogonal is cleaner. + self.commit_fault.signal(now); + if (self.primary_abdicating) { + assert(self.primary_abdicate_timeout.ticking); + + log.mark.debug("{}: send_commit: primary abdicating (view={})", .{ + self.log_prefix(), + self.view, + }); + return; + } + + const latest_committed_entry = checksum: { + if (self.commit_max == self.superblock.working.vsr_state.checkpoint.header.op) { + break :checksum self.superblock.working.vsr_state.checkpoint.header.checksum; + } else { + break :checksum self.journal.header_with_op(self.commit_max).?.checksum; + } + }; + + self.send_header_to_other_replicas_and_standbys(@bitCast(Header.Commit{ + .command = .commit, + .cluster = self.cluster, + .replica = self.replica, + .view = self.view, + .commit = self.commit_max, + .commit_checksum = latest_committed_entry, + .timestamp_monotonic = self.clock.monotonic().ns, + .checkpoint_op = self.superblock.working.vsr_state.checkpoint.header.op, + .checkpoint_id = self.superblock.working.checkpoint_id(), + })); + } + + fn pulse_enabled(self: *Replica) bool { + assert(self.status == .normal); + assert(self.primary()); + assert(!self.pipeline.queue.full()); + + // Pulses are replayed during `aof recovery`. + if (self.aof_recovery) return false; + // There's a pulse already in progress. + if (self.pipeline.queue.contains_operation(.pulse)) return false; + // Solo replicas only change views immediately when they start up, + // and during that time they do not accept requests. + // See Replica.open() for more detail. + if (self.solo() and self.view_durable_updating()) return false; + // Requests are ignored during upgrades. + if (self.upgrading()) return false; + + return true; + } + + fn send_request_pulse_to_self(self: *Replica) void { + assert(!self.aof_recovery); + assert(self.status == .normal); + assert(self.primary()); + assert(!self.view_durable_updating()); + assert(!self.pipeline.queue.full()); + assert(!self.pipeline.queue.contains_operation(.pulse)); + assert(self.pulse_enabled()); + assert(self.state_machine.pulse_needed(self.state_machine.prepare_timestamp)); + + self.send_request_to_self(.pulse, &.{}); + assert(self.pipeline.queue.contains_operation(.pulse)); + } + + fn send_request_upgrade_to_self(self: *Replica) void { + assert(self.status == .normal); + assert(self.primary()); + assert(!self.view_durable_updating()); + assert(self.upgrade_release.?.value > self.release.value); + maybe(self.pipeline.queue.contains_operation(.upgrade)); + + const upgrade = vsr.UpgradeRequest{ .release = self.upgrade_release.? }; + self.send_request_to_self(.upgrade, std.mem.asBytes(&upgrade)); + assert(self.pipeline.queue.contains_operation(.upgrade)); + } + + fn send_request_to_self(self: *Replica, operation: vsr.Operation, body: []const u8) void { + assert(self.status == .normal); + assert(self.primary()); + + const request = self.message_bus.get_message(.request); + defer self.message_bus.unref(request); + + request.header.* = .{ + .cluster = self.cluster, + .command = .request, + .replica = self.replica, + .release = self.release, + .size = @intCast(@sizeOf(Header) + body.len), + .view = self.view, + .operation = operation, + .request = 0, + .parent = 0, + .client = 0, + .session = 0, + .previous_request_latency = 0, + }; + + stdx.copy_disjoint(.exact, u8, request.body_used(), body); + request.header.set_checksum_body(request.body_used()); + request.header.set_checksum(); + + // Enable zero-copy Request->Prepare->WAL path by padding to disk sector. + @memset(request.buffer[request.header.size..vsr.sector_ceil(request.header.size)], 0); + + self.send_message_to_replica(self.replica, request); + return self.flush_loopback_queue(); + } + + fn upgrading(self: *const Replica) bool { + return self.upgrade_release != null or + self.pipeline.queue.contains_operation(.upgrade); + } + + /// Asserts that the count of acquired blocks in the free set is the sum of: + /// 1. Index blocks across all tables in the forest + /// 2. Value blocks across all tables in the forest + /// 3. ManifestLog blocks + pub fn assert_free_set_consistent(self: *const Replica) void { + assert(self.grid.free_set.opened); + assert(self.state_machine.forest.manifest_log.opened); + + // Must be invoked either on startup, or after checkpoint completes. + assert(!self.state_machine_opened or self.commit_stage == .checkpoint_superblock); + + var forest_tables_iterator = ForestTableIterator{}; + var tables_index_block_count: u64 = 0; + var tables_value_block_count: u64 = 0; + while (forest_tables_iterator.next(&self.state_machine.forest)) |table| { + const block_value_count = switch (Forest.tree_id_cast(table.tree_id)) { + inline else => |tree_id| self.state_machine.forest.tree_for_id_const( + tree_id, + ).block_value_count_max(), + }; + tables_index_block_count += 1; + tables_value_block_count += stdx.div_ceil( + table.value_count, + block_value_count, + ); + } + + assert((self.grid.free_set.count_acquired() - self.grid.free_set.count_released()) == + (tables_index_block_count + tables_value_block_count + + self.state_machine.forest.manifest_log.log_block_checksums.count)); + } + + pub fn log_prefix(self: *const Replica) LogPrefix { + return .{ + .replica = self.replica, + .status = self.status, + .primary = self.primary_index(self.view) == self.replica, + }; + } + }; +} + +/// A view change: +/// - selects the view's head (modulo nack+truncation during repair) +/// - discards uncommitted ops (to maximize availability in the presence of storage faults) +/// - retains all committed ops +/// - retains all possibly-committed ops (because they might be committed — we can't tell) +/// (Some of these may be discarded during repair, via the nack protocol). +/// Refer to the CTRL protocol from Protocol-Aware Recovery for Consensus-Based Storage. +/// +/// Terminology: +/// +/// - *JV* refers to a command=join_view message. +/// - *View* refers to a command=view message. +/// +/// - The *head* message (of a view) is the message (committed or uncommitted) within that view with +/// the highest op. +/// +/// - *gap*: There is a header for op X and X+n (n>1), but no header at op X+1. +/// - *blank*: A header that explicitly marks a gap in the JV headers. +/// (See `vsr.Headers.jv_blank()`). +/// - *break*/*chain break*: The header for op X is not the parent of the header for op X+1. +/// - *fork*: A correctness bug in which a committed (or possibly committed) message is discarded. +/// +/// The cluster can have many different "versions" of the "same" header. +/// That is, different headers (different checksum) with the same op. +/// But at most one version (per op) is "canonical", the remainder are "uncanonical". +/// - A *canonical message* is any JV message from the most recent log_view in the quorum. +/// - An *uncanonical header* may have been removed/changed during a prior view. +/// - A *canonical header* was part of the most recent log_view. +/// - (That is, the canonical headers are the union of headers from all canonical messages). +/// - Canonical headers do not necessarily survive into the new view, but they take +/// precedence over uncanonical headers. +/// - Canonical headers may be committed or uncommitted. +/// +/// +/// Invariants (for each JV message): +/// +/// - The "valid" headers all belong to the same hash chain. +/// - Reason: If multiple replicas with the same canonical log_view disagree about an op, the new +/// primary could not determine which is correct. +/// - The JV-sender is responsible for ensuring blanks do not conceal chain breaks. +/// - For example, +/// - a JV of 6a,7_,8a is valid (6a/8a belong to the same chain). +/// - a JV of 6b,7_,8a is invalid (the gap at 7 conceal a chain break). +/// - a JV of 6b,7b,8a is invalid (7b/8a is a chain break).. +/// - All pipeline headers present on the replica must be included in the JV headers. +/// - When `replica.commit_max ≤ replica.op`, +/// the JV must include a valid/blank header for every op in that range. +/// - When `replica.commit_max > replica.op`, only a single header is included +/// (`replica.commit_max` if available in the View, otherwise `replica.op`). +/// - (The JV will need a valid header corresponding to its `commit_max` to complete, since the +/// entire pipeline may be truncated, and the new primary still needs a header for its head op.) +/// +/// Each header in the JV body is one of: +/// +/// | Header State || Derived Information +/// | Blank | Nack || Nack | Description +/// |-------|------||-------|------------- +/// | yes | yes || yes | No header, and replica did not prepare this op during its last view. +/// | yes | no || no | No header, but the replica may have prepared this op during its last +/// | | || | view. Since the replica does not know the header, it cannot nack. +/// | no | yes || yes | Valid header, but the replica has never prepared the message. +/// | no | no || maybe | Valid header, and the replica has prepared the message. +/// | | || | Counts as a nack iff the header does not match the canonical header +/// | | || | for this op. +/// +/// Where: +/// +/// - Blank: +/// - Yes: Send a bogus header that indicates that the sender does not know the actual +/// command=prepare header for that op. +/// - No: Send the actual header. The corresponding header may be corrupt, prepared, or nacked. +/// - Nack (header state): +/// - Yes: The corresponding header in the message body was definitely not +/// prepared in the latest view. (The corresponding header may be blank or ¬blank). +/// - No: The corresponding header in the message body was either prepared during the latest view, +/// or _might_ have been prepared, but due to WAL corruption we can't tell. +/// - Nack (derived): based on Blank/Nack, whether the new primary counts it as a nack. +/// - The header corresponding to the sender's `replica.op` is always "valid", never a "blank". +/// (Otherwise the replica would be in status=recovering_head and unable to participate). +/// +/// Invariants (across all JVs in the quorum): +/// +/// - The valid headers of every JV with the same log_view must not conflict. +/// - In other words: +/// jv₁.headers[i].op == jv₂.headers[j].op implies +/// jv₁.headers[i].checksum == jv₂.headers[j].checksum. +/// - Reason: the headers bundled with the JV(s) with the highest log_view will be +/// loaded into the new primary with `replace_header()`, not `repair_header()`. +/// - Any pipeline message which could have been committed is included in some canonical JV. +/// +/// Perhaps unintuitively, it is safe to advertise a header before its message is prepared +/// (e.g. the write is still queued, or the prepare has not arrived). The header is either: +/// +/// - committed — so another replica in the quorum must have a copy, according to the quorum +/// intersection property. Or, +/// - uncommitted — if the header is chosen, but cannot be recovered from any replica, then +/// it will be discarded by the nack protocol. +const JVQuorum = struct { + const JVArray = stdx.BoundedArrayType(*const Message.JoinView, constants.replicas_max); + + fn verify(jv_quorum: JVQuorumMessages) void { + const jvs = JVQuorum.jvs_all(jv_quorum); + for (jvs.const_slice()) |message| verify_message(message); + + // Verify that JVs with the same log_view do not conflict. + for (jvs.const_slice(), 0..) |jv_a, i| { + for (jvs.const_slice()[0..i]) |jv_b| { + if (jv_a.header.log_view != jv_b.header.log_view) continue; + + const headers_a = message_body_as_view_headers(jv_a.base_const()); + const headers_b = message_body_as_view_headers(jv_b.base_const()); + // Find the intersection of the ops covered by each JV. + const op_max = @min(jv_a.header.op, jv_b.header.op); + const op_min = @max( + headers_a.slice[headers_a.slice.len - 1].op, + headers_b.slice[headers_b.slice.len - 1].op, + ); + // If a replica is lagging, its headers may not overlap at all. + maybe(op_min > op_max); + + var op = op_min; + while (op <= op_max) : (op += 1) { + const header_a = &headers_a.slice[jv_a.header.op - op]; + const header_b = &headers_b.slice[jv_b.header.op - op]; + if (vsr.Headers.jv_header_type(header_a) == .valid and + vsr.Headers.jv_header_type(header_b) == .valid) + { + assert(header_a.checksum == header_b.checksum); + } + } + } + } + } + + fn verify_message(message: *const Message.JoinView) void { + assert(message.header.command == .join_view); + assert(message.header.commit_min <= message.header.op); + + const checkpoint = message.header.checkpoint_op; + assert(checkpoint <= message.header.commit_min); + + // The log_view: + // * may be higher than the view in any of the prepare headers. + // * must be lower than the view of this view change. + const log_view = message.header.log_view; + assert(log_view < message.header.view); + + // Ignore the result, init() verifies the headers. + const headers = message_body_as_view_headers(message.base_const()); + assert(headers.slice.len >= 1); + assert(headers.slice.len <= constants.pipeline_prepare_queue_max + 1); + assert(headers.slice[0].op == message.header.op); + assert(headers.slice[0].view <= log_view); + + const nacks = message.header.nack_bitset; + comptime assert(@TypeOf(nacks) == u128); + assert(@popCount(nacks) <= headers.slice.len); + assert(@clz(nacks) + headers.slice.len >= @bitSizeOf(u128)); + + const present = message.header.present_bitset; + comptime assert(@TypeOf(present) == u128); + assert(@popCount(present) <= headers.slice.len); + assert(@clz(present) + headers.slice.len >= @bitSizeOf(u128)); + } + + fn jvs_all(jv_quorum: JVQuorumMessages) JVArray { + var array = JVArray{}; + for (jv_quorum, 0..) |received, replica| { + if (received) |message| { + assert(message.header.command == .join_view); + assert(message.header.replica == replica); + + array.push(message); + } + } + return array; + } + + fn jvs_canonical(jv_quorum: JVQuorumMessages) JVArray { + return jvs_with_log_view(jv_quorum, JVQuorum.log_view_max(jv_quorum)); + } + + fn jvs_with_log_view(jv_quorum: JVQuorumMessages, log_view: u32) JVArray { + var array = JVArray{}; + const jvs = JVQuorum.jvs_all(jv_quorum); + for (jvs.const_slice()) |message| { + if (message.header.log_view == log_view) { + array.push(message); + } + } + return array; + } + + fn jvs_uncanonical(jv_quorum: JVQuorumMessages) JVArray { + const log_view_max_ = JVQuorum.log_view_max(jv_quorum); + var array = JVArray{}; + const jvs = JVQuorum.jvs_all(jv_quorum); + for (jvs.const_slice()) |message| { + assert(message.header.log_view <= log_view_max_); + + if (message.header.log_view < log_view_max_) { + array.push(message); + } + } + return array; + } + + fn op_checkpoint_max(jv_quorum: JVQuorumMessages) u64 { + var checkpoint_max: ?u64 = null; + const jvs = jvs_all(jv_quorum); + for (jvs.const_slice()) |jv| { + const jv_checkpoint = jv.header.checkpoint_op; + if (checkpoint_max == null or checkpoint_max.? < jv_checkpoint) { + checkpoint_max = jv_checkpoint; + } + } + return checkpoint_max.?; + } + + /// Returns the highest `log_view` of any JV. + /// + /// The headers bundled with JVs with the highest `log_view` are canonical, since + /// the replica has knowledge of previous view changes in which headers were replaced. + fn log_view_max(jv_quorum: JVQuorumMessages) u32 { + var log_view_max_: ?u32 = null; + const jvs = JVQuorum.jvs_all(jv_quorum); + for (jvs.const_slice()) |message| { + // `log_view` is the view when this replica was last in normal status, which: + // * may be higher than the view in any of the prepare headers. + // * must be lower than the view of this view change. + assert(message.header.log_view < message.header.view); + + if (log_view_max_ == null or log_view_max_.? < message.header.log_view) { + log_view_max_ = message.header.log_view; + } + } + return log_view_max_.?; + } + + fn commit_max(jv_quorum: JVQuorumMessages) u64 { + const jvs = JVQuorum.jvs_all(jv_quorum); + assert(jvs.count() > 0); + + var commit_max_: u64 = 0; + for (jvs.const_slice()) |jv| { + const jv_headers = message_body_as_view_headers(jv.base_const()); + // JV generation stops when a header with op ≤ commit_max is appended. + const jv_commit_max_tail = jv_headers.slice[jv_headers.slice.len - 1].op; + // An op cannot be uncommitted if it is definitely outside the pipeline. + // Use `join_view_op_head` instead of `replica.op` since the former is + // about to become the new `replica.op`. + const jv_commit_max_pipeline = + jv.header.op -| constants.pipeline_prepare_queue_max; + + commit_max_ = @max(commit_max_, jv_commit_max_tail); + commit_max_ = @max(commit_max_, jv_commit_max_pipeline); + commit_max_ = @max(commit_max_, jv.header.commit_min); + commit_max_ = @max(commit_max_, jv_headers.slice[0].commit); + } + return commit_max_; + } + + /// Returns the highest `timestamp` from any replica. + fn timestamp_max(jv_quorum: JVQuorumMessages) u64 { + var timestamp_max_: ?u64 = null; + const jvs = JVQuorum.jvs_all(jv_quorum); + for (jvs.const_slice()) |jv| { + const jv_headers = message_body_as_view_headers(jv.base_const()); + const jv_head = &jv_headers.slice[0]; + if (timestamp_max_ == null or timestamp_max_.? < jv_head.timestamp) { + timestamp_max_ = jv_head.timestamp; + } + } + return timestamp_max_.?; + } + + fn op_max_canonical(jv_quorum: JVQuorumMessages) u64 { + var op_max: ?u64 = null; + const jvs = JVQuorum.jvs_canonical(jv_quorum); + for (jvs.const_slice()) |message| { + if (op_max == null or op_max.? < message.header.op) { + op_max = message.header.op; + } + } + return op_max.?; + } + + /// When the view is ready to begin: + /// - Return an iterator over the canonical JV's headers, from high-to-low op. + /// The first header returned is the new head message. + /// Otherwise: + /// - Return the reason the view cannot begin. + fn quorum_headers(jv_quorum: JVQuorumMessages, options: struct { + quorum_nack_prepare: u8, + quorum_view_change: u8, + replica_count: u8, + }) union(enum) { + // The quorum has fewer than "quorum_view_change" JVs. + // We are waiting for JVs from the remaining replicas. + awaiting_quorum, + // The quorum has at least "quorum_view_change" JVs. + // The quorum has fewer than "replica_count" JVs. + // The quorum collected so far is insufficient to determine which headers can be nacked + // (due to an excess of faults). + // We must wait for JVs from one or more remaining replicas. + awaiting_repair, + // All replicas have contributed a JV, but there are too many faults to start a new view. + // The cluster is deadlocked, unable to ever complete a view change. + complete_invalid, + // The quorum is complete, and sufficient to start the new view. + complete_valid: HeaderIterator, + } { + assert(options.replica_count >= 2); + assert(options.replica_count <= constants.replicas_max); + assert(options.quorum_view_change >= 2); + assert(options.quorum_view_change <= options.replica_count); + if (options.replica_count == 2) { + assert(options.quorum_nack_prepare == 1); + } else { + assert(options.quorum_nack_prepare == options.quorum_view_change); + } + + const jvs_all_ = JVQuorum.jvs_all(jv_quorum); + if (jvs_all_.count() < options.quorum_view_change) return .awaiting_quorum; + + const log_view_canonical = JVQuorum.log_view_max(jv_quorum); + const jvs_canonical_ = JVQuorum.jvs_canonical(jv_quorum); + assert(jvs_canonical_.count() > 0); + assert(jvs_canonical_.count() <= jvs_all_.count()); + + const op_head_max = JVQuorum.op_max_canonical(jv_quorum); + const op_head_min = JVQuorum.commit_max(jv_quorum); + + // Iterate the highest definitely committed op and all maybe-uncommitted ops. + var op = op_head_min; + const op_head = while (op <= op_head_max) : (op += 1) { + const header_canonical = for (jvs_canonical_.const_slice()) |jv| { + // This JV is canonical, but lagging far behind. + if (jv.header.op < op) continue; + + const headers = message_body_as_view_headers(jv.base_const()); + const header_index = jv.header.op - op; + assert(header_index <= headers.slice.len); + + const header = &headers.slice[header_index]; + assert(header.op == op); + + if (vsr.Headers.jv_header_type(header) == .valid) break header; + } else null; + + var copies: usize = 0; + var nacks: usize = 0; + for (jvs_all_.const_slice()) |jv| { + if (jv.header.op < op) { + nacks += 1; + continue; + } + + const headers = message_body_as_view_headers(jv.base_const()); + const header_index = jv.header.op - op; + if (header_index >= headers.slice.len) { + nacks += 1; + continue; + } + + const header = &headers.slice[header_index]; + assert(header.op == op); + assert(header.view <= log_view_canonical); + + const header_nacks = stdx.BitSetType(128){ + .bits = jv.header.nack_bitset, + }; + const header_present = stdx.BitSetType(128){ + .bits = jv.header.present_bitset, + }; + + if (vsr.Headers.jv_header_type(header) == .valid and + header_present.is_set(header_index) and + header_canonical != null and header_canonical.?.checksum == header.checksum) + { + copies += 1; + } + + if (header_nacks.is_set(header_index)) { + // The op is nacked explicitly. + nacks += 1; + } else if (vsr.Headers.jv_header_type(header) == .valid) { + if (header_canonical != null and + header_canonical.?.checksum != header.checksum) + { + assert(jv.header.log_view < log_view_canonical); + // The op is nacked implicitly, because the replica has a different header. + nacks += 1; + } + if (header_canonical == null) { + assert(header.view < log_view_canonical); + assert(jv.header.log_view < log_view_canonical); + // The op is nacked implicitly, because the header has already been + // truncated in the latest log_view. + nacks += 1; + } + } + } + + // This is an abbreviated version of Protocol-Aware Recovery's CTRL protocol. + // When we can confirm that an op is definitely uncommitted, truncate it to + // improve availability. + if (nacks >= options.quorum_nack_prepare) { + // Never nack op_head_min (aka commit_max). + assert(op > op_head_min); + break op - 1; + } + + if (header_canonical == null or + (header_canonical != null and copies == 0)) + { + if (jvs_all_.count() < options.replica_count) { + return .awaiting_repair; + } else { + return .complete_invalid; + } + } + + // This op is eligible to be the view's head. + assert(header_canonical != null and copies > 0); + } else op_head_max; + assert(op_head >= op_head_min); + assert(op_head <= op_head_max); + + return .{ .complete_valid = HeaderIterator{ + .jvs = jvs_canonical_, + .op_max = op_head, + .op_min = op_head_min, + } }; + } + + /// Iterate the consecutive headers of a set of (same-log_view) JVs, from high-to-low op. + const HeaderIterator = struct { + jvs: JVArray, + op_max: u64, + op_min: u64, + child_op: ?u64 = null, + child_parent: ?u128 = null, + + fn next(iterator: *HeaderIterator) ?*const Header.Prepare { + assert(iterator.jvs.count() > 0); + assert(iterator.op_min <= iterator.op_max); + assert((iterator.child_op == null) == (iterator.child_parent == null)); + + if (iterator.child_op != null and iterator.child_op.? == iterator.op_min) return null; + + const op = (iterator.child_op orelse (iterator.op_max + 1)) - 1; + + var header: ?*const Header.Prepare = null; + + const log_view = iterator.jvs.get(0).header.log_view; + for (iterator.jvs.const_slice()) |jv| { + assert(log_view == jv.header.log_view); + + if (op > jv.header.op) continue; + + const jv_headers = message_body_as_view_headers(jv.base_const()); + const jv_header_index = jv.header.op - op; + if (jv_header_index >= jv_headers.slice.len) continue; + + const jv_header = &jv_headers.slice[jv_header_index]; + if (vsr.Headers.jv_header_type(jv_header) == .valid) { + if (header) |h| { + assert(h.checksum == jv_header.checksum); + } else { + header = jv_header; + } + } + } + + if (iterator.child_parent) |parent| { + assert(header.?.checksum == parent); + } + + iterator.child_op = op; + iterator.child_parent = header.?.parent; + return header.?; + } + }; +}; + +fn message_body_as_view_headers(message: *const Message) vsr.Headers.ViewChangeSlice { + assert(message.header.size > @sizeOf(Header)); // Body must contain at least one header. + assert(message.header.command == .join_view); + + return vsr.Headers.ViewChangeSlice.init( + switch (message.header.command) { + .join_view => .join_view, + else => unreachable, + }, + message_body_as_headers_unchecked(message), + ); +} + +/// Asserts that the headers are in descending op order. +/// The headers may contain gaps and/or breaks. +fn message_body_as_prepare_headers(message: *const Message) []const Header.Prepare { + assert(message.header.size > @sizeOf(Header)); // Body must contain at least one header. + assert(message.header.command == .headers); + + const headers = message_body_as_headers_unchecked(message); + var child: ?*const Header.Prepare = null; + for (headers) |*header| { + assert(header.valid_checksum()); + assert(header.command == .prepare); + assert(header.cluster == message.header.cluster); + assert(header.view <= message.header.view); + + if (child) |child_header| { + // Headers must be provided in reverse order for the sake of `repair_header()`. + // Otherwise, headers may never be repaired where the hash chain never connects. + assert(header.op < child_header.op); + } + child = header; + } + + return headers; +} + +fn message_body_as_headers_unchecked(message: *const Message) []const Header.Prepare { + assert(message.header.size > @sizeOf(Header)); // Body must contain at least one header. + assert(message.header.command == .join_view or + message.header.command == .headers); + + return std.mem.bytesAsSlice( + Header.Prepare, + message.body_used(), + ); +} + +fn view_message_checkpoint(message: *const Message.View) *const vsr.CheckpointState { + assert(message.header.command == .view); + assert(message.body_used().len > @sizeOf(vsr.CheckpointState)); + + const checkpoint = std.mem.bytesAsValue( + vsr.CheckpointState, + message.body_used()[0..@sizeOf(vsr.CheckpointState)], + ); + assert(checkpoint.header.valid_checksum()); + assert(stdx.zeroed(&checkpoint.reserved)); + return checkpoint; +} + +fn view_message_headers(message: *const Message.View) []const Header.Prepare { + assert(message.header.command == .view); + + // Body must contain at least one header. + assert(message.header.size > @sizeOf(Header) + @sizeOf(vsr.CheckpointState)); + + comptime assert(@sizeOf(vsr.CheckpointState) % @alignOf(vsr.Header) == 0); + const headers: []const vsr.Header.Prepare = std.mem.bytesAsSlice( + Header.Prepare, + message.body_used()[@sizeOf(vsr.CheckpointState)..], + ); + assert(headers.len > 0); + vsr.Headers.ViewChangeSlice.verify(.{ .command = .view, .slice = headers }); + if (constants.verify) { + for (headers) |*header| { + assert(header.valid_checksum()); + assert(vsr.Headers.jv_header_type(header) == .valid); + } + } + return headers; +} + +fn ping_message_release_list(message: *const Message.Ping) vsr.ReleaseList { + assert(message.header.release_count <= constants.vsr_releases_max); + + const releases_all = std.mem.bytesAsSlice(vsr.Release, message.body_used()); + for (releases_all[message.header.release_count..]) |r| assert(r.value == 0); + const releases = releases_all[0..message.header.release_count]; + assert(releases.len == message.header.release_count); + + var result: vsr.ReleaseList = .empty; + for (releases) |release| result.push(release); + + result.verify(); + assert(result.contains(message.header.release)); + return result; +} + +/// The PipelineQueue belongs to a normal-status primary. It consists of two queues: +/// - A prepare queue, containing all messages currently being prepared. +/// - A request queue, containing all messages which are waiting to begin preparing. +/// +/// Invariants: +/// - prepare_queue contains only messages with command=prepare. +/// - prepare_queue's messages have sequential, increasing ops. +/// - prepare_queue's messages are hash-chained. +/// - request_queue contains only messages with command=request. +/// - If request_queue is not empty, then prepare_queue is full OR 1-less than full. +/// (The caller is responsible for maintaining this invariant. If the caller removes an entry +/// from `prepare_queue`, an entry from request_queue should be moved over promptly.) +/// +/// Note: The prepare queue may contain multiple prepares from a single client, but the request +/// queue may not (see message_by_client()). +const PipelineQueue = struct { + const PrepareQueue = RingBufferType( + Prepare, + .{ .array = constants.pipeline_prepare_queue_max }, + ); + const RequestQueue = RingBufferType( + Request, + .{ .array = constants.pipeline_request_queue_max }, + ); + + pipeline_request_queue_limit: u32, + /// Messages that are preparing (uncommitted, being written to the WAL (may already be written + /// to the WAL) and replicated (may just be waiting for acks)). + prepare_queue: PrepareQueue = PrepareQueue.init(), + /// Messages that are accepted from the client, but not yet preparing. + /// When `pipeline_prepare_queue_max + pipeline_request_queue_max = clients_max`, the request + /// queue guards against clients starving one another. + request_queue: RequestQueue = RequestQueue.init(), + + fn deinit(pipeline: *PipelineQueue, message_pool: *MessagePool) void { + while (pipeline.request_queue.pop()) |r| message_pool.unref(r.message); + while (pipeline.prepare_queue.pop()) |p| message_pool.unref(p.message); + } + + fn verify(pipeline: PipelineQueue) void { + assert(pipeline.request_queue.count <= constants.pipeline_request_queue_max); + assert(pipeline.prepare_queue.count <= constants.pipeline_prepare_queue_max); + + assert(pipeline.pipeline_request_queue_limit >= 0); + assert(pipeline.pipeline_request_queue_limit <= constants.pipeline_request_queue_max); + assert(pipeline.request_queue.count <= pipeline.pipeline_request_queue_limit); + + assert(pipeline.request_queue.empty() or + constants.pipeline_prepare_queue_max == pipeline.prepare_queue.count or + constants.pipeline_prepare_queue_max == pipeline.prepare_queue.count + 1 or + pipeline.contains_operation(.pulse)); + + if (pipeline.prepare_queue.head_ptr_const()) |head| { + var op = head.message.header.op; + var parent = head.message.header.parent; + var prepare_iterator = pipeline.prepare_queue.iterator(); + var upgrade: bool = false; + while (prepare_iterator.next_ptr()) |prepare| { + assert(prepare.message.header.command == .prepare); + assert(prepare.message.header.operation != .reserved); + assert(prepare.message.header.op == op); + assert(prepare.message.header.parent == parent); + + if (prepare.message.header.operation == .upgrade) { + upgrade = true; + } else { + assert(!upgrade); + } + + parent = prepare.message.header.checksum; + op += 1; + } + } + + var request_iterator = pipeline.request_queue.iterator(); + while (request_iterator.next()) |request| { + assert(request.message.header.command == .request); + } + } + + fn prepare_queue_capacity(pipeline: *const PipelineQueue) u32 { + _ = pipeline; + return constants.pipeline_prepare_queue_max; + } + + fn request_queue_capacity(pipeline: *const PipelineQueue) u32 { + return pipeline.pipeline_request_queue_limit; + } + + fn full(pipeline: PipelineQueue) bool { + if (pipeline.prepare_queue.count == pipeline.prepare_queue_capacity()) { + return pipeline.request_queue.count == pipeline.request_queue_capacity(); + } else { + assert(pipeline.request_queue.empty() or + pipeline.prepare_queue.count + 1 == constants.pipeline_prepare_queue_max or + pipeline.contains_operation(.pulse)); + return false; + } + } + + /// Searches the pipeline for a prepare for a given op and checksum. + fn prepare_by_op_and_checksum(pipeline: *PipelineQueue, op: u64, checksum: u128) ?*Prepare { + if (pipeline.prepare_queue.empty()) return null; + + // To optimize the search, we can leverage the fact that the pipeline's entries are + // ordered and consecutive. + const head_op = pipeline.prepare_queue.head_ptr().?.message.header.op; + const tail_op = pipeline.prepare_queue.tail_ptr().?.message.header.op; + assert(tail_op == head_op + pipeline.prepare_queue.count - 1); + + if (op < head_op) return null; + if (op > tail_op) return null; + + const prepare = pipeline.prepare_queue.get_ptr(op - head_op).?; + assert(prepare.message.header.op == op); + + if (checksum == prepare.message.header.checksum) return prepare; + return null; + } + + /// Searches the pipeline for a prepare matching the given ack. + /// Asserts that the returned prepare corresponds to the prepare_ok. + fn prepare_by_prepare_ok(pipeline: *PipelineQueue, ok: *const Message.PrepareOk) ?*Prepare { + assert(ok.header.command == .prepare_ok); + + const prepare = pipeline.prepare_by_op_and_checksum( + ok.header.op, + ok.header.prepare_checksum, + ) orelse return null; + assert(prepare.message.header.command == .prepare); + assert(prepare.message.header.parent == ok.header.parent); + assert(prepare.message.header.client == ok.header.client); + assert(prepare.message.header.request == ok.header.request); + assert(prepare.message.header.cluster == ok.header.cluster); + assert(prepare.message.header.epoch == ok.header.epoch); + // A prepare may be committed in the same view or in a newer view: + assert(prepare.message.header.view <= ok.header.view); + assert(prepare.message.header.op == ok.header.op); + assert(prepare.message.header.timestamp == ok.header.timestamp); + assert(prepare.message.header.operation == ok.header.operation); + assert(prepare.message.header.checkpoint_id == ok.header.checkpoint_id); + + return prepare; + } + + /// Search the pipeline (both request & prepare queues) for a message from the given client. + /// - A client may have multiple prepares in the pipeline if these were committed by the + /// previous primary and were reloaded into the pipeline after a view change. + /// - A client may have at most one request in the pipeline. + /// If there are multiple messages in the pipeline from the client, the *latest* message is + /// returned (to help the caller identify bad client behavior). + fn message_by_client(pipeline: PipelineQueue, client_id: u128) ?*const Message { + var message: ?*const Message = null; + var prepare_iterator = pipeline.prepare_queue.iterator(); + while (prepare_iterator.next_ptr()) |prepare| { + if (prepare.message.header.client == client_id) message = prepare.message.base(); + } + + var request_iterator = pipeline.request_queue.iterator(); + while (request_iterator.next()) |request| { + if (request.message.header.client == client_id) message = request.message.base(); + } + return message; + } + + fn contains_operation(pipeline: PipelineQueue, operation: vsr.Operation) bool { + var prepare_iterator = pipeline.prepare_queue.iterator(); + while (prepare_iterator.next_ptr()) |prepare| { + if (prepare.message.header.operation == operation) return true; + } + + var request_iterator = pipeline.request_queue.iterator(); + while (request_iterator.next()) |request| { + if (request.message.header.operation == operation) return true; + } + return false; + } + + /// Warning: This temporarily violates the prepare/request queue count invariant. + /// After invocation, call pop_request→push_prepare to begin preparing the next request. + fn pop_prepare(pipeline: *PipelineQueue) ?Prepare { + if (pipeline.prepare_queue.pop()) |prepare| { + assert(pipeline.request_queue.empty() or + pipeline.prepare_queue.count + 1 == constants.pipeline_prepare_queue_max or + prepare.message.header.operation == .pulse or + pipeline.contains_operation(.pulse)); + return prepare; + } else { + assert(pipeline.request_queue.empty()); + return null; + } + } + + fn pop_request(pipeline: *PipelineQueue) ?Request { + return pipeline.request_queue.pop(); + } + + fn push_request(pipeline: *PipelineQueue, request: Request) void { + assert(pipeline.request_queue.count < pipeline.request_queue_capacity()); + assert(request.message.header.command == .request); + pipeline.assert_request_queue(request); + + pipeline.request_queue.push_assume_capacity(request); + if (constants.verify) pipeline.verify(); + } + + fn assert_request_queue(pipeline: *const PipelineQueue, request: Request) void { + var queue_iterator = pipeline.request_queue.iterator(); + while (queue_iterator.next()) |queue_request| { + assert(queue_request.message.header.client != request.message.header.client); + } + } + + fn push_prepare(pipeline: *PipelineQueue, message: *Message.Prepare) void { + assert(pipeline.prepare_queue.count < pipeline.prepare_queue_capacity()); + assert(message.header.command == .prepare); + assert(message.header.operation != .reserved); + if (pipeline.prepare_queue.tail()) |tail| { + assert(message.header.op == tail.message.header.op + 1); + assert(message.header.parent == tail.message.header.checksum); + assert(message.header.view >= tail.message.header.view); + } else { + assert(pipeline.request_queue.empty()); + } + + pipeline.prepare_queue.push_assume_capacity(.{ .message = message.ref() }); + if (constants.verify) pipeline.verify(); + } +}; + +/// Prepares in the cache may be committed or uncommitted, and may not belong to the current view. +/// +/// Invariants: +/// - The cache contains only messages with command=prepare. +/// - If a message with op X is in the cache, it is in `prepares[X % prepares.len]`. +const PipelineCache = struct { + const prepares_max = + constants.pipeline_prepare_queue_max + + constants.pipeline_request_queue_max; + + capacity: u32, + + // Invariant: prepares[capacity..] == null + prepares: [prepares_max]?*Message.Prepare = @splat(null), + + /// Converting a PipelineQueue to a PipelineCache discards all accumulated acks. + /// "prepare_ok"s from previous views are not valid, even if the pipeline entry is reused + /// after a cycle of view changes. In other words, when a view change cycles around, so + /// that the original primary becomes a primary of a new view, pipeline entries may be + /// reused. However, the pipeline's prepare_ok quorums must not be reused, since the + /// replicas that sent them may have swapped them out during a previous view change. + fn init_from_queue(queue: *PipelineQueue) PipelineCache { + assert(queue.pipeline_request_queue_limit >= 0); + assert(queue.pipeline_request_queue_limit + constants.pipeline_prepare_queue_max <= + prepares_max); + + var cache = PipelineCache{ + .capacity = constants.pipeline_prepare_queue_max + queue.pipeline_request_queue_limit, + }; + var prepares = queue.prepare_queue.iterator(); + while (prepares.next()) |prepare| { + const prepare_evicted = cache.insert(prepare.message.ref()); + assert(prepare_evicted == null); + assert(prepare.message.header.command == .prepare); + } + return cache; + } + + fn deinit(pipeline: *PipelineCache, message_pool: *MessagePool) void { + for (&pipeline.prepares) |*entry| { + if (entry.*) |m| { + message_pool.unref(m); + entry.* = null; + } + } + } + + fn empty(pipeline: *const PipelineCache) bool { + for (pipeline.prepares[pipeline.capacity..]) |*entry| assert(entry.* == null); + + for (pipeline.prepares[0..pipeline.capacity]) |*entry| { + if (entry) |_| return true; + } + return false; + } + + fn contains_header(pipeline: *const PipelineCache, header: *const Header.Prepare) bool { + assert(header.command == .prepare); + assert(header.operation != .reserved); + + const slot = header.op % pipeline.capacity; + const prepare = pipeline.prepares[slot] orelse return false; + return prepare.header.op == header.op and prepare.header.checksum == header.checksum; + } + + /// Unlike the PipelineQueue, cached messages may not belong to the current view. + /// Thus, a matching checksum is required. + fn prepare_by_op_and_checksum( + pipeline: *PipelineCache, + op: u64, + checksum: u128, + ) ?*Message.Prepare { + const slot = op % pipeline.capacity; + const prepare = pipeline.prepares[slot] orelse return null; + if (prepare.header.op != op) return null; + if (prepare.header.checksum != checksum) return null; + return prepare; + } + + /// Returns the message evicted from the cache, if any. + fn insert(pipeline: *PipelineCache, prepare: *Message.Prepare) ?*Message.Prepare { + assert(prepare.header.command == .prepare); + assert(prepare.header.operation != .reserved); + + const slot = prepare.header.op % pipeline.capacity; + const prepare_evicted = pipeline.prepares[slot]; + pipeline.prepares[slot] = prepare; + return prepare_evicted; + } +}; diff --git a/ocam/src/vsr/replica_format.zig b/ocam/src/vsr/replica_format.zig new file mode 100644 index 00000000..e4731d4d --- /dev/null +++ b/ocam/src/vsr/replica_format.zig @@ -0,0 +1,441 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const constants = @import("../constants.zig"); +const stdx = @import("stdx"); +const vsr = @import("../vsr.zig"); +const Header = vsr.Header; +const data_file_size_min = @import("./superblock.zig").data_file_size_min; +const SuperBlockHeader = @import("./superblock.zig").SuperBlockHeader; +const superblock_copy_size = @import("./superblock.zig").superblock_copy_size; + +/// Initialize the TigerBeetle replica's data file. +pub fn format( + comptime Storage: type, + gpa: std.mem.Allocator, + storage: *Storage, + options: vsr.SuperBlockType(Storage).FormatOptions, +) !void { + const ReplicaFormat = ReplicaFormatType(Storage); + const SuperBlock = vsr.SuperBlockType(Storage); + + var superblock = try SuperBlock.init(gpa, storage, .{ + .storage_size_limit = data_file_size_min, + }); + defer superblock.deinit(gpa); + + var replica_format = try ReplicaFormat.init(gpa); + defer replica_format.deinit(gpa); + + try replica_format.queue_format_wal(options.cluster, storage); + replica_format.format_and_tick(storage, &superblock, options); + replica_format.verify_writes(); +} + +/// When formatting, we write: +/// * constants.journal_slot_count many prepares, +/// * 1 write that contains all of the headers. +pub const writes_max = constants.journal_slot_count + 1; + +fn ReplicaFormatType(comptime Storage: type) type { + const SuperBlock = vsr.SuperBlockType(Storage); + return struct { + const ReplicaFormat = @This(); + + const Write = struct { + write: Storage.Write = undefined, + replica_format: *ReplicaFormat, + + issued_buffer: []const u8, + issued_offset: u64, + }; + + formatting: bool = false, + formatting_superblock: bool = false, + superblock_context: SuperBlock.Context = undefined, + + writes: [writes_max]Write = undefined, + writes_pending: u64 = 0, + + sectors_written: std.DynamicBitSetUnmanaged, + arena: std.heap.ArenaAllocator, + + fn init(gpa: std.mem.Allocator) !ReplicaFormat { + var sectors_written = try std.DynamicBitSetUnmanaged.initEmpty( + gpa, + @divExact(data_file_size_min, constants.sector_size), + ); + errdefer sectors_written.deinit(gpa); + + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + + return .{ + .sectors_written = sectors_written, + .arena = arena, + }; + } + + fn deinit(self: *ReplicaFormat, gpa: std.mem.Allocator) void { + self.arena.deinit(); + self.sectors_written.deinit(gpa); + } + + fn queue_format_wal( + self: *ReplicaFormat, + cluster: u128, + storage: *Storage, + ) !void { + assert(!self.formatting and !self.formatting_superblock); + + const arena = self.arena.allocator(); + + // The logical offset *within the Zone*. + // Even though the prepare zone follows the redundant header zone, write the prepares + // first. This allows the test Storage to check the invariant "never write the redundant + // header before the prepare". + for (0..constants.journal_slot_count) |slot| { + // Direct I/O requires the buffer to be sector-aligned. Allocate a buffer for each + // sector in the arena, so they can be written concurrently. + const header_buffer = try arena.alignedAlloc( + u8, + constants.sector_size, + constants.sector_size, + ); + const header: *Header.Prepare = std.mem.bytesAsValue( + Header.Prepare, + header_buffer, + ); + header.* = slot_header(cluster, slot); + assert(header.valid_checksum()); + + const prepare_offset = slot * constants.message_size_max; + assert(prepare_offset <= constants.journal_size_prepares); + assert(prepare_offset % @sizeOf(Header) == 0); + assert(prepare_offset % constants.sector_size == 0); + + // Zero padding to produce identical checksums of an empty datafile, not because + // it's required for correctness. + const header_padding = header_buffer[@sizeOf(Header.Prepare)..]; + @memset(header_padding, 0); + assert(stdx.zeroed(header_padding)); + + if (header.op == 0) { + assert(header.operation == .root); + } else { + assert(header.operation == .reserved); + } + + self.writes[self.writes_pending] = .{ + .replica_format = self, + .issued_buffer = header_buffer, + .issued_offset = prepare_offset, + }; + + storage.write_sectors( + write_sectors_callback, + &self.writes[self.writes_pending].write, + header_buffer, + .wal_prepares, + prepare_offset, + ); + self.writes_pending += 1; + } + + // Direct I/O requires the buffer to be sector-aligned. Unlike the Prepares above that + // require a buffer per prepare, since they are spread out with zeros inbetween, the + // headers zone is contiguous so a single buffer will do. + // + // There might be padding, so allocate []u8 instead of []Header.Prepare. + const headers_buffer = try arena.alignedAlloc( + u8, + constants.sector_size, + vsr.sector_ceil(constants.journal_size_headers), + ); + + for (0..constants.journal_slot_count) |slot| { + const header_buffer = + headers_buffer[slot * @sizeOf(Header.Prepare) ..][0..@sizeOf(Header.Prepare)]; + const header: *Header.Prepare = @alignCast( + std.mem.bytesAsValue(Header.Prepare, header_buffer), + ); + header.* = slot_header(cluster, slot); + assert(header.valid_checksum()); + + if (header.op == 0) { + assert(header.operation == .root); + } else { + assert(header.operation == .reserved); + } + } + + // Zero padding to produce identical checksums of an empty datafile, not because it's + // required for correctness. + const headers_padding = + headers_buffer[constants.journal_slot_count * @sizeOf(Header.Prepare) ..]; + @memset(headers_padding, 0); + assert(stdx.zeroed(headers_padding)); + + self.writes[self.writes_pending] = .{ + .replica_format = self, + .issued_buffer = headers_buffer, + .issued_offset = 0, + }; + storage.write_sectors( + write_sectors_callback, + &self.writes[self.writes_pending].write, + headers_buffer, + .wal_headers, + 0, + ); + self.writes_pending += 1; + } + + fn format_and_tick( + self: *ReplicaFormat, + storage: *Storage, + superblock: *SuperBlock, + superblock_options: SuperBlock.FormatOptions, + ) void { + assert(self.writes_pending == writes_max); + + self.formatting = true; + while (self.formatting) storage.run(); + + self.formatting_superblock = true; + superblock.format( + format_superblock_callback, + &self.superblock_context, + superblock_options, + ); + while (self.formatting_superblock) storage.run(); + } + + fn write_sectors_callback(storage_write: *Storage.Write) void { + const write: *Write = @fieldParentPtr("write", storage_write); + const self = write.replica_format; + + assert(self.formatting); + assert(!self.formatting_superblock); + + self.writes_pending -= 1; + + const sector_offset = @divExact( + storage_write.zone.offset(write.issued_offset), + constants.sector_size, + ); + const sector_count = @divExact(write.issued_buffer.len, constants.sector_size); + + for (sector_offset..sector_offset + sector_count) |sector| { + self.sectors_written.set(sector); + } + + if (self.writes_pending == 0) { + self.formatting = false; + } + } + + fn format_superblock_callback(superblock_context: *SuperBlock.Context) void { + const self: *ReplicaFormat = + @alignCast(@fieldParentPtr("superblock_context", superblock_context)); + assert(!self.formatting); + assert(self.formatting_superblock); + self.formatting_superblock = false; + } + + fn verify_writes(self: *ReplicaFormat) void { + assert(!self.formatting and !self.formatting_superblock); + assert(self.writes_pending == 0); + + assert(self.sectors_written.count() > 0); + assert(self.sectors_written.capacity() == + @divExact(data_file_size_min, constants.sector_size)); + + // Expect that: + // * every sector in the wal_headers zone has been written, + // * the first sector in every constants.message_size_max has been written, + // * nothing else has been written. + // + // This might seem to miss the superblock zone, but that's handled entirely by + // superblock.zig, which reads back the headers to validate it has been written + // correctly. + for (0..self.sectors_written.capacity()) |sector| { + const sector_start = sector * constants.sector_size; + + const zone = for (std.enums.values(vsr.Zone)) |zone| { + if (sector_start >= zone.start() and + sector_start < zone.start() + zone.size().?) break zone; + } else unreachable; + + switch (zone) { + // Every sector in the wal_headers zone has been written: + .wal_headers => assert(self.sectors_written.isSet(sector)), + + // The first sector in every constants.message_size_max has been written: + .wal_prepares => { + if ((sector_start - zone.start()) % constants.message_size_max == 0) { + assert(self.sectors_written.isSet(sector)); + } else { + assert(!self.sectors_written.isSet(sector)); + } + }, + + // Nothing else has been written: + else => assert(!self.sectors_written.isSet(sector)), + } + } + } + }; +} + +pub fn slot_header(cluster: u128, slot: u64) Header.Prepare { + assert(slot < constants.journal_slot_count); + assert(slot * @sizeOf(Header.Prepare) < constants.journal_size_headers); + assert(slot * constants.message_size_max < constants.journal_size_prepares); + assert(@sizeOf(Header.Prepare) < constants.sector_size); + + return if (slot == 0) + Header.Prepare.root(cluster) + else + Header.Prepare.reserve(cluster, slot); +} + +test slot_header { + const allocator = std.testing.allocator; + + const header_buffer = try allocator.create(Header.Prepare); + defer allocator.destroy(header_buffer); + + for (0..constants.journal_slot_count) |slot| { + const header = slot_header(0, slot); + + try std.testing.expect(header.valid_checksum()); + try std.testing.expect(header.valid_checksum_body(&[0]u8{})); + try std.testing.expectEqual(header.invalid(), null); + try std.testing.expectEqual(header.cluster, 0); + try std.testing.expectEqual(header.op, slot); + try std.testing.expectEqual(header.size, @sizeOf(vsr.Header)); + try std.testing.expectEqual(header.command, .prepare); + if (slot == 0) { + try std.testing.expectEqual(header.operation, .root); + } else { + try std.testing.expectEqual(header.operation, .reserved); + } + } +} + +test "format" { + const Storage = @import("../testing/storage.zig").Storage; + const fixtures = @import("../testing/fixtures.zig"); + const allocator = std.testing.allocator; + const cluster = 0; + const replica = 1; + const replica_count = 1; + + var storage = try fixtures.init_storage(allocator, .{ + .size = data_file_size_min, + .iops_write_max = writes_max, + }); + defer storage.deinit(allocator); + + // Format only writes the minimum data needed. Verify it works with garbage in-between. + var prng = stdx.PRNG.from_seed_testing(); + prng.fill(storage.memory); + + try format(Storage, allocator, &storage, .{ + .cluster = cluster, + .release = vsr.Release.minimum, + .replica = replica, + .replica_count = replica_count, + .view = null, + }); + + // Verify the superblock headers. + var copy: u8 = 0; + while (copy < constants.superblock_copies) : (copy += 1) { + const superblock_header = storage.superblock_header(copy); + + try std.testing.expectEqual(superblock_header.copy, copy); + try std.testing.expectEqual(superblock_header.cluster, cluster); + try std.testing.expectEqual(superblock_header.sequence, 1); + try std.testing.expectEqual( + superblock_header.vsr_state.checkpoint.storage_size, + storage.size, + ); + try std.testing.expectEqual(superblock_header.vsr_state.checkpoint.header.op, 0); + try std.testing.expectEqual(superblock_header.vsr_state.commit_max, 0); + try std.testing.expectEqual(superblock_header.vsr_state.view, 0); + try std.testing.expectEqual(superblock_header.vsr_state.log_view, 0); + try std.testing.expectEqual( + superblock_header.vsr_state.replica_id, + superblock_header.vsr_state.members[replica], + ); + try std.testing.expectEqual(superblock_header.vsr_state.replica_count, replica_count); + } + + // Verify the WAL headers and prepares zones. + for (storage.wal_headers(), storage.wal_prepares(), 0..) |header, *message, slot| { + try std.testing.expect(std.meta.eql(header, message.header)); + + try std.testing.expect(header.valid_checksum()); + try std.testing.expect(header.valid_checksum_body(&[0]u8{})); + try std.testing.expectEqual(header.invalid(), null); + try std.testing.expectEqual(header.cluster, cluster); + try std.testing.expectEqual(header.op, slot); + try std.testing.expectEqual(header.size, @sizeOf(vsr.Header)); + try std.testing.expectEqual(header.command, .prepare); + if (slot == 0) { + try std.testing.expectEqual(header.operation, .root); + } else { + try std.testing.expectEqual(header.operation, .reserved); + } + } + + // Verify client replies. The contents are not zeroed. + try std.testing.expectEqual(storage.client_replies().len, constants.clients_max); + + // Verify grid alignment. The contents of the padding are not zeroed. + try std.testing.expect(vsr.Zone.grid.start() % constants.sector_size == 0); + + // Set the parts of the datafile that haven't been explicitly written to a known value for + // checksum comparision. + // + // 0xaa is used instead of 0, as originally it implicitly relied on testing storage being + // undefined. By keeping it the same, the verification checksum can stay the same. + for (0..constants.superblock_copies) |i| { + const unwritten_start = vsr.Zone.superblock.offset(i * superblock_copy_size) + + @sizeOf(SuperBlockHeader); + const unwritten_size = superblock_copy_size - @sizeOf(SuperBlockHeader); + @memset(storage.memory[unwritten_start..][0..unwritten_size], 0xaa); + } + for (0..constants.journal_slot_count) |slot| { + const unwritten_start = vsr.Zone.wal_prepares.offset(slot * constants.message_size_max) + + vsr.sector_ceil(@sizeOf(Header.Prepare)); + const unwritten_size = constants.message_size_max - + vsr.sector_ceil(@sizeOf(Header.Prepare)); + @memset(storage.memory[unwritten_start..][0..unwritten_size], 0xaa); + } + + // client_replies, grid_padding and the grid aren't formatted. Don't worry about the grid, as + // the storage size is set to data_file_size_min, which excludes it. + @memset( + storage.memory[vsr.Zone.client_replies.offset(0)..][0..vsr.Zone.client_replies.size().?], + 0, + ); + if (vsr.Zone.grid_padding.size().? > 0) { + @memset( + storage.memory[vsr.Zone.grid_padding.offset(0)..][0..vsr.Zone.grid_padding.size().?], + 0, + ); + } + + // Lastly, verify the entire storage contents against a known good checksum for the given + // cluster, replica and replica count. + // + // This doesn't match the output from `tigerbeetle format ...` since the testing storage / slot + // counts are lower. + try std.testing.expectEqual( + 339529914272821912685300045374558551362, + vsr.checksum(storage.memory), + ); +} diff --git a/ocam/src/vsr/replica_reformat.zig b/ocam/src/vsr/replica_reformat.zig new file mode 100644 index 00000000..9d83dbeb --- /dev/null +++ b/ocam/src/vsr/replica_reformat.zig @@ -0,0 +1,179 @@ +//! Replica recovery: Format a data file to replace one which was permanently lost. +//! +//! 1. The recovery process send `pipeline_prepare_queue_max` requests (1 register + many noops) to +//! the cluster. +//! 2. Once those have committed, it creates the new data file. The data file is identical to +//! `tigerbeetle format`'s output *except* that `vsr_state.view == client.view + 2` (where +//! `client.view` is the view number of the client at the end of committing the requests). +//! 3. The recovery process exits. Now running `tigerbeetle start` as normal will work. +//! +//! The `pipeline_prepare_queue_max` committed requests ensure that if the newly recovered replica +//! nacks uncommitted ops via a JV message, it is nacking ops which were definitely not received by +//! the previous version of the replica. +//! +//! The +2 is because: +//! - We don't want to join in the same view, since the replica might have participated in it before +//! being lost, and we can't remember any promises we made. +//! - Likewise, we don't want to go to view + 1 -- if we were the first to collect a EV quorum +//! before being lost, we might have sent a JV. Since we don't remember, we must skip past +//! `view + 1` to ensure that we don't send a different JV. (We have the invariant that if a +//! replica sends a JV for a given view, then all JV's it sends for that view will be +//! identical.) +const std = @import("std"); +const assert = std.debug.assert; + +const constants = @import("../constants.zig"); +const vsr = @import("../vsr.zig"); +const replica_format = @import("./replica_format.zig"); + +const log = std.log.scoped(.reformat); + +pub fn ReplicaReformatType( + comptime StateMachine: type, + comptime MessageBus: type, + comptime Storage: type, +) type { + const Client = vsr.ClientType(StateMachine.Operation, MessageBus); + const SuperBlock = vsr.SuperBlockType(Storage); + + return struct { + const ReplicaReformat = @This(); + + const Result = union(enum) { + failed: anyerror, + ok, + }; + + allocator: std.mem.Allocator, + options: SuperBlock.FormatOptions, + client: *Client, + storage: *Storage, + + requests_done: u32 = 0, + safe_view: ?u32 = null, + + pub fn init( + allocator: std.mem.Allocator, + client: *Client, + storage: *Storage, + options: SuperBlock.FormatOptions, + ) !ReplicaReformat { + assert(options.view == null); + assert(options.replica_count >= 3); + + return .{ + .allocator = allocator, + .options = options, + .client = client, + .storage = storage, + }; + } + + pub fn deinit(reformat: *ReplicaReformat, allocator: std.mem.Allocator) void { + _ = reformat; + _ = allocator; + } + pub fn done(reformat: *const ReplicaReformat) ?Result { + assert(reformat.requests_done <= constants.pipeline_prepare_queue_max); + return reformat.result; + } + + pub fn start(reformat: *ReplicaReformat) void { + assert(reformat.requests_done == 0); + const user_data = @intFromPtr(reformat); + reformat.client.register(client_register_callback, user_data); + } + + pub fn pending(reformat: *const ReplicaReformat) bool { + return reformat.safe_view == null; + } + + pub fn format(reformat: *ReplicaReformat) !void { + assert(reformat.safe_view != null); + const safe_view = reformat.safe_view.?; + reformat.safe_view = null; + + var options = reformat.options; + assert(options.view == null); + options.view = safe_view; + + try replica_format.format( + Storage, + reformat.allocator, + reformat.storage, + options, + ); + } + + fn client_register_callback( + user_data: u128, + register_result: *const vsr.RegisterResult, + ) void { + _ = register_result; + const reformat: *ReplicaReformat = @ptrFromInt(@as(usize, @intCast(user_data))); + assert(reformat.requests_done == 0); + assert(reformat.safe_view == null); + + log.debug("{}: register", .{reformat.options.replica}); + + reformat.requests_done += 1; + reformat.client_request(); + } + + fn client_request(reformat: *ReplicaReformat) void { + assert(reformat.safe_view == null); + assert(reformat.requests_done < constants.pipeline_prepare_queue_max); + + log.debug("{}: request start={}", .{ + reformat.options.replica, + reformat.requests_done, + }); + + const message = reformat.client.get_message().build(.request); + errdefer reformat.client.release_message(message.base()); + + message.header.* = .{ + .client = reformat.client.id, + .request = 0, // Set inside `raw_request`. + .cluster = reformat.client.cluster, + .command = .request, + .release = reformat.client.release, + .operation = .noop, + .size = @sizeOf(vsr.Header), + .previous_request_latency = 0, + }; + + const user_data = @intFromPtr(reformat); + reformat.client.raw_request(client_request_callback, user_data, message); + } + + fn client_request_callback( + user_data: u128, + operation: vsr.Operation, + timestamp: u64, + result: []align(constants.cache_line_size) const u8, + ) void { + assert(operation == .noop); + assert(timestamp > 0); + + const reformat: *ReplicaReformat = @ptrFromInt(@as(usize, @intCast(user_data))); + assert(reformat.requests_done > 0); + assert(reformat.requests_done < constants.pipeline_prepare_queue_max); + assert(reformat.safe_view == null); + assert(result.len == 0); + + log.debug("{}: request done={}", .{ + reformat.options.replica, + reformat.requests_done, + }); + + reformat.requests_done += 1; + if (reformat.requests_done == constants.pipeline_prepare_queue_max) { + // +2 since we might have sent a JV as part of +1 before we crashed. + reformat.safe_view = reformat.client.view + 2; + } else { + reformat.client_request(); + } + } + }; +} diff --git a/ocam/src/vsr/replica_test.zig b/ocam/src/vsr/replica_test.zig new file mode 100644 index 00000000..8da200f4 --- /dev/null +++ b/ocam/src/vsr/replica_test.zig @@ -0,0 +1,2964 @@ +const std = @import("std"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const log = std.log.scoped(.test_replica); +const expectEqual = std.testing.expectEqual; +const expect = std.testing.expect; +const allocator = std.testing.allocator; + +const stdx = @import("stdx"); +const constants = @import("../constants.zig"); +const vsr = @import("../vsr.zig"); +const fuzz = @import("../testing/fuzz.zig"); +const Process = @import("../testing/cluster/message_bus.zig").Process; +const Message = @import("../message_pool.zig").MessagePool.Message; +const MessageBuffer = @import("../message_buffer.zig").MessageBuffer; +const marks = @import("../testing/marks.zig"); +const StateMachineType = @import("../testing/state_machine.zig").StateMachineType; +const Cluster = @import("../testing/cluster.zig").ClusterType(StateMachineType); +const Release = @import("../testing/cluster.zig").Release; +const LinkFilter = @import("../testing/cluster/network.zig").LinkFilter; +const Network = @import("../testing/cluster/network.zig").Network; +const Ratio = stdx.PRNG.Ratio; + +const slot_count = constants.journal_slot_count; +const checkpoint_1 = vsr.Checkpoint.checkpoint_after(0); +const checkpoint_2 = vsr.Checkpoint.checkpoint_after(checkpoint_1); +const checkpoint_3 = vsr.Checkpoint.checkpoint_after(checkpoint_2); +const checkpoint_1_trigger = vsr.Checkpoint.trigger_for_checkpoint(checkpoint_1).?; +const checkpoint_2_trigger = vsr.Checkpoint.trigger_for_checkpoint(checkpoint_2).?; +const checkpoint_3_trigger = vsr.Checkpoint.trigger_for_checkpoint(checkpoint_3).?; +const checkpoint_1_prepare_max = vsr.Checkpoint.prepare_max_for_checkpoint(checkpoint_1).?; +const checkpoint_2_prepare_max = vsr.Checkpoint.prepare_max_for_checkpoint(checkpoint_2).?; +// No test is using this yet: +// const checkpoint_3_prepare_max = vsr.Checkpoint.prepare_max_for_checkpoint(checkpoint_3).?; +const checkpoint_1_prepare_ok_max = checkpoint_1_trigger + constants.pipeline_prepare_queue_max; +const checkpoint_2_prepare_ok_max = checkpoint_2_trigger + constants.pipeline_prepare_queue_max; + +const MiB = stdx.MiB; + +const log_level: std.log.Level = .err; + +const releases = [_]Release{ + .{ + .release = vsr.Release.from(.{ .major = 0, .minor = 0, .patch = 10 }), + .release_client_min = vsr.Release.from(.{ .major = 0, .minor = 0, .patch = 10 }), + }, + .{ + .release = vsr.Release.from(.{ .major = 0, .minor = 0, .patch = 20 }), + .release_client_min = vsr.Release.from(.{ .major = 0, .minor = 0, .patch = 10 }), + }, + .{ + .release = vsr.Release.from(.{ .major = 0, .minor = 0, .patch = 30 }), + .release_client_min = vsr.Release.from(.{ .major = 0, .minor = 0, .patch = 10 }), + }, +}; + +// TODO Detect when cluster has stabilized and stop run() early, rather than just running for a +// fixed number of ticks. + +comptime { + // The tests are written for these configuration values in particular. + assert(constants.journal_slot_count == 32); + assert(constants.lsm_compaction_ops == 4); +} + +test "Cluster: smoke" { + const t = try TestContext.init(.{ .replica_count = 1 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(checkpoint_2_trigger, checkpoint_2_trigger); + try expectEqual(t.replica(.R_).commit(), checkpoint_2_trigger); +} + +test "Cluster: recovery: WAL prepare corruption (R=3, corrupt right of head)" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + t.replica(.R_).stop(); + t.replica(.R0).corrupt(.{ .wal_prepare = 2 }); + + // 2/3 can't commit when 1/2 is status=recovering_head. + try t.replica(.R0).open(); + try expectEqual(t.replica(.R0).status(), .recovering_head); + try t.replica(.R1).open(); + try c.request(4, 0); + // With the aid of the last replica, the cluster can recover. + try t.replica(.R2).open(); + try c.request(4, 4); + try expectEqual(t.replica(.R_).commit(), 4); +} + +test "Cluster: recovery: WAL prepare corruption (R=3, corrupt left of head, 3/3 corrupt)" { + // The replicas recognize that the corrupt entry is outside of the pipeline and + // must be committed. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + t.replica(.R_).stop(); + t.replica(.R_).corrupt(.{ .wal_prepare = 1 }); + try t.replica(.R_).open(); + t.run(); + + // The same prepare is lost by all WALs, so the cluster can never recover. + // Each replica stalls trying to repair the header break. + try expectEqual(t.replica(.R_).status(), .view_change); + try expectEqual(t.replica(.R_).commit(), 0); +} + +test "Cluster: recovery: WAL prepare corruption (R=3, corrupt root)" { + // A replica can recover from a corrupt root prepare. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + t.replica(.R0).stop(); + t.replica(.R0).corrupt(.{ .wal_prepare = 0 }); + try t.replica(.R0).open(); + + try c.request(1, 1); + try expectEqual(t.replica(.R_).commit(), 1); + + const r0 = t.replica(.R0); + const r0_storage = &t.cluster.storages[r0.replicas.get(0)]; + try expect(!r0_storage.area_faulty(.{ .wal_prepares = .{ .slot = 0 } })); +} + +test "Cluster: recovery: WAL prepare corruption (R=3, corrupt checkpoint…head)" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + // Trigger the first checkpoint. + try c.request(checkpoint_1_trigger, checkpoint_1_trigger); + t.replica(.R0).stop(); + + // Corrupt op_checkpoint (27) and all ops that follow. + var slot: usize = slot_count - constants.lsm_compaction_ops - 1; + while (slot < slot_count) : (slot += 1) { + t.replica(.R0).corrupt(.{ .wal_prepare = slot }); + } + try t.replica(.R0).open(); + try expectEqual(t.replica(.R0).status(), .recovering_head); + + try c.request(slot_count, slot_count); + try expectEqual(t.replica(.R0).status(), .normal); + t.replica(.R1).stop(); + try c.request(slot_count + 1, slot_count + 1); +} + +test "Cluster: recovery: WAL prepare corruption (R=1, corrupt between checkpoint and head)" { + // R=1 can never recover if a WAL-prepare is corrupt. + const t = try TestContext.init(.{ .replica_count = 1 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + t.replica(.R0).stop(); + t.replica(.R0).corrupt(.{ .wal_prepare = 1 }); + if (t.replica(.R0).open()) { + unreachable; + } else |err| switch (err) { + error.WALCorrupt => {}, + else => unreachable, + } +} + +test "Cluster: recovery: WAL header corruption (R=1)" { + // R=1 locally repairs WAL-header corruption. + const t = try TestContext.init(.{ .replica_count = 1 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + t.replica(.R0).stop(); + t.replica(.R0).corrupt(.{ .wal_header = 1 }); + try t.replica(.R0).open(); + try c.request(3, 3); +} + +test "Cluster: recovery: WAL torn prepare, standby with intact prepare (R=1 S=1)" { + // R=1 recovers to find that its last prepare was a torn write, so it is truncated. + // The standby received the prepare, though. + // + // R=1 handles this by incrementing its view during recovery, so that the standby can truncate + // discard the truncated prepare. + const t = try TestContext.init(.{ + .replica_count = 1, + .standby_count = 1, + }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + t.replica(.R0).stop(); + t.replica(.R0).corrupt(.{ .wal_header = 2 }); + try t.replica(.R0).open(); + try c.request(3, 3); + try expectEqual(t.replica(.R0).commit(), 3); + try expectEqual(t.replica(.S0).commit(), 3); +} + +test "Cluster: recovery: grid corruption (disjoint)" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + + // Checkpoint to ensure that the replicas will actually use the grid to recover. + // All replicas must be at the same commit to ensure grid repair won't fail and + // fall back to state sync. + try c.request(checkpoint_1_trigger, checkpoint_1_trigger); + try expectEqual(t.replica(.R_).op_checkpoint(), checkpoint_1); + try expectEqual(t.replica(.R_).commit(), checkpoint_1_trigger); + + t.replica(.R_).stop(); + + // Corrupt the whole grid. + // Manifest blocks will be repaired as each replica opens its forest. + // Table index/filter/value blocks will be repaired as the replica commits/compacts. + for ([_]TestReplicas{ + t.replica(.R0), + t.replica(.R1), + t.replica(.R2), + }, 0..) |replica, i| { + const address_max = t.block_address_max(); + var address: u64 = 1 + i; // Addresses start at 1. + while (address <= address_max) : (address += 3) { + // Leave every third address un-corrupt. + // Each block exists intact on exactly one replica. + replica.corrupt(.{ .grid_block = address + 1 }); + replica.corrupt(.{ .grid_block = address + 2 }); + } + } + + try t.replica(.R_).open(); + t.run(); + + try expectEqual(t.replica(.R_).status(), .normal); + try expectEqual(t.replica(.R_).commit(), checkpoint_1_trigger); + try expectEqual(t.replica(.R_).op_checkpoint(), checkpoint_1); + + try c.request(checkpoint_2_trigger, checkpoint_2_trigger); + try expectEqual(t.replica(.R_).op_checkpoint(), checkpoint_2); + try expectEqual(t.replica(.R_).commit(), checkpoint_2_trigger); +} + +test "Cluster: recovery: recovering_head, outdated View" { + // 1. Wait for B1 to ok op=3. + // 2. Restart B1 while corrupting op=3, so that it gets into a .recovering_head with op=2. + // 3. Try make B1 forget about op=3 by delivering it an outdated View with op=2. + const t = try TestContext.init(.{ + .replica_count = 3, + }); + defer t.deinit(); + + var c = t.clients(.{}); + var a = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + try c.request(2, 2); + + b1.stop(); + b1.corrupt(.{ .wal_prepare = 2 }); + + try b1.open(); + try expectEqual(b1.status(), .recovering_head); + try expectEqual(b1.op_head(), 1); + + b1.record(.A0, .incoming, .view); + t.run(); + try expectEqual(b1.status(), .normal); + try expectEqual(b1.op_head(), 2); + + b2.drop_all(.R_, .bidirectional); + + try c.request(3, 3); + + b1.stop(); + b1.corrupt(.{ .wal_prepare = 3 }); + + try b1.open(); + try expectEqual(b1.status(), .recovering_head); + try expectEqual(b1.op_head(), 2); + + const mark = marks.check("ignoring (recovering_head, nonce mismatch)"); + a.stop(); + b1.replay_recorded(); + t.run(); + + try expectEqual(b1.status(), .recovering_head); + try expectEqual(b1.op_head(), 2); + + // Should B1 erroneously accept op=2 as head, unpartitioning B2 here would lead to a data loss. + b2.pass_all(.R_, .bidirectional); + t.run(); + try a.open(); + try c.request(4, 4); + try mark.expect_hit(); +} + +test "Cluster: recovery: recovering head: idle cluster" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + var b = t.replica(.B1); + + try c.request(2, 2); + + b.stop(); + b.corrupt(.{ .wal_prepare = 3 }); + b.corrupt(.{ .wal_header = 3 }); + + try b.open(); + try expectEqual(b.status(), .recovering_head); + try expectEqual(b.op_head(), 2); + + t.run(); + + try expectEqual(b.status(), .normal); + try expectEqual(b.op_head(), 2); +} + +test "Cluster: recovery: reformat unrecoverable replica" { + for ([_]u64{ + // The cluster is still within the first checkpoint. + // The recovering replica just needs to load a View and then it can repair. + 5, + // The cluster is ahead of the initial checkpoint. + // The recovering replica needs to state sync via View. + checkpoint_2, + }) |op_max| { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + var b = t.replica(.B1); + + try c.request(op_max, op_max); + + b.stop(); + try b.open_reformat(); + t.run(); + try expectEqual(b.health(), .up); + + try expectEqual(b.status(), .normal); + // +pipeline since the reformatted replica pulses noop requests. + try expectEqual(b.op_head(), op_max + constants.pipeline_prepare_queue_max); + } +} + +test "Cluster: recovery: reformat unrecoverable replica: too many faults" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + try c.request(3, 3); + + b1.stop(); + b2.stop(); + + // Restart A0 to force it out of normal mode. + // Otherwise it would just share a View, repairing the recovering replicas. + a0.stop(); + try a0.open(); + + try b1.open_reformat(); + t.run(); + try expectEqual(b1.health(), .reformatting); + + try b2.open_reformat(); + t.run(); + try expectEqual(b1.health(), .reformatting); + + t.run(); + + // There were too many faults, so the cluster (safely) remains unavailable. + try expectEqual(b1.health(), .reformatting); + try expectEqual(b1.health(), .reformatting); +} + +test "Cluster: network: partition 2-1 (isolate backup, symmetric)" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + t.replica(.B2).drop_all(.__, .bidirectional); + try c.request(3, 3); + try expectEqual(t.replica(.A0).commit(), 3); + try expectEqual(t.replica(.B1).commit(), 3); + try expectEqual(t.replica(.B2).commit(), 2); +} + +test "Cluster: network: partition 2-1 (isolate backup, asymmetric, send-only)" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + t.replica(.B2).drop_all(.__, .incoming); + try c.request(3, 3); + try expectEqual(t.replica(.A0).commit(), 3); + try expectEqual(t.replica(.B1).commit(), 3); + try expectEqual(t.replica(.B2).commit(), 2); +} + +test "Cluster: network: partition 2-1 (isolate backup, asymmetric, receive-only)" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + t.replica(.B2).drop_all(.__, .outgoing); + try c.request(3, 3); + try expectEqual(t.replica(.A0).commit(), 3); + try expectEqual(t.replica(.B1).commit(), 3); + // B2 may commit some ops, but at some point is will likely fall behind. + // Prepares may be reordered by the network, and if B1 receives X+1 then X, + // it will not forward X on, as it is a "repair". + // And B2 is partitioned, so it cannot repair its hash chain. + try expect(t.replica(.B2).commit() >= 2); +} + +test "Cluster: network: partition 1-2 (isolate primary, symmetric)" { + // The primary cannot communicate with either backup, but the backups can communicate with one + // another. The backups will perform a view-change since they don't receive heartbeats. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + + const p = t.replica(.A0); + p.drop_all(.B1, .bidirectional); + p.drop_all(.B2, .bidirectional); + try c.request(3, 3); + try expectEqual(p.commit(), 2); +} + +test "Cluster: network: partition 1-2 (isolate primary, asymmetric, send-only)" { + // The primary can send to the backups, but not receive. + // After a short interval of not receiving messages (specifically prepare_ok's) it will abdicate + // by pausing heartbeats, allowing the next replica to take over as primary. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(1, 1); + t.replica(.A0).drop_all(.B1, .incoming); + t.replica(.A0).drop_all(.B2, .incoming); + const mark = marks.check("send_commit: primary abdicating"); + try c.request(2, 2); + try mark.expect_hit(); +} + +test "Cluster: network: partition 1-2 (isolate primary, asymmetric, receive-only)" { + // The primary can receive from the backups, but not send to them. + // The backups will perform a view-change since they don't receive heartbeats. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(1, 1); + t.replica(.A0).drop_all(.B1, .outgoing); + t.replica(.A0).drop_all(.B2, .outgoing); + try c.request(2, 2); +} + +test "Cluster: network: partition primary-all (isolate primary, asymmetric, send-only)" { + // The primary can send to the backups and clients, but not receive. + // Since primary can't see requests, it doesn't know that it needs to abdicate. + // The rest of the cluster needs to view-change anyway. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(1, 1); + t.replica(.A0).drop(.__, .incoming, .request); + // Since the primary doesn't receive requests, it can't ever abdicate. + const mark = marks.check("send_commit: primary abdicating"); + // TODO: + // try c.request(2, 2); + try c.request(2, 1); + try mark.expect_not_hit(); +} + +test "Cluster: network: partition client-primary (symmetric)" { + // Clients cannot communicate with the primary, but they still request/reply via a backup. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + + t.replica(.A0).drop_all(.C_, .bidirectional); + try c.request(1, 1); +} + +test "Cluster: network: partition client-primary (asymmetric, drop requests)" { + // Primary cannot receive messages from the clients. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + + t.replica(.A0).drop_all(.C_, .incoming); + try c.request(1, 1); +} + +test "Cluster: network: partition client-primary (asymmetric, drop replies)" { + // Clients cannot receive replies from the primary, but they receive replies from a backup. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + + t.replica(.A0).drop_all(.C_, .outgoing); + try c.request(1, 1); +} + +test "Cluster: network: partition flexible quorum" { + // Two out of four replicas should be able to carry on as long the pair includes the primary. + const t = try TestContext.init(.{ .replica_count = 4 }); + defer t.deinit(); + + var c = t.clients(.{}); + + t.run(); + t.replica(.B2).stop(); + t.replica(.B3).stop(); + for (0..3) |_| t.run(); // Give enough time for the clocks to desync. + + try c.request(4, 4); +} + +test "Cluster: network: primary no clock sync" { + // When primary can't accept requests because the clock is not synchronized, it must proactively + // abdicate (the rest of the cluster doesn't know that there are dropped requests). + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(3, 3); + const a0 = t.replica(.A0); + try expectEqual(a0.role(), .primary); + try expectEqual(a0.commit(), 3); + + a0.drop(.R_, .incoming, .pong); + for (0..3) |_| t.run(); // Give enough time for the clocks to desync. + + try expectEqual(a0.role(), .primary); + const mark = marks.check("send_commit: primary abdicating"); + try c.request(5, 5); + try mark.expect_hit(); + try expectEqual(a0.role(), .backup); + try expectEqual(t.replica(.R_).commit(), 5); +} + +test "Cluster: repair: partition 2-1, then backup fast-forward 1 checkpoint" { + // A backup that has fallen behind by two checkpoints can catch up, without using state sync. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(3, 3); + try expectEqual(t.replica(.R_).commit(), 3); + + var r_lag = t.replica(.B2); + r_lag.stop(); + + // Commit enough ops to checkpoint once, and then nearly wrap around, leaving enough slack + // that the lagging backup can repair (without state sync). + const commit = 3 + slot_count - constants.pipeline_prepare_queue_max; + try c.request(commit, commit); + try expectEqual(t.replica(.A0).op_checkpoint(), checkpoint_1); + try expectEqual(t.replica(.B1).op_checkpoint(), checkpoint_1); + + try r_lag.open(); + try expectEqual(r_lag.status(), .normal); + try expectEqual(r_lag.op_checkpoint(), 0); + + // Allow repair, but check that state sync doesn't run. + const mark = marks.check("sync started"); + t.run(); + try mark.expect_not_hit(); + + try expectEqual(t.replica(.R_).status(), .normal); + try expectEqual(t.replica(.R_).op_checkpoint(), checkpoint_1); + try expectEqual(t.replica(.R_).commit(), commit); +} + +test "Cluster: repair: view-change, new-primary lagging behind checkpoint, forfeit" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + try expectEqual(t.replica(.R_).commit(), 2); + + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + b1.drop_all(.__, .bidirectional); + + try c.request(checkpoint_1_prepare_max + 1, checkpoint_1_prepare_max + 1); + try expectEqual(a0.op_checkpoint(), checkpoint_1); + try expectEqual(b1.op_checkpoint(), 0); + try expectEqual(b2.op_checkpoint(), checkpoint_1); + try expectEqual(a0.commit(), checkpoint_1_prepare_max + 1); + try expectEqual(b1.commit(), 2); + try expectEqual(b2.commit(), checkpoint_1_prepare_max + 1); + try expectEqual(a0.op_head(), checkpoint_1_prepare_max + 1); + try expectEqual(b1.op_head(), 2); + try expectEqual(b2.op_head(), checkpoint_1_prepare_max + 1); + + // Partition the primary, but restore B1. B1 will attempt to become the primary next, + // but it is too far behind, so B2 becomes the new primary instead. + b2.pass_all(.__, .bidirectional); + b1.pass_all(.__, .bidirectional); + a0.drop_all(.__, .bidirectional); + // TODO: make sure that B1 uses WAL repair rather than state sync here. + const mark = marks.check("on_join_view: lagging primary; forfeiting"); + t.run(); + try mark.expect_hit(); + + try expectEqual(b2.role(), .primary); + try expectEqual(b2.index(), t.replica(.A0).index()); + try expectEqual(b2.view(), b1.view()); + try expectEqual(b2.log_view(), b1.log_view()); + + // Thanks to the new primary, the lagging backup is able to catch up to the latest + // checkpoint/commit. + try expectEqual(b1.role(), .backup); + try expectEqual(b1.commit(), checkpoint_1_prepare_max + 1); + try expectEqual(b1.op_checkpoint(), checkpoint_1); + + try expectEqual(t.replica(.R_).commit(), checkpoint_1_prepare_max + 1); +} + +test "Cluster: repair: crash, corrupt committed pipeline op, repair it, view-change; dont nack" { + // This scenario is also applicable when any op within the pipeline suffix is corrupted. + // But we test by corrupting the last op to take advantage of recovering_head to learn the last + // op's header without its prepare. + // + // Also, a corrupt last op maximizes uncertainty — there are no higher ops which + // can definitively show that the last op is committed (via `header.commit`). + const t = try TestContext.init(.{ + .replica_count = 3, + .client_count = constants.pipeline_prepare_queue_max, + }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + b2.drop_all(.R_, .bidirectional); + + try c.request(4, 4); + + b1.stop(); + b1.corrupt(.{ .wal_prepare = 4 }); + + // We can't learn op=4's prepare, only its header (via View). + b1.drop(.R_, .bidirectional, .prepare); + try b1.open(); + try expectEqual(b1.status(), .recovering_head); + t.run(); + + b1.pass_all(.R_, .bidirectional); + b2.pass_all(.R_, .bidirectional); + a0.stop(); + a0.drop_all(.R_, .outgoing); + t.run(); + + // The cluster is stuck trying to repair op=4 (requesting the prepare). + // B2 can nack op=4, but B1 *must not*. + try expectEqual(b1.status(), .view_change); + try expectEqual(b1.commit(), 3); + try expectEqual(b1.op_head(), 4); + + // A0 provides prepare=4. + a0.pass_all(.R_, .outgoing); + try a0.open(); + t.run(); + try expectEqual(t.replica(.R_).status(), .normal); + try expectEqual(t.replica(.R_).commit(), 4); + try expectEqual(t.replica(.R_).op_head(), 4); +} + +test "Cluster: repair: corrupt reply" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + try expectEqual(t.replica(.R_).commit(), 2); + + // Prevent any view changes, to ensure A0 repairs its corrupt prepare. + t.replica(.R_).drop(.R_, .bidirectional, .join_view); + + // Block the client from seeing the reply from the cluster. + t.replica(.R_).drop(.C_, .outgoing, .reply); + try c.request(3, 2); + + // Corrupt all of the primary's saved replies. + // (This is easier than figuring out the reply's actual slot.) + var slot: usize = 0; + while (slot < constants.clients_max) : (slot += 1) { + t.replica(.A0).corrupt(.{ .client_reply = slot }); + } + + // The client will keep retrying request 3 until it receives a reply. + // The primary requests the reply from one of its backups. + // (Pass A0 only to ensure that no other client forwards the reply.) + t.replica(.A0).pass(.C_, .outgoing, .reply); + t.run(); + + try expectEqual(c.replies(), 3); +} + +test "Cluster: repair: ack committed prepare" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + try expectEqual(t.replica(.R_).commit(), 2); + + const p = t.replica(.A0); + const b1 = t.replica(.B1); + const b2 = t.replica(.B2); + + // A0 commits 3. + // B1 prepares 3, but does not commit. + t.replica(.R_).drop(.R_, .bidirectional, .exit_view); + t.replica(.R_).drop(.R_, .bidirectional, .join_view); + p.drop(.__, .outgoing, .commit); + b2.drop(.__, .incoming, .prepare); + try c.request(3, 3); + try expectEqual(p.commit(), 3); + try expectEqual(b1.commit(), 2); + try expectEqual(b2.commit(), 2); + + try expectEqual(p.op_head(), 3); + try expectEqual(b1.op_head(), 3); + try expectEqual(b2.op_head(), 2); + + try expectEqual(p.status(), .normal); + try expectEqual(b1.status(), .normal); + try expectEqual(b2.status(), .normal); + + // Change views. B1/B2 participate. Don't allow B2 to repair op=3. + try expectEqual(p.role(), .primary); + t.replica(.R_).pass(.R_, .bidirectional, .exit_view); + t.replica(.R_).pass(.R_, .bidirectional, .join_view); + p.drop(.__, .bidirectional, .prepare); + p.drop(.__, .bidirectional, .join_view); + p.drop(.__, .bidirectional, .exit_view); + t.run(); + try expectEqual(b1.commit(), 2); + try expectEqual(b2.commit(), 2); + try expectEqual(p.role(), .backup); + + try expectEqual(p.status(), .normal); + try expectEqual(b1.status(), .normal); + try expectEqual(b2.status(), .normal); + + // But other than that, heal A0/B1, but partition B2 completely. + // (Prevent another view change.) + p.pass_all(.__, .bidirectional); + b1.pass_all(.__, .bidirectional); + b2.drop_all(.__, .bidirectional); + t.replica(.R_).drop(.R_, .bidirectional, .exit_view); + t.replica(.R_).drop(.R_, .bidirectional, .join_view); + t.run(); + + try expectEqual(p.status(), .normal); + try expectEqual(b1.status(), .normal); + try expectEqual(b2.status(), .normal); + + // A0 acks op=3 even though it already committed it. + try expectEqual(p.commit(), 3); + try expectEqual(b1.commit(), 3); + try expectEqual(b2.commit(), 2); +} + +test "Cluster: repair: primary checkpoint, backup crash before checkpoint, primary prepare" { + // 1. Given 3 replica: A0, B1, B2. + // 2. B2 is partitioned (for the entire scenario). + // 3. A0 and B1 prepare and commit many messages... + // 4. A0 commits a checkpoint trigger and checkpoints. + // 5. B1 crashes before it can commit the trigger or checkpoint. + // 6. A0 prepares a message. + // 7. B1 restarts. The very first entry in its WAL is corrupt. + // A0 has *not* already overwritten the corresponding entry in its own WAL, thanks to the + // pipeline component of the vsr_checkpoint_ops. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + var p = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + // B2 does not participate in this scenario. + b2.stop(); + try c.request(checkpoint_1_trigger - 1, checkpoint_1_trigger - 1); + + b1.drop(.R_, .incoming, .commit); + try c.request(checkpoint_1_trigger, checkpoint_1_trigger); + try expectEqual(p.op_checkpoint(), checkpoint_1); + try expectEqual(b1.op_checkpoint(), 0); + try expectEqual(p.commit(), checkpoint_1_trigger); + try expectEqual(b1.commit(), checkpoint_1_trigger - 1); + + b1.pass(.R_, .incoming, .commit); + b1.stop(); + b1.corrupt(.{ .wal_prepare = 1 }); + try c.request( + checkpoint_1_trigger + constants.pipeline_prepare_queue_max, + checkpoint_1_trigger, + ); + try b1.open(); + t.run(); + + try expectEqual(p.op_checkpoint(), checkpoint_1); + try expectEqual(b1.op_checkpoint(), checkpoint_1); + try expectEqual(p.commit(), checkpoint_1_trigger + constants.pipeline_prepare_queue_max); + try expectEqual(b1.commit(), checkpoint_1_trigger + constants.pipeline_prepare_queue_max); +} + +test "Cluster: view-change: JV, 1+1/2 faulty header stall, 2+1/3 faulty header succeed" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + try expectEqual(t.replica(.R_).commit(), 2); + + t.replica(.R0).stop(); + try c.request(4, 4); + t.replica(.R1).stop(); + t.replica(.R2).stop(); + + t.replica(.R1).corrupt(.{ .wal_prepare = 3 }); + + // The nack quorum size is 2. + // The new view must determine whether op=3 is possibly committed. + // - R0 never received op=3 (it had already crashed), so it nacks. + // - R1 did receive op=3, but upon recovering its WAL, it was corrupt, so it cannot nack. + // The cluster must wait form R2 before recovering. + try t.replica(.R0).open(); + try t.replica(.R1).open(); + const mark = marks.check("quorum received, awaiting repair"); + t.run(); + try expectEqual(t.replica(.R0).status(), .view_change); + try expectEqual(t.replica(.R1).status(), .view_change); + try mark.expect_hit(); + + // R2 provides the missing header, allowing the view-change to succeed. + try t.replica(.R2).open(); + t.run(); + try expectEqual(t.replica(.R_).status(), .normal); + try expectEqual(t.replica(.R_).commit(), 4); +} + +test "Cluster: view-change: JV, 2/3 faulty header stall" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + + t.replica(.R0).stop(); + try c.request(3, 3); + t.replica(.R1).stop(); + t.replica(.R2).stop(); + + t.replica(.R1).corrupt(.{ .wal_prepare = 2 }); + t.replica(.R2).corrupt(.{ .wal_prepare = 2 }); + + try t.replica(.R_).open(); + const mark = marks.check("quorum received, deadlocked"); + t.run(); + try expectEqual(t.replica(.R_).status(), .view_change); + try mark.expect_hit(); +} + +test "Cluster: view-change: duel of the primaries" { + // In a cluster of 3, one replica gets partitioned away, and the remaining two _both_ become + // primaries (for different views). Additionally, the primary from the higher view is + // abdicating. The primaries should figure out that they need to view-change to a higher view. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(2, 2); + try expectEqual(t.replica(.R_).commit(), 2); + + try expectEqual(t.replica(.R_).view(), 1); + try expectEqual(t.replica(.R1).role(), .primary); + + t.replica(.R2).drop_all(.R_, .bidirectional); + t.replica(.R1).drop(.R_, .outgoing, .commit); + try c.request(3, 3); + + try expectEqual(t.replica(.R0).commit_max(), 2); + try expectEqual(t.replica(.R1).commit_max(), 3); + try expectEqual(t.replica(.R2).commit_max(), 2); + + t.replica(.R0).pass_all(.R_, .bidirectional); + t.replica(.R2).pass_all(.R_, .bidirectional); + t.replica(.R1).drop_all(.R_, .bidirectional); + t.replica(.R2).drop(.R0, .bidirectional, .prepare_ok); + t.replica(.R2).drop(.R0, .outgoing, .join_view); + t.run(); + + // The stage is set: we have two primaries in different views, R2 is about to abdicate. + try expectEqual(t.replica(.R1).view(), 1); + try expectEqual(t.replica(.R1).status(), .normal); + try expectEqual(t.replica(.R1).role(), .primary); + try expectEqual(t.replica(.R1).commit(), 3); + try expectEqual(t.replica(.R2).op_head(), 3); + + try expectEqual(t.replica(.R2).view(), 2); + try expectEqual(t.replica(.R2).status(), .normal); + try expectEqual(t.replica(.R2).role(), .primary); + try expectEqual(t.replica(.R2).commit(), 2); + try expectEqual(t.replica(.R2).op_head(), 3); + + t.replica(.R1).pass_all(.R_, .bidirectional); + t.replica(.R2).pass_all(.R_, .bidirectional); + t.replica(.R0).drop_all(.R_, .bidirectional); + t.run(); + + try expectEqual(t.replica(.R1).commit(), 3); + try expectEqual(t.replica(.R2).commit(), 3); +} + +test "Cluster: view_change: lagging replica advances checkpoint during view change" { + // It could be the case that the replica with the most advanced checkpoint has its checkpoint + // corrupted. In this case, a replica with a slightly older checkpoint must step up as primary. + + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + b2.stop(); + + // Ensure b1 only commits up till checkpoint_2_trigger - 1, so it stays at checkpoint_1 while + // a0 moves to checkpoint_2. + try c.request(checkpoint_2_trigger - 1, checkpoint_2_trigger - 1); + b1.drop(.R_, .incoming, .commit); + try c.request(checkpoint_2_trigger, checkpoint_2_trigger); + + try expectEqual(a0.commit(), checkpoint_2_trigger); + try expectEqual(a0.op_checkpoint(), checkpoint_2); + try expectEqual(b1.commit(), checkpoint_2_trigger - 1); + try expectEqual(b1.op_checkpoint(), checkpoint_1); + + b1.stop(); + + try b2.open(); + // Don't allow b2 to repair its grid, otherwise it could help a0 commit past op_prepare_max for + // checkpoint_2. + b2.drop(.R_, .incoming, .block); + + t.run(); + + try expectEqual(b2.op_checkpoint(), checkpoint_2); + try expectEqual(b2.commit_max(), checkpoint_2_trigger); + try expectEqual(b2.status(), .normal); + + // Progress a0 & b2's head past op_prepare_ok_max for checkpoint_2 (commit_max stays at + // op_prepare_ok_max since a syncing replica's don't prepare_ok ops past prepare_ok_max). + try c.request( + checkpoint_2_prepare_max, + checkpoint_2_prepare_ok_max, + ); + + try expectEqual(a0.op_checkpoint(), checkpoint_2); + try expectEqual(a0.commit_max(), checkpoint_2_prepare_ok_max); + + try expectEqual(b2.op_checkpoint(), checkpoint_2); + try expectEqual(b2.commit_max(), checkpoint_2_prepare_ok_max); + + b2.stop(); + + a0.stop(); + // Drop incoming JVs to a0 to check if b1 steps up as primary. + a0.drop(.R_, .incoming, .join_view); + try a0.open(); + + try b1.open(); + b1.pass(.R_, .incoming, .commit); + + t.run(); + + try expectEqual(a0.status(), .normal); + try expectEqual(a0.op_checkpoint(), checkpoint_2); + + // b1 is able to advance its checkpoint during view change and become primary. + try expectEqual(b1.role(), .primary); + try expectEqual(b1.status(), .normal); + try expectEqual(b1.op_checkpoint(), checkpoint_2); +} + +test "Cluster: view-change: primary with dirty log" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + // Commit past the checkpoint_2_trigger to ensure that the op we will corrupt won't be found in + // B1's pipeline cache. + const commit_max = checkpoint_2_trigger + + constants.pipeline_prepare_queue_max + + constants.pipeline_request_queue_max; + + // Partition B2 so that it falls behind the cluster. + b2.drop_all(.R_, .bidirectional); + try c.request(commit_max, commit_max); + + // Allow B2 to join the cluster and complete state sync. + b2.pass_all(.R_, .bidirectional); + t.run(); + + try expectEqual(t.replica(.R_).commit(), commit_max); + try TestReplicas.expect_sync_done(t.replica(.R_)); + + // Crash A0, and force B2 to become the primary. + a0.stop(); + b1.drop(.__, .incoming, .join_view); + + // B2 tries to become primary. (Don't let B1 become primary – it would not realize its + // checkpoint entry is corrupt, which would defeat the purpose of this test). + // B2 tries to repair (get_prepare) this corrupt op, even though it is before its + // checkpoint. B1 discovers that this op is corrupt, and marks it as faulty. + b1.corrupt(.{ .wal_prepare = checkpoint_2 % slot_count }); + t.run(); + + try expectEqual(b1.status(), .normal); + try expectEqual(b2.status(), .normal); +} + +test "Cluster: view-change: nack older view" { + // a0 prepares (but does not commit) three ops (`x`, `x + 1`, `x + 2`) at view `v`. + // b1 prepares (but does not commit) the same ops at view `v + 1`. + // b2 receives only `x + 2` op prepared at b1. + // b1 gets permanently partitioned from the cluster, and a0 and b2 form a core. + // + // a0 and b2 and should be able to truncate all the prepared, but uncommitted ops. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(checkpoint_1_trigger, checkpoint_1_trigger); + try expectEqual(t.replica(.R_).commit(), checkpoint_1_trigger); + + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + try expectEqual(a0.role(), .primary); + t.replica(.R_).drop_all(.R_, .bidirectional); + try c.request(checkpoint_1_trigger + 3, checkpoint_1_trigger); + try expectEqual(a0.op_head(), checkpoint_1_trigger + 3); + + t.replica(.R_).pass(.R_, .bidirectional, .ping); + t.replica(.R_).pass(.R_, .bidirectional, .pong); + b1.pass(.R_, .bidirectional, .exit_view); + b1.pass(.R_, .incoming, .join_view); + b1.pass(.R_, .outgoing, .view); + a0.drop_all(.R_, .bidirectional); + b2.pass(.R_, .incoming, .prepare); + b2.drop_fn(.R_, .incoming, struct { + fn drop_message(message: *const Message) bool { + const header = message.header.into(.prepare) orelse return false; + return header.op < checkpoint_1_trigger + 3; + } + }.drop_message); + + t.run(); + try expectEqual(b1.role(), .primary); + try expectEqual(b1.status(), .normal); + + try expectEqual(t.replica(.R_).op_head(), checkpoint_1_trigger + 3); + try expectEqual(t.replica(.R_).commit_max(), checkpoint_1_trigger); + + a0.pass_all(.R_, .bidirectional); + b2.pass_all(.R_, .bidirectional); + b2.drop_fn(.R_, .incoming, null); + b1.drop_all(.R_, .bidirectional); + + try c.request(checkpoint_1_trigger + 3, checkpoint_1_trigger + 3); + try expectEqual(b2.commit_max(), checkpoint_1_trigger + 3); + try expectEqual(a0.commit_max(), checkpoint_1_trigger + 3); + try expectEqual(b1.commit_max(), checkpoint_1_trigger); +} + +test "Cluster: sync: partition, lag, sync (transition from idle)" { + for ([_]u64{ + // Normal case: the cluster has prepared beyond the checkpoint. + // The lagging replica can learn the latest checkpoint from a commit message. + checkpoint_2_prepare_max + 1, + // Idle case: the idle cluster has not prepared beyond the checkpoint. + // The lagging replica is far enough behind the cluster that it can sync to the latest + // checkpoint anyway, since it cannot possibly recover via WAL repair. + checkpoint_2_prepare_max, + }) |cluster_commit_max| { + log.info("test cluster_commit_max={}", .{cluster_commit_max}); + + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + + t.replica(.R2).drop_all(.R_, .bidirectional); + try c.request(cluster_commit_max, cluster_commit_max); + + t.replica(.R2).pass_all(.R_, .bidirectional); + t.run(); + + // R2 catches up via state sync. + try expectEqual(t.replica(.R_).status(), .normal); + try expectEqual(t.replica(.R_).commit(), cluster_commit_max); + try expectEqual(t.replica(.R_).sync_status(), .idle); + + // The entire cluster is healthy and able to commit more. + try c.request(checkpoint_3_trigger, checkpoint_3_trigger); + try expectEqual(t.replica(.R_).status(), .normal); + try expectEqual(t.replica(.R_).commit(), checkpoint_3_trigger); + + t.run(); // (Wait for grid sync to finish.) + try TestReplicas.expect_sync_done(t.replica(.R_)); + } +} + +test "Cluster: repair: R=2 (primary checkpoints, but backup lags behind)" { + const t = try TestContext.init(.{ .replica_count = 2 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(checkpoint_1_trigger - 1, checkpoint_1_trigger - 1); + + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + + // A0 prepares the trigger op, commits it, and checkpoints. + // B1 prepares the trigger op, but does not commit/checkpoint. + b1.drop(.R_, .incoming, .commit); // Prevent last commit. + try c.request(checkpoint_1_trigger, checkpoint_1_trigger); + try expectEqual(a0.commit(), checkpoint_1_trigger); + try expectEqual(b1.commit(), checkpoint_1_trigger - 1); + try expectEqual(a0.op_head(), checkpoint_1_trigger); + try expectEqual(b1.op_head(), checkpoint_1_trigger); + try expectEqual(a0.op_checkpoint(), checkpoint_1); + try expectEqual(b1.op_checkpoint(), 0); + + // On B1, corrupt the same slot that A0 is about to overwrite with a new prepare. + // (B1 doesn't have any prepare in this slot, thanks to the vsr_checkpoint_ops.) + b1.stop(); + b1.pass(.R_, .incoming, .commit); + b1.corrupt(.{ .wal_prepare = (checkpoint_1_trigger + 2) % slot_count }); + + // Prepare a full pipeline of ops. Since B1 is still lagging behind, this doesn't actually + // overwrite any entries from the previous wrap. + const pipeline_prepare_queue_max = constants.pipeline_prepare_queue_max; + try c.request(checkpoint_1_trigger + pipeline_prepare_queue_max, checkpoint_1_trigger); + + try b1.open(); + t.run(); + + try expectEqual(t.replica(.R_).commit(), checkpoint_1_trigger + pipeline_prepare_queue_max); + try expectEqual(c.replies(), checkpoint_1_trigger + pipeline_prepare_queue_max); + + // Neither replica used state sync, but it is "done" since all content is present. + try TestReplicas.expect_sync_done(t.replica(.R_)); +} + +test "Cluster: sync: R=4, 2/4 ahead + idle, 2/4 lagging, sync" { + const t = try TestContext.init(.{ .replica_count = 4 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(1, 1); + try expectEqual(t.replica(.R_).commit(), 1); + + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + var b3 = t.replica(.B3); + + b2.stop(); + b3.stop(); + + try c.request(checkpoint_2_trigger, checkpoint_2_trigger); + try expectEqual(a0.status(), .normal); + try expectEqual(b1.status(), .normal); + + try b2.open(); + try b3.open(); + t.run(); + t.run(); + + try expectEqual(t.replica(.R_).status(), .normal); + try expectEqual(t.replica(.R_).sync_status(), .idle); + try expectEqual(t.replica(.R_).commit(), checkpoint_2_trigger); + try expectEqual(t.replica(.R_).op_checkpoint(), checkpoint_2); + + try TestReplicas.expect_sync_done(t.replica(.R_)); +} + +test "Cluster: sync: view-change with lagging replica" { + // Check that a cluster can view change even if view-change quorum contains syncing replicas. + // This used to be a special case for an older sync protocol, but now this mostly holds by + // construction. + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(1, 1); // Make sure that the logic doesn't depend on the root prepare. + try expectEqual(t.replica(.R_).commit(), 1); + + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + b2.drop_all(.R_, .bidirectional); // Isolate B2. + try c.request(checkpoint_2_trigger, checkpoint_2_trigger); + + // Allow B2 to join, but partition A0 to force a view change. + // B2 is lagging far enough behind that it must state sync. + // Despite this, the cluster of B1/B2 should recover to normal status. + b2.pass_all(.R_, .bidirectional); + a0.drop_all(.R_, .bidirectional); + + // Let the cluster run for some time without B2 state syncing. + b2.drop(.R_, .bidirectional, .view); + t.run(); + try expectEqual(b2.status(), .view_change); + try expectEqual(b2.op_checkpoint(), 0); + try c.request(checkpoint_2_trigger + 1, checkpoint_2_trigger); // Cluster is blocked. + + // Let B2 state sync. This unblocks the cluster. + b2.pass(.R_, .bidirectional, .view); + t.run(); + try expectEqual(b1.role(), .primary); + try expectEqual(t.replica(.R_).status(), .normal); + try expectEqual(t.replica(.R_).sync_status(), .idle); + try expect(b2.commit() >= checkpoint_2_trigger); + try expectEqual(t.replica(.R_).op_checkpoint(), checkpoint_2); + + // Note: we need to commit more --- state sync status is cleared only at checkpoint. + try c.request(checkpoint_3_trigger, checkpoint_3_trigger); + try TestReplicas.expect_sync_done(t.replica(.R_)); +} + +test "Cluster: sync: slightly lagging replica" { + // Sometimes a replica must switch to state sync even if it is within journal_slot_count + // ops from commit_max. Checkpointed ops are not repaired and might become unavailable. + + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(checkpoint_1 - 1, checkpoint_1 - 1); + + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + b2.drop_all(.R_, .bidirectional); + try c.request(checkpoint_1_trigger + 1, checkpoint_1_trigger + 1); + + // Corrupt all copies of a checkpointed prepare. + a0.corrupt(.{ .wal_prepare = checkpoint_1 }); + b1.corrupt(.{ .wal_prepare = checkpoint_1 }); + try c.request(checkpoint_1_prepare_max + 1, checkpoint_1_prepare_max + 1); + + // At this point, b2 won't be able to repair WAL and must state sync. + b2.pass_all(.R_, .bidirectional); + try c.request(checkpoint_1_prepare_max + 2, checkpoint_1_prepare_max + 2); + try expectEqual(t.replica(.R_).commit(), checkpoint_1_prepare_max + 2); +} + +test "Cluster: sync: using View from durable checkpoint" { + // Primary sends a View message to backups when a checkpoint becomes durable. A lagging backup + // must use this View message to state sync to the checkpoint. + + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + // Run for a few ticks to ensure all replicas transition to normal status. + t.run(); + + b2.stop(); + + try c.request(checkpoint_1_prepare_max - 1, checkpoint_1_prepare_max - 1); + + // Ensure b2 can't repair its WAL, commit & transition to checkpoint_1. + a0.drop(.R_, .incoming, .get_prepare); + b1.drop(.R_, .incoming, .get_prepare); + + try b2.open(); + + // Ensure b2 doesn't use repair_sync_timeout to initiate state sync and instead uses a View + // message that a0 sends on checkpoint durability. + const b2_replica = &t.cluster.replicas[b2.replicas.get(0)]; + b2_replica.repair_sync_timeout.stop(); + + // b2 at first only accepts prepares up till checkpoint_1_prepare_max. When a0 and b1 commit + // past checkpoint_2_prepare_ok_max and checkpoint_2 is durable, a0 sends a View message to + // the backups. b2 uses this View message to state sync to checkpoint_2. + try c.request(checkpoint_2_prepare_ok_max + 1, checkpoint_2_prepare_ok_max + 1); + + try expectEqual(a0.commit(), checkpoint_2_prepare_ok_max + 1); + try expectEqual(a0.op_checkpoint(), checkpoint_2); + + try expectEqual(b1.commit(), checkpoint_2_prepare_ok_max + 1); + try expectEqual(b1.op_checkpoint(), checkpoint_2); + + try expectEqual(b2.op_head(), checkpoint_2_prepare_ok_max + 1); + try expectEqual(b2.commit(), checkpoint_2); + try expectEqual(b2.op_checkpoint(), checkpoint_2); +} + +test "Cluster: sync: checkpoint from a newer view" { + // B1 appends (but does not commit) prepares across a checkpoint boundary. + // Then the cluster truncates those prepares and commits past the checkpoint trigger. + // When B1 subsequently joins, it should state sync and truncate the log. Immediately + // after state sync, the log doesn't connect to B1's new checkpoint. + const t = try TestContext.init(.{ .replica_count = 6 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(checkpoint_1 - 1, checkpoint_1 - 1); + try expectEqual(t.replica(.R_).commit(), checkpoint_1 - 1); + + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + + { + // Prevent A0 from committing, prevent any other replica from becoming a primary, and + // only allow B1 to learn about A0 prepares. + t.replica(.R_).drop(.R_, .incoming, .prepare); + t.replica(.R_).drop(.R_, .incoming, .prepare_ok); + t.replica(.R_).drop(.R_, .incoming, .exit_view); + + // Force b1 to sync, rather than repair, by making op=checkpoint_1 - 1 unavailable. + b1.stop(); + b1.corrupt(.{ .wal_prepare = (checkpoint_1 - 1) % slot_count }); + try b1.open(); + b1.pass(.A0, .incoming, .prepare); + b1.drop_fn(.A0, .incoming, struct { + fn drop_message(message: *const Message) bool { + const header = message.header.into(.prepare) orelse return false; + return header.op == checkpoint_1 - 1; + } + }.drop_message); + + try c.request(checkpoint_1 + 1, checkpoint_1 - 1); + + try expectEqual(a0.op_head(), checkpoint_1 + 1); + try expectEqual(b1.op_head(), checkpoint_1 + 1); + try expectEqual(a0.commit(), checkpoint_1 - 1); + try expectEqual(b1.commit(), checkpoint_1 - 2); + } + + { + // Make the rest of cluster prepare and commit a different sequence of prepares. + t.replica(.R_).pass(.R_, .incoming, .prepare); + t.replica(.R_).pass(.R_, .incoming, .prepare_ok); + t.replica(.R_).pass(.R_, .incoming, .exit_view); + + a0.drop_all(.R_, .bidirectional); + b1.drop_all(.R_, .bidirectional); + try c.request(checkpoint_2, checkpoint_2); + } + + { + // Let B1 rejoin, but prevent it from jumping into view change. + b1.pass_all(.R_, .bidirectional); + b1.drop(.R_, .bidirectional, .view); + b1.drop(.R_, .incoming, .ping); + b1.drop(.R_, .incoming, .pong); + + try c.request(checkpoint_2_trigger - 1, checkpoint_2_trigger - 1); + + // Wipe B1 in-memory state and check that it ends up in a consistent state after restart. + b1.stop(); + try b1.open(); + t.run(); + } + + t.replica(.R_).pass_all(.R_, .bidirectional); + t.run(); + try expectEqual(t.replica(.R_).commit(), checkpoint_2_trigger - 1); +} + +test "Cluster: prepare beyond checkpoint trigger" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(checkpoint_1_trigger - 1, checkpoint_1_trigger - 1); + try expectEqual(t.replica(.R_).commit(), checkpoint_1_trigger - 1); + + // Temporarily drop acks so that requests may prepare but not commit. + // (And to make sure we don't start checkpointing until we have had a chance to assert the + // cluster's state.) + t.replica(.R_).drop(.__, .bidirectional, .prepare_ok); + + // Prepare ops beyond the checkpoint. + try c.request(checkpoint_1_prepare_ok_max, checkpoint_1_trigger - 1); + try expectEqual(t.replica(.R_).op_checkpoint(), 0); + try expectEqual(t.replica(.R_).commit(), checkpoint_1_trigger - 1); + try expectEqual(t.replica(.R_).op_head(), checkpoint_1_prepare_ok_max - 1); + + t.replica(.R_).pass(.__, .bidirectional, .prepare_ok); + t.run(); + try expectEqual(c.replies(), checkpoint_1_prepare_ok_max); + try expectEqual(t.replica(.R_).op_checkpoint(), checkpoint_1); + try expectEqual(t.replica(.R_).commit(), checkpoint_1_prepare_ok_max); + try expectEqual(t.replica(.R_).op_head(), checkpoint_1_prepare_ok_max); +} + +test "Cluster: upgrade: operation=upgrade near trigger-minus-bar" { + const trigger_for_checkpoint = vsr.Checkpoint.trigger_for_checkpoint; + for ([_]struct { + request: u64, + checkpoint: u64, + }{ + .{ + // The entire last bar before the operation is free for operation=upgrade's, so when we + // hit the checkpoint trigger we can immediately upgrade the cluster. + .request = checkpoint_1_trigger - constants.lsm_compaction_ops, + .checkpoint = checkpoint_1, + }, + .{ + // Since there is a non-upgrade request in the last bar, the replica cannot upgrade + // during checkpoint_1 and must pad ahead to the next checkpoint. + .request = checkpoint_1_trigger - constants.lsm_compaction_ops + 1, + .checkpoint = checkpoint_2, + }, + }) |data| { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(data.request, data.request); + + t.replica(.R_).stop(); + try t.replica(.R_).open_upgrade(&[_]u8{ 10, 20 }); + + // Prevent the upgrade from committing so that we can verify that the replica is still + // running version 1. + t.replica(.R_).drop(.__, .bidirectional, .prepare_ok); + t.run(); + try expectEqual(t.replica(.R_).op_checkpoint(), 0); + try expectEqual(t.replica(.R_).release(), 10); + + t.replica(.R_).pass(.__, .bidirectional, .prepare_ok); + t.run(); + try expectEqual(t.replica(.R_).release(), 20); + try expectEqual(t.replica(.R_).op_checkpoint(), data.checkpoint); + try expectEqual(t.replica(.R_).commit(), trigger_for_checkpoint(data.checkpoint).?); + try expectEqual(t.replica(.R_).op_head(), trigger_for_checkpoint(data.checkpoint).?); + + // Verify that the upgraded cluster is healthy; i.e. that it can commit. + try c.request(data.request + 1, data.request + 1); + } +} + +test "Cluster: upgrade: R=1" { + // R=1 clusters upgrade even though they don't build a quorum of upgrade targets. + const t = try TestContext.init(.{ .replica_count = 1 }); + defer t.deinit(); + + t.replica(.R_).stop(); + try t.replica(.R0).open_upgrade(&[_]u8{ 10, 20 }); + t.run(); + + try expectEqual(t.replica(.R0).health(), .up); + try expectEqual(t.replica(.R0).release(), 20); + try expectEqual(t.replica(.R0).op_checkpoint(), checkpoint_1); + try expectEqual(t.replica(.R0).commit(), checkpoint_1_trigger); +} + +test "Cluster: upgrade: state-sync to new release" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + + t.replica(.R_).stop(); + try t.replica(.R0).open_upgrade(&[_]u8{ 10, 20 }); + try t.replica(.R1).open_upgrade(&[_]u8{ 10, 20 }); + try t.replica(.R2).open_upgrade(&[_]u8{ 10, 20 }); + + // R2 is advertising the new release (so that the upgrade can begin) but it doesn't actually + // join in yet. + t.replica(.R2).drop(.__, .bidirectional, .prepare); + t.replica(.R2).drop(.__, .bidirectional, .view); // Prevent state sync. + t.run(); + + try expectEqual(t.replica(.R0).commit(), checkpoint_1_trigger); + try c.request(constants.vsr_checkpoint_ops, constants.vsr_checkpoint_ops); + try expectEqual(t.replica(.R0).commit(), checkpoint_2_trigger); + + // R2 state-syncs from R0/R1, updating its release from v1 to v2 via CheckpointState... + t.replica(.R2).stop(); + t.replica(.R2).pass_all(.__, .bidirectional); + try t.replica(.R2).open_upgrade(&[_]u8{10}); + try expectEqual(t.replica(.R2).health(), .up); + try expectEqual(t.replica(.R2).release(), 10); + try expectEqual(t.replica(.R2).commit(), 0); + t.run(); + + // ...But R2 doesn't have v2 available, so it shuts down. + try expectEqual(t.replica(.R2).health(), .down); + try expectEqual(t.replica(.R2).release(), 10); + try expectEqual(t.replica(.R2).commit(), checkpoint_2); + + // Start R2 up with v2 available, and it recovers. + try t.replica(.R2).open_upgrade(&[_]u8{ 10, 20 }); + try expectEqual(t.replica(.R2).health(), .up); + try expectEqual(t.replica(.R2).release(), 20); + try expectEqual(t.replica(.R2).commit(), checkpoint_2); + + t.run(); + try expectEqual(t.replica(.R2).commit(), t.replica(.R_).commit()); +} + +test "Cluster: scrub: background scrubber, fully corrupt grid" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(checkpoint_2_trigger, checkpoint_2_trigger); + try expectEqual(t.replica(.R_).commit(), checkpoint_2_trigger); + + var a0 = t.replica(.A0); + const b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + const a0_free_set = &t.cluster.replicas[a0.replicas.get(0)].grid.free_set; + const b2_free_set = &t.cluster.replicas[b2.replicas.get(0)].grid.free_set; + const b2_storage = &t.cluster.storages[b2.replicas.get(0)]; + + // Corrupt B2's entire grid. + // Note that we intentionally do *not* shut down B2 for this – the intent is to test the + // scrubber, without leaning on Grid.read_block()'s `from_local_or_global_storage`. + { + const address_max = t.block_address_max(); + var address: u64 = 1; + while (address <= address_max) : (address += 1) { + b2.corrupt(.{ .grid_block = address }); + } + } + + // Disable new read/write faults so that we can use `storage.faults` to track repairs. + // (That is, as the scrubber runs, the number of faults will monotonically decrease.) + b2_storage.options.read_fault_probability = Ratio.zero(); + b2_storage.options.write_fault_probability = Ratio.zero(); + + // Tick until B2's grid repair stops making progress. + { + var faults_before = b2_storage.faults.count(); + while (true) { + t.run(); + + const faults_after = b2_storage.faults.count(); + assert(faults_after <= faults_before); + if (faults_after == faults_before) break; + + faults_before = faults_after; + } + } + + // Verify that B2 repaired all blocks. + const address_max = t.block_address_max(); + var address: u64 = 1; + while (address <= address_max) : (address += 1) { + if (a0_free_set.is_free(address)) { + assert(b2_free_set.is_free(address)); + assert(b2_storage.area_faulty(.{ .grid = .{ .address = address } })); + } else if (!a0_free_set.is_released(address)) { + // Acquired (but not released) blocks are guaranteed to be repaired by the scrubber. + assert(!b2_free_set.is_free(address)); + assert(!b2_free_set.is_released(address)); + assert(!b2_storage.area_faulty(.{ .grid = .{ .address = address } })); + } else { + // Acquired (but released) blocks are not guaranteed to be repaired by the scrubber. + // Includes the following blocks that will be freed when checkpoint_2 becomes durable: + // * Blocks released by ManifestLog compaction, + // * Blocks released ClientSessions and FreeSet checkpoint trailers (these *could* be + // released at the checkpoint itself, since new checkpoint trailers are allocated + // at checkpoint, but we release them at checkpoint durability alongside other + // released blocks). + maybe(b2_storage.area_faulty(.{ .grid = .{ .address = address } })); + } + } + + try TestReplicas.expect_equal_grid(a0, b2); + try TestReplicas.expect_equal_grid(b1, b2); +} + +// Compat(v0.15.3) +test "Cluster: client: empty command=request operation=register body" { + const run_test = struct { + fn run_test( + client_release: vsr.Release, + eviction_reason: vsr.Header.Eviction.Reason, + ) !void { + const t = try TestContext.init(.{ .replica_count = 1 }); + defer t.deinit(); + + // Wait for the primary to settle, since this test doesn't implement request retries. + t.run(); + + var client_bus = try t.client_bus(0); + defer client_bus.deinit(); + + var request_header = vsr.Header.Request{ + .cluster = t.cluster.options.cluster_id, + .size = @sizeOf(vsr.Header), + .client = client_bus.client_id, + .request = 0, + .command = .request, + .operation = .register, + .release = client_release, + .previous_request_latency = 0, + }; + request_header.set_checksum_body(&.{}); // Note the absence of a `vsr.RegisterRequest`. + request_header.set_checksum(); + + client_bus.request(t.replica(.A0).index(), &request_header, &.{}); + t.run(); + + const reply = std.mem.bytesAsValue( + vsr.Header.Eviction, + client_bus.reply.?.buffer[0..@sizeOf(vsr.Header.Eviction)], + ); + try expectEqual(reply.command, .eviction); + try expectEqual(reply.size, @sizeOf(vsr.Header.Eviction)); + try expectEqual(reply.reason, eviction_reason); + } + }.run_test; + + try run_test(vsr.Release.minimum, .client_release_too_low); + try run_test(releases[0].release_client_min, .invalid_request_body_size); +} + +test "Cluster: eviction: no_session via request" { + const t = try TestContext.init(.{ + .replica_count = 3, + .client_count = constants.clients_max + 1, + }); + defer t.deinit(); + + var c0 = t.clients(.{ .index = 0, .count = 1 }); + var c = t.clients(.{ .index = 1, .count = constants.clients_max }); + + const mark = marks.check("on_request: no session"); + + // Drop ping_client to prevent eviction message being sent via that path. + t.replica(.R_).drop(.__, .incoming, .ping_client); + // Register a single client. + try c0.request(1, 1); + // Register clients_max other clients. + // This evicts the "extra" client, though the eviction message has not been sent yet. + try c.request(constants.clients_max, constants.clients_max); + + // Try to send one last request -- which fails, since this client has been evicted. + try c0.request(2, 1); + + try mark.expect_hit(); + try expectEqual(c0.eviction_reason(), .no_session); + try expectEqual(c.eviction_reason(), null); +} + +test "Cluster: eviction: no_session via ping" { + const t = try TestContext.init(.{ + .replica_count = 3, + .client_count = constants.clients_max + 1, + }); + defer t.deinit(); + + var c0 = t.clients(.{ .index = 0, .count = 1 }); + var c = t.clients(.{ .index = 1, .count = constants.clients_max }); + + const mark = marks.check("on_ping_client: no session"); + // Register a single client. + try c0.request(1, 1); + // Register clients_max other clients. + // This evicts the "extra" client, though the eviction message has not been sent yet. + try c.request(constants.clients_max, constants.clients_max); + + try mark.expect_hit(); + try expectEqual(c0.eviction_reason(), .no_session); + try expectEqual(c.eviction_reason(), null); +} + +test "Cluster: eviction: client_release_too_low" { + const t = try TestContext.init(.{ + .replica_count = 3, + .client_release = .{ .value = releases[0].release.value - 1 }, + }); + defer t.deinit(); + + var c0 = t.clients(.{ .index = 0, .count = 1 }); + try c0.request(1, 0); + try expectEqual(c0.eviction_reason(), .client_release_too_low); +} + +test "Cluster: eviction: client_release_too_high" { + const t = try TestContext.init(.{ + .replica_count = 3, + .client_release = .{ .value = releases[0].release.value + 1 }, + }); + defer t.deinit(); + + var c0 = t.clients(.{ .index = 0, .count = 1 }); + try c0.request(1, 0); + try expectEqual(c0.eviction_reason(), .client_release_too_high); +} + +test "Cluster: eviction: session_too_low" { + const t = try TestContext.init(.{ + .replica_count = 3, + .client_count = constants.clients_max + 1, + }); + defer t.deinit(); + + var c0 = t.clients(.{ .index = 0, .count = 1 }); + var c = t.clients(.{ .index = 1, .count = constants.clients_max }); + + t.replica(.R_).record(.C0, .incoming, .request); + try c0.request(1, 1); + + // Drop ping_client to prevent eviction message being sent via that path. + t.replica(.R_).drop(.__, .incoming, .ping_client); + + // Evict C0. (C0 doesn't know this yet, though). + try c.request(constants.clients_max, constants.clients_max); + try expectEqual(c0.eviction_reason(), null); + + // Replay C0's register message. + t.replica(.R_).replay_recorded(); + t.run(); + + const mark = marks.check("on_request: ignoring older session"); + + // C0 now has a session again, but the client only knows the old (evicted) session number. + try c0.request(2, 1); + try mark.expect_hit(); + try expectEqual(c0.eviction_reason(), .session_too_low); +} + +test "Cluster: view_change: JV header doesn't match current header in journal" { + // It could be the case that a replica's JV headers don't match the journal's current state. + // For example, a header could be blank in the JV but present in the journal (could happen if + // the JV was computed when that header was corrupt/missing in the replica's journal, and the + // replica is simply reusing an old JV). The replica must check the journal before + // broadcasating its JV, so it appropriately acks/nacks headers in the JV based on the current + // state of the journal. + + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + b2.stop(); + + // Ensure b1 only commits up till checkpoint_2_trigger - 1, so it stays at checkpoint_1 while + // a0 moves to checkpoint_2. + try c.request(checkpoint_2_trigger - 1, checkpoint_2_trigger - 1); + b1.drop(.R_, .incoming, .commit); + try c.request(checkpoint_2_trigger, checkpoint_2_trigger); + + try expectEqual(a0.commit(), checkpoint_2_trigger); + try expectEqual(a0.op_checkpoint(), checkpoint_2); + try expectEqual(b1.commit(), checkpoint_2_trigger - 1); + try expectEqual(b1.op_checkpoint(), checkpoint_1); + + b1.stop(); + + try b2.open(); + t.run(); + + // b2 performs state sync to get caught up with a0. + try expectEqual(b2.op_checkpoint(), checkpoint_2); + try expectEqual(b2.commit_max(), checkpoint_2_trigger); + try expectEqual(b2.status(), .normal); + try b2.expect_sync_done(); + + try c.request(checkpoint_2_prepare_max, checkpoint_2_prepare_max); + + // a0 and b2 both prepare and commit up to the prepare_max for checkpoint_2. + try expectEqual(a0.op_head(), checkpoint_2_prepare_max); + try expectEqual(a0.op_checkpoint(), checkpoint_2); + try expectEqual(a0.commit_max(), checkpoint_2_prepare_max); + + try expectEqual(b2.op_head(), checkpoint_2_prepare_max); + try expectEqual(b2.op_checkpoint(), checkpoint_2); + try expectEqual(b2.commit_max(), checkpoint_2_prepare_max); + + b2.stop(); + a0.stop(); + + // Corrupt op_head() - 1 to ensure that the JV headers computed by a0 on startup contain a + // blank header for op_header() - 1. + a0.corrupt(.{ .wal_prepare = (a0.op_head() - 1) % slot_count }); + + const mark = marks.check("quorum received, awaiting repair"); + + try a0.open(); + try b1.open(); + + t.run(); + + // The two replicas are stuck in view change: + // B1 is still on checkpoint_1, it's JV header lagging behind A0's. A0's JV headers contain a + // blank header for op_head() - 1, which it can't nack/ack because it is corrupted in the + // journal. There aren't enough nacks for truncating op_head() -1 (nack_quorum=2), and no acks + // for it to be retained in the view change. + try expectEqual(a0.status(), .view_change); + try expectEqual(b1.status(), .view_change); + try mark.expect_hit(); + + a0.stop(); + const a0_storage = &t.cluster.storages[a0.replicas.get(0)]; + + a0_storage.faulty = false; + const mark2 = marks.check("quorum received, awaiting repair"); + + try a0.open(); + + t.run(); + + // The two replicas are stuck in view change still. a0 reuses its old JV headers with a blank + // header for op_head() - 1, but it still can't ack/nack it. + try mark2.expect_hit(); + try expectEqual(a0.status(), .view_change); + try expectEqual(b1.status(), .view_change); + + a0_storage.faulty = true; + try b2.open(); + t.run(); + + // a0 is able to resolve its dilemma about op_head() - 1 with the help of b2, which acks it. + try expectEqual(t.replica(.R0).status(), .normal); + try expectEqual(t.replica(.R0).op_checkpoint(), checkpoint_2); + try expectEqual(t.replica(.R0).commit_max(), checkpoint_2_prepare_max); +} + +test "Cluster: view_change: lagging replica repairs WAL using View from potential primary" { + // It could be the case that the replica with the most advanced checkpoint has a corruption in + // its grid. In this case, a replica on an older checkpoint can use a View message from + // the most up-to-date replica to repair its WAL, advance its checkpoint, and become primary. + + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + var a0 = t.replica(.A0); + var b1 = t.replica(.B1); + var b2 = t.replica(.B2); + + b2.stop(); + + // Ensure b1 only commits up till checkpoint_2_trigger - 1, so it stays at checkpoint_1 while + // a0 moves to checkpoint_2. + try c.request(checkpoint_2_trigger - 1, checkpoint_2_trigger - 1); + b1.drop(.R_, .incoming, .commit); + try c.request(checkpoint_2_trigger, checkpoint_2_trigger); + + try expectEqual(a0.commit(), checkpoint_2_trigger); + try expectEqual(a0.op_checkpoint(), checkpoint_2); + try expectEqual(b1.commit(), checkpoint_2_trigger - 1); + try expectEqual(b1.op_checkpoint(), checkpoint_1); + + // Start b2 so that the a0 & b2 can make progress to checkpoint_3; b1 is stopped so it remains + // lagging at checkpoint_1. + try b2.open(); + b1.stop(); + + t.run(); + + try expectEqual(b2.op_checkpoint(), checkpoint_2); + try expectEqual(b2.commit_max(), checkpoint_2_trigger); + try expectEqual(b2.status(), .normal); + try b2.expect_sync_done(); + + try c.request( + checkpoint_3_trigger, + checkpoint_3_trigger, + ); + + try expectEqual(a0.op_head(), checkpoint_3_trigger); + try expectEqual(a0.op_checkpoint(), checkpoint_3); + try expectEqual(b2.op_head(), checkpoint_3_trigger); + try expectEqual(b2.op_checkpoint(), checkpoint_3); + + // Simulate compaction getting stuck on a0 due to a grid corruption. Corrupting the grid doesn't + // work here since compaction in replica tests is always able to apply the move table + // optimization. This is because all requests in replica tests are `echo` operations, which are + // inserted into the LSM with monotonically increasing id. + const a0_replica = &t.cluster.replicas[a0.replicas.get(0)]; + a0_replica.commit_stage = .compact; + + try c.request( + checkpoint_3_trigger + 1, + checkpoint_3_trigger, + ); + + try expectEqual(a0.op_head(), checkpoint_3_trigger + 1); + try expectEqual(a0.commit(), checkpoint_3_trigger); + + try expectEqual(b2.op_head(), checkpoint_3_trigger + 1); + try expectEqual(b2.commit(), checkpoint_3_trigger); + + const committing_prepare = a0_replica.pipeline.queue.prepare_queue.head_ptr_const().?; + a0_replica.commit_prepare = committing_prepare.message.ref(); + + // Partition a0, force b1 & b2 into view_change by blocking outgoing .join_view messages. + a0.drop_all(.R_, .bidirectional); + + try b1.open(); + b1.drop(.R_, .outgoing, .join_view); + b2.drop(.R_, .outgoing, .join_view); + + t.run(); + + try expectEqual(b1.status(), .view_change); + try expectEqual(b2.status(), .view_change); + + // Stop b2, allow a0 and b1 to view change. a0 can't step up as primary since it has a + // corruption in its grid, due to which it can't make progress on its commit pipeline. However, + // since it has an intact WAL, it is able to send a .view message to b1. With the help + // of the .view message, b1 can repair, commit, advance from checkpoint_1 -> checkpoint_3, + // and step up as primary. + b2.stop(); + a0.pass_all(.R_, .bidirectional); + b1.pass(.R_, .outgoing, .join_view); + + t.run(); + t.run(); + t.run(); + + try expectEqual(b1.status(), .normal); + try expectEqual(b1.role(), .primary); + try expectEqual(b1.op_checkpoint(), checkpoint_3); + try expectEqual(b1.commit(), checkpoint_3_trigger + 1); + + try expectEqual(a0.status(), .normal); + try expectEqual(a0.role(), .backup); + try expectEqual(a0.op_checkpoint(), checkpoint_3); + try expectEqual(b1.commit(), checkpoint_3_trigger + 1); +} + +test "Cluster: partitioned replica with higher view cannot lock out client" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{ .index = 0, .count = 1 }); + + try c.request(1, 1); + + try expectEqual(t.replica(.R_).commit(), 1); + try expectEqual(t.replica(.R_).view(), 1); + try expectEqual(t.replica(.R_).log_view(), 1); + + const a0 = t.replica(.A0); + const b1 = t.replica(.B1); + const b2 = t.replica(.B2); + + // Partition primary, allow one of the backups to increment its view to 2 but the other to + // maintain its view at 1. Block exchange of JV messages to avoid view change. + a0.drop_all(.R_, .bidirectional); + t.replica(.R_).drop(.R_, .bidirectional, .join_view); + b1.drop_all(.R_, .incoming); + + t.run(); + try expectEqual(b1.view(), 1); + try expectEqual(b1.log_view(), 1); + try expectEqual(b2.view(), 2); + try expectEqual(b2.log_view(), 1); + + // Reconnect primary, partition the backup with view=2 so it doesn't influence a view change. + a0.pass_all(.R_, .bidirectional); + b2.drop_all(.R_, .bidirectional); + + // Verify that the client is able to get its requests processed by the cluster even though + // there is a partitioned replica with a higher view number (view=2) than the cluster (view=1). + try c.request(2, 2); + + try expectEqual(b2.view(), 2); + try expectEqual(b1.view(), 1); + try expectEqual(a0.view(), 1); + + try expectEqual(b1.commit(), 2); + try expectEqual(a0.commit(), 2); +} + +test "Cluster: broken hash chain within the same view does not stall commit via repair" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + // Forcefully stall commit pipeline. We let the cluster run for a while to circumvent assertions + // related to `commit_stage` on replica startup. + t.run(); + const b2 = t.replica(.B2); + const b2_replica = &t.cluster.replicas[b2.replicas.get(0)]; + b2_replica.commit_stage = .compact; + + // Disallow receiving a specific prepare, and repairing headers via repair and view, to + // force a hash chain break. + b2.drop_fn(.R_, .incoming, struct { + fn drop_message(message: *const Message) bool { + const header = message.header.into(.prepare) orelse return false; + return header.op == constants.pipeline_prepare_queue_max + 1; + } + }.drop_message); + b2.drop(.R_, .outgoing, .get_headers); + b2.drop(.R_, .incoming, .view); + + var c = t.clients(.{}); + try c.request( + constants.pipeline_prepare_queue_max - 1, + constants.pipeline_prepare_queue_max - 1, + ); + + try expectEqual(t.replica(.R_).op_head(), constants.pipeline_prepare_queue_max - 1); + try expectEqual(t.replica(.R_).commit_max(), constants.pipeline_prepare_queue_max - 1); + try expectEqual(b2.commit(), 0); + + try c.request( + 2 * constants.pipeline_prepare_queue_max, + 2 * constants.pipeline_prepare_queue_max, + ); + + // Disallow commit pipeline initiation via commit. Dropping incoming commit messages, and the + // fact that no more prepares are exchanged, ensures commit can only be initiated via repair. + b2_replica.commit_stage = .idle; + b2.drop(.R_, .incoming, .commit); + t.run(); + + try expectEqual(b2.op_head(), constants.pipeline_prepare_queue_max * 2); + try expectEqual(b2.commit_max(), constants.pipeline_prepare_queue_max * 2); + try expectEqual(b2.commit(), constants.pipeline_prepare_queue_max); +} + +test "Cluster: backups prepare past prepare_max if the next checkpoint is durable" { + const t = try TestContext.init(.{ .replica_count = 3 }); + defer t.deinit(); + + var c = t.clients(.{}); + try c.request(checkpoint_1_trigger - 1, checkpoint_1_trigger - 1); + + try expectEqual(t.replica(.R_).op_head(), checkpoint_1_trigger - 1); + try expectEqual(t.replica(.R_).commit(), checkpoint_1_trigger - 1); + try expectEqual(t.replica(.R_).op_checkpoint(), 0); + + const a0 = t.replica(.A0); + const b1 = t.replica(.B1); + const b2 = t.replica(.B2); + + const b2_replica = &t.cluster.replicas[b2.replicas.get(0)]; + + // Stall commit pipeline on b2, forcing it to accept prepares + // but not advance its checkpoint past 0. Meanwhile, the rest + // of the cluster moves to checkpoint=checkpoint_2. Setting the + // stage to checkpoint_superblock also ensures that if we receive + // any View message from the primary, we don't use it to + // start state sync (see `on_view_set_checkpoint`). + b2_replica.commit_stage = .checkpoint_superblock; + + try c.request(checkpoint_2_prepare_max, checkpoint_2_prepare_max); + + try expectEqual(t.replica(.R_).commit_max(), checkpoint_2_prepare_max); + + try expectEqual(a0.op_head(), checkpoint_2_prepare_max); + try expectEqual(b1.op_head(), checkpoint_2_prepare_max); + + // Since checkpoint_1 is durable on a0, b1 (a commit quorum of + // replicas), b2 is able to accept some prepares from the next + // checkpoint, overwriting some of its committed prepares. + // However, even though ops [checkpoint_1, checkpoint_1_trigger - 1] + // are committed on b2, they are not overwritten as they are + // required during checkpointing & upgrade. + try expectEqual(b2.op_head(), checkpoint_1 + constants.journal_slot_count - 1); + + try expectEqual(a0.op_checkpoint(), checkpoint_2); + try expectEqual(b1.op_checkpoint(), checkpoint_2); + try expectEqual(b2.op_checkpoint(), 0); + + // b2 crashes and restarts, and truncates all prepares that past + // checkpoint_1_prepare_max, since all prepares in checkpoint=0 + // must be replayed after restart. + b2.stop(); + try b2.open(); + + try expectEqual(b2.op_head(), checkpoint_1_prepare_max); + + t.run(); + + try expectEqual(t.replica(.R_).op_head(), checkpoint_2_prepare_max); + try expectEqual(t.replica(.R_).commit(), checkpoint_2_prepare_max); + try expectEqual(t.replica(.R_).op_checkpoint(), checkpoint_2); +} + +const ProcessSelector = enum { + __, // all replicas, standbys, and clients + R_, // all (non-standby) replicas + R0, + R1, + R2, + R3, + R4, + R5, + S_, // all standbys + S0, + S1, + S2, + S3, + S4, + S5, + A0, // current primary + B1, // backup immediately following current primary + B2, + B3, + B4, + B5, + C_, // all clients + C0, +}; + +const TestContext = struct { + cluster: *Cluster, + log_level: std.log.Level, + client_requests: []usize, + client_replies: []usize, + + pub fn init(options: struct { + replica_count: u8, + standby_count: u8 = 0, + client_count: u8 = constants.clients_max, + client_release: vsr.Release = releases[0].release, + seed: u64 = 123, + }) !*TestContext { + const log_level_original = std.testing.log_level; + std.testing.log_level = log_level; + var prng = stdx.PRNG.from_seed(options.seed); + const storage_size_limit = vsr.sector_floor(128 * MiB); + + const cluster = try Cluster.init(allocator, .{ + .cluster = .{ + .cluster_id = 0, + .replica_count = options.replica_count, + .standby_count = options.standby_count, + .client_count = options.client_count, + .storage_size_limit = storage_size_limit, + .seed = prng.int(u64), + .releases = &releases, + .client_release = options.client_release, + .reformats_max = 3, + .state_machine = .{ + .batch_size_limit = constants.message_body_size_max, + .lsm_forest_node_count = 4096, + }, + }, + .network = .{ + .node_count = options.replica_count + options.standby_count, + .client_count = options.client_count, + .seed = prng.int(u64), + .one_way_delay_mean = fuzz.range_inclusive_ms(&prng, 30, 120), + .one_way_delay_min = fuzz.range_inclusive_ms(&prng, 0, 20), + + .path_maximum_capacity = 10, + .path_clog_duration_mean = .{ .ns = 0 }, + .path_clog_probability = Ratio.zero(), + .recorded_count_max = 16, + }, + .storage = .{ + .size = storage_size_limit, + .read_latency_min = .ms(10), + .read_latency_mean = .ms(50), + .write_latency_min = .ms(10), + .write_latency_mean = .ms(50), + }, + .storage_fault_atlas = .{ + .faulty_superblock = false, + .faulty_wal_headers = false, + .faulty_wal_prepares = false, + .faulty_client_replies = false, + .faulty_grid = false, + }, + .callbacks = .{ + .on_client_reply = TestContext.on_client_reply, + }, + }); + errdefer cluster.deinit(); + + for (cluster.storages) |*storage| storage.faulty = true; + + const client_requests = try allocator.alloc(usize, options.client_count); + errdefer allocator.free(client_requests); + @memset(client_requests, 0); + + const client_replies = try allocator.alloc(usize, cluster.clients.len); + errdefer allocator.free(client_replies); + @memset(client_replies, 0); + + const context = try allocator.create(TestContext); + errdefer allocator.destroy(context); + + context.* = .{ + .cluster = cluster, + .log_level = log_level_original, + .client_requests = client_requests, + .client_replies = client_replies, + }; + cluster.context = context; + + return context; + } + + pub fn deinit(t: *TestContext) void { + std.testing.log_level = t.log_level; + allocator.free(t.client_replies); + allocator.free(t.client_requests); + t.cluster.deinit(); + allocator.destroy(t); + } + + pub fn replica(t: *TestContext, selector: ProcessSelector) TestReplicas { + const replica_processes = t.processes(selector); + var replica_indexes = stdx.BoundedArrayType(u8, constants.members_max){}; + for (replica_processes.const_slice()) |p| replica_indexes.push(p.replica); + return TestReplicas{ + .context = t, + .cluster = t.cluster, + .replicas = replica_indexes, + }; + } + pub fn clients( + t: *TestContext, + options: struct { + index: usize = 0, + count: ?usize = null, + }, + ) TestClients { + const index = options.index; + const count = options.count orelse t.cluster.options.client_count; + assert(index + count <= t.cluster.options.client_count); + + var client_indexes = stdx.BoundedArrayType(usize, constants.clients_max){}; + for (index..index + count) |i| client_indexes.push(i); + return TestClients{ + .context = t, + .cluster = t.cluster, + .clients = client_indexes, + }; + } + + pub fn client_bus(t: *TestContext, client_index: usize) !*TestClientBus { + // Reuse one of `Cluster.clients`' ids since the Network preallocated links for it. + return TestClientBus.init(t, t.cluster.clients[client_index].?.id); + } + + pub fn run(t: *TestContext) void { + const tick_max = 8_200; + var tick_count: usize = 0; + while (tick_count < tick_max) : (tick_count += 1) { + if (t.tick()) tick_count = 0; + } + } + + pub fn block_address_max(t: *TestContext) u64 { + const grid_blocks = t.cluster.storages[0].grid_blocks(); + for (t.cluster.storages) |storage| { + assert(storage.grid_blocks() == grid_blocks); + } + return grid_blocks; // NB: no -1 needed, addresses start from 1. + } + + /// Returns whether the cluster state advanced. + fn tick(t: *TestContext) bool { + const commits_before = t.cluster.state_checker.commits.items.len; + t.cluster.tick(); + return commits_before != t.cluster.state_checker.commits.items.len; + } + + fn on_client_reply( + cluster: *Cluster, + client: usize, + request: *const Message.Request, + reply: *const Message.Reply, + ) void { + _ = request; + _ = reply; + const t: *TestContext = @ptrCast(@alignCast(cluster.context.?)); + t.client_replies[client] += 1; + } + + const ProcessList = stdx.BoundedArrayType( + Process, + constants.members_max + constants.clients_max, + ); + + fn processes(t: *const TestContext, selector: ProcessSelector) ProcessList { + const replica_count = t.cluster.options.replica_count; + + var view: u32 = 0; + for (t.cluster.replicas) |*r| view = @max(view, r.view); + + var array = ProcessList{}; + switch (selector) { + .R0 => array.push(.{ .replica = 0 }), + .R1 => array.push(.{ .replica = 1 }), + .R2 => array.push(.{ .replica = 2 }), + .R3 => array.push(.{ .replica = 3 }), + .R4 => array.push(.{ .replica = 4 }), + .R5 => array.push(.{ .replica = 5 }), + .S0 => array.push(.{ .replica = replica_count + 0 }), + .S1 => array.push(.{ .replica = replica_count + 1 }), + .S2 => array.push(.{ .replica = replica_count + 2 }), + .S3 => array.push(.{ .replica = replica_count + 3 }), + .S4 => array.push(.{ .replica = replica_count + 4 }), + .S5 => array.push(.{ .replica = replica_count + 5 }), + .A0 => array + .push(.{ .replica = @intCast((view + 0) % replica_count) }), + .B1 => array + .push(.{ .replica = @intCast((view + 1) % replica_count) }), + .B2 => array + .push(.{ .replica = @intCast((view + 2) % replica_count) }), + .B3 => array + .push(.{ .replica = @intCast((view + 3) % replica_count) }), + .B4 => array + .push(.{ .replica = @intCast((view + 4) % replica_count) }), + .B5 => array + .push(.{ .replica = @intCast((view + 5) % replica_count) }), + .C0 => array.push(.{ .client = t.cluster.clients[0].?.id }), + .__, .R_, .S_, .C_ => { + if (selector == .__ or selector == .R_) { + for (t.cluster.replicas[0..replica_count], 0..) |_, i| { + array.push(.{ .replica = @intCast(i) }); + } + } + if (selector == .__ or selector == .S_) { + for (t.cluster.replicas[replica_count..], 0..) |_, i| { + array.push(.{ .replica = @intCast(replica_count + i) }); + } + } + if (selector == .__ or selector == .C_) { + for (t.cluster.clients) |*client| { + array.push(.{ .client = client.*.?.id }); + } + } + }, + } + assert(array.count() > 0); + return array; + } +}; + +const TestReplicas = struct { + context: *TestContext, + cluster: *Cluster, + replicas: stdx.BoundedArrayType(u8, constants.members_max), + + pub fn stop(t: *const TestReplicas) void { + for (t.replicas.const_slice()) |r| { + log.info("{}: crash replica", .{r}); + t.cluster.replica_crash(r); + + // For simplicity, ensure that any packets that are in flight to this replica are + // discarded before it starts up again. + const paths = t.peer_paths(.__, .incoming); + for (paths.const_slice()) |path| { + t.cluster.network.link_clear(path); + } + } + } + + pub fn open(t: *const TestReplicas) !void { + for (t.replicas.const_slice()) |r| { + log.info("{}: restart replica", .{r}); + t.cluster.replica_restart(r) catch |err| { + assert(t.replicas.count() == 1); + return switch (err) { + error.WALCorrupt => return error.WALCorrupt, + error.WALInvalid => return error.WALInvalid, + else => @panic("unexpected error"), + }; + }; + } + } + + pub fn open_upgrade(t: *const TestReplicas, releases_bundled_patch: []const u8) !void { + var releases_bundled: vsr.ReleaseList = .empty; + for (releases_bundled_patch) |patch| { + releases_bundled.push(vsr.Release.from(.{ + .major = 0, + .minor = 0, + .patch = patch, + })); + } + releases_bundled.verify(); + + for (t.replicas.const_slice()) |r| { + log.info("{}: restart replica", .{r}); + t.cluster.replica_set_releases(r, &releases_bundled); + t.cluster.replica_restart(r) catch |err| { + assert(t.replicas.count() == 1); + return switch (err) { + error.WALCorrupt => return error.WALCorrupt, + error.WALInvalid => return error.WALInvalid, + else => @panic("unexpected error"), + }; + }; + } + } + + pub fn open_reformat(t: *const TestReplicas) !void { + for (t.replicas.const_slice()) |r| { + log.info("{}: recover replica", .{r}); + try t.cluster.replica_reformat(r); + } + } + + pub fn index(t: *const TestReplicas) u8 { + assert(t.replicas.count() == 1); + return t.replicas.get(0); + } + + const Health = enum { up, down, reformatting }; + + pub fn health(t: *const TestReplicas) Health { + var value_all: ?Health = null; + for (t.replicas.const_slice()) |r| { + const value: Health = switch (t.cluster.replica_health[r]) { + .up => .up, + .down => .down, + .reformatting => .reformatting, + }; + if (value_all) |all| { + assert(all == value); + } else { + value_all = value; + } + } + return value_all.?; + } + + fn get( + t: *const TestReplicas, + comptime field: std.meta.FieldEnum(Cluster.Replica), + ) @FieldType(Cluster.Replica, @tagName(field)) { + var value_all: ?@FieldType(Cluster.Replica, @tagName(field)) = null; + for (t.replicas.const_slice()) |r| { + const replica = &t.cluster.replicas[r]; + const value = @field(replica, @tagName(field)); + if (value_all) |all| { + if (all != value) { + for (t.replicas.const_slice()) |replica_index| { + log.err("replica={} field={s} value={}", .{ + replica_index, + @tagName(field), + @field(&t.cluster.replicas[replica_index], @tagName(field)), + }); + } + @panic("test failed: value mismatch"); + } + } else { + value_all = value; + } + } + return value_all.?; + } + + pub fn release(t: *const TestReplicas) u16 { + var value_all: ?u16 = null; + for (t.replicas.const_slice()) |r| { + const value = t.cluster.replicas[r].release.triple().patch; + if (value_all) |all| { + assert(all == value); + } else { + value_all = value; + } + } + return value_all.?; + } + + pub fn status(t: *const TestReplicas) vsr.Status { + return t.get(.status); + } + + pub fn view(t: *const TestReplicas) u32 { + return t.get(.view); + } + + pub fn log_view(t: *const TestReplicas) u32 { + return t.get(.log_view); + } + + pub fn op_head(t: *const TestReplicas) u64 { + return t.get(.op); + } + + pub fn commit(t: *const TestReplicas) u64 { + return t.get(.commit_min); + } + + pub fn commit_max(t: *const TestReplicas) u64 { + return t.get(.commit_max); + } + + pub fn state_machine_opened(t: *const TestReplicas) bool { + return t.get(.state_machine_opened); + } + + fn sync_stage(t: *const TestReplicas) vsr.SyncStage { + assert(t.replicas.count() > 0); + + var sync_stage_all: ?vsr.SyncStage = null; + for (t.replicas.const_slice()) |r| { + const replica = &t.cluster.replicas[r]; + if (sync_stage_all) |all| { + assert(std.meta.eql(all, replica.syncing)); + } else { + sync_stage_all = replica.syncing; + } + } + return sync_stage_all.?; + } + + pub fn sync_status(t: *const TestReplicas) std.meta.Tag(vsr.SyncStage) { + return @as(std.meta.Tag(vsr.SyncStage), t.sync_stage()); + } + + fn sync_target(t: *const TestReplicas) ?vsr.SyncTarget { + return t.sync_stage().target(); + } + + pub fn sync_target_checkpoint_op(t: *const TestReplicas) ?u64 { + if (t.sync_target()) |target| { + return target.checkpoint_op; + } else { + return null; + } + } + + pub fn sync_target_checkpoint_id(t: *const TestReplicas) ?u128 { + if (t.sync_target()) |target| { + return target.checkpoint_id; + } else { + return null; + } + } + + const Role = enum { primary, backup, standby }; + + pub fn role(t: *const TestReplicas) Role { + var role_all: ?Role = null; + for (t.replicas.const_slice()) |r| { + const replica = &t.cluster.replicas[r]; + const replica_role: Role = role: { + if (replica.standby()) { + break :role .standby; + } else if (replica.replica == replica.primary_index(replica.view)) { + break :role .primary; + } else { + break :role .backup; + } + }; + assert(role_all == null or role_all.? == replica_role); + role_all = replica_role; + } + return role_all.?; + } + + pub fn op_checkpoint_id(t: *const TestReplicas) u128 { + var checkpoint_id_all: ?u128 = null; + for (t.replicas.const_slice()) |r| { + const replica = &t.cluster.replicas[r]; + const replica_checkpoint_id = replica.superblock.working.checkpoint_id(); + assert(checkpoint_id_all == null or checkpoint_id_all.? == replica_checkpoint_id); + checkpoint_id_all = replica_checkpoint_id; + } + return checkpoint_id_all.?; + } + + pub fn op_checkpoint(t: *const TestReplicas) u64 { + var checkpoint_all: ?u64 = null; + for (t.replicas.const_slice()) |r| { + const replica = &t.cluster.replicas[r]; + assert(checkpoint_all == null or checkpoint_all.? == replica.op_checkpoint()); + checkpoint_all = replica.op_checkpoint(); + } + return checkpoint_all.?; + } + + pub fn corrupt( + t: *const TestReplicas, + target: union(enum) { + wal_header: usize, // slot + wal_prepare: usize, // slot + client_reply: usize, // slot + grid_block: u64, // address + }, + ) void { + switch (target) { + .wal_header => |slot| { + const fault_offset = vsr.Zone.wal_headers.offset(slot * @sizeOf(vsr.Header)); + for (t.replicas.const_slice()) |r| { + t.cluster.storages[r].memory[fault_offset] +%= 1; + } + }, + .wal_prepare => |slot| { + const fault_offset = vsr.Zone.wal_prepares.offset(slot * + constants.message_size_max); + const fault_sector = @divExact(fault_offset, constants.sector_size); + for (t.replicas.const_slice()) |r| { + t.cluster.storages[r].faults.set(fault_sector); + } + }, + .client_reply => |slot| { + const fault_offset = vsr.Zone.client_replies.offset(slot * + constants.message_size_max); + const fault_sector = @divExact(fault_offset, constants.sector_size); + for (t.replicas.const_slice()) |r| { + t.cluster.storages[r].faults.set(fault_sector); + } + }, + .grid_block => |address| { + const fault_offset = vsr.Zone.grid.offset((address - 1) * constants.block_size); + const fault_sector = @divExact(fault_offset, constants.sector_size); + for (t.replicas.const_slice()) |r| { + t.cluster.storages[r].faults.set(fault_sector); + } + }, + } + } + + pub const LinkDirection = enum { bidirectional, incoming, outgoing }; + + pub fn pass_all(t: *const TestReplicas, peer: ProcessSelector, direction: LinkDirection) void { + const paths = t.peer_paths(peer, direction); + for (paths.const_slice()) |path| { + t.cluster.network.link_filter(path).* = LinkFilter.initFull(); + } + } + + pub fn drop_all(t: *const TestReplicas, peer: ProcessSelector, direction: LinkDirection) void { + const paths = t.peer_paths(peer, direction); + for (paths.const_slice()) |path| t.cluster.network.link_filter(path).* = LinkFilter{}; + } + + pub fn pass( + t: *const TestReplicas, + peer: ProcessSelector, + direction: LinkDirection, + command: vsr.Command, + ) void { + const paths = t.peer_paths(peer, direction); + for (paths.const_slice()) |path| t.cluster.network.link_filter(path).insert(command); + } + + pub fn drop( + t: *const TestReplicas, + peer: ProcessSelector, + direction: LinkDirection, + command: vsr.Command, + ) void { + const paths = t.peer_paths(peer, direction); + for (paths.const_slice()) |path| t.cluster.network.link_filter(path).remove(command); + } + + pub fn drop_fn( + t: *const TestReplicas, + peer: ProcessSelector, + direction: LinkDirection, + comptime drop_message_fn: ?fn (message: *const Message) bool, + ) void { + const paths = t.peer_paths(peer, direction); + for (paths.const_slice()) |path| { + t.cluster.network.link_drop_packet_fn(path).* = if (drop_message_fn) |f| + &f + else + null; + } + } + + pub fn record( + t: *const TestReplicas, + peer: ProcessSelector, + direction: LinkDirection, + command: vsr.Command, + ) void { + const paths = t.peer_paths(peer, direction); + for (paths.const_slice()) |path| t.cluster.network.link_record(path).insert(command); + } + + pub fn replay_recorded( + t: *const TestReplicas, + ) void { + t.cluster.network.replay_recorded(); + } + + // -1: no route to self. + const paths_max = constants.members_max * (constants.members_max - 1 + constants.clients_max); + + fn peer_paths( + t: *const TestReplicas, + peer: ProcessSelector, + direction: LinkDirection, + ) stdx.BoundedArrayType(Network.Path, paths_max) { + var paths = stdx.BoundedArrayType(Network.Path, paths_max){}; + const peers = t.context.processes(peer); + for (t.replicas.const_slice()) |a| { + const process_a = Process{ .replica = a }; + for (peers.const_slice()) |process_b| { + if (direction == .bidirectional or direction == .outgoing) { + paths.push(.{ .source = process_a, .target = process_b }); + } + if (direction == .bidirectional or direction == .incoming) { + paths.push(.{ .source = process_b, .target = process_a }); + } + } + } + return paths; + } + + fn expect_sync_done(t: TestReplicas) !void { + assert(t.replicas.count() > 0); + + for (t.replicas.const_slice()) |replica_index| { + const replica: *const Cluster.Replica = &t.cluster.replicas[replica_index]; + if (!replica.sync_content_done()) return error.SyncContentPending; + + // If the replica has finished syncing, but not yet checkpointed, then it might not have + // updated its sync_op_max. + maybe(replica.superblock.staging.vsr_state.sync_op_max > 0); + + try t.cluster.storage_checker.replica_sync(Cluster.Replica, replica); + } + } + + fn expect_equal_grid(want: TestReplicas, got: TestReplicas) !void { + assert(want.replicas.count() == 1); + assert(got.replicas.count() > 0); + + const want_replica: *const Cluster.Replica = &want.cluster.replicas[want.replicas.get(0)]; + + for (got.replicas.const_slice()) |replica_index| { + const got_replica: *const Cluster.Replica = &got.cluster.replicas[replica_index]; + + const address_max = want.context.block_address_max(); + var address: u64 = 1; + while (address <= address_max) : (address += 1) { + const address_free = want_replica.grid.free_set.is_free(address); + assert(address_free == got_replica.grid.free_set.is_free(address)); + if (address_free) continue; + + const block_want = want_replica.superblock.storage.grid_block(address).?; + const block_got = got_replica.superblock.storage.grid_block(address).?; + + try expectEqual( + std.mem.bytesToValue(vsr.Header, block_want[0..@sizeOf(vsr.Header)]), + std.mem.bytesToValue(vsr.Header, block_got[0..@sizeOf(vsr.Header)]), + ); + } + } + } +}; + +const TestClients = struct { + context: *TestContext, + cluster: *Cluster, + clients: stdx.BoundedArrayType(usize, constants.clients_max), + requests: usize = 0, + + pub fn request(t: *TestClients, requests: usize, expect_replies: usize) !void { + assert(t.requests <= requests); + defer assert(t.requests == requests); + + outer: while (true) { + for (t.clients.const_slice()) |c| { + if (t.requests == requests) break :outer; + t.context.client_requests[c] += 1; + t.requests += 1; + } + } + + const tick_max = 3_000; + var tick: usize = 0; + while (tick < tick_max) : (tick += 1) { + if (t.context.tick()) tick = 0; + + for (t.clients.const_slice()) |c| { + if (t.cluster.clients[c]) |*client| { + if (client.request_inflight == null and + t.context.client_requests[c] > client.request_number) + { + if (client.request_number == 0) { + t.cluster.register(c); + } else { + const message = client.get_message(); + errdefer client.release_message(message); + + const body_size = 123; + @memset(message.buffer[@sizeOf(vsr.Header)..][0..body_size], 42); + t.cluster.request(c, .echo, message, body_size); + } + } + } + } + } + try std.testing.expectEqual(t.replies(), expect_replies); + } + + pub fn replies(t: *const TestClients) usize { + var replies_total: usize = 0; + for (t.clients.const_slice()) |c| replies_total += t.context.client_replies[c]; + return replies_total; + } + + pub fn eviction_reason(t: *const TestClients) ?vsr.Header.Eviction.Reason { + var evicted_all: ?vsr.Header.Eviction.Reason = null; + for (t.clients.const_slice(), 0..) |r, i| { + const client_eviction_reason = t.cluster.client_eviction_reasons[r]; + if (i == 0) { + assert(evicted_all == null); + } else { + assert(evicted_all == client_eviction_reason); + } + evicted_all = client_eviction_reason; + } + return evicted_all; + } +}; + +/// TestClientBus supports tests which require fine-grained control of the client protocol. +/// Note that in particular, TestClientBus does *not* implement message retries. +const TestClientBus = struct { + const MessagePool = @import("../message_pool.zig").MessagePool; + const MessageBus = Cluster.MessageBus; + + context: *TestContext, + client_id: u128, + message_pool: *MessagePool, + message_bus: MessageBus, + reply: ?*Message = null, + + fn init(context: *TestContext, client_id: u128) !*TestClientBus { + const message_pool = try allocator.create(MessagePool); + errdefer allocator.destroy(message_pool); + + message_pool.* = try MessagePool.init(allocator, .client); + errdefer message_pool.deinit(allocator); + + var client_bus = try allocator.create(TestClientBus); + errdefer allocator.destroy(client_bus); + + client_bus.* = .{ + .context = context, + .client_id = client_id, + .message_pool = message_pool, + .message_bus = try MessageBus.init( + allocator, + .{ .client = client_id }, + message_pool, + on_messages, + .{ .network = context.cluster.network }, + ), + }; + errdefer client_bus.message_bus.deinit(allocator); + + context.cluster.state_checker.clients_exhaustive = false; + context.cluster.network.link(client_bus.message_bus.process, &client_bus.message_bus); + + return client_bus; + } + + pub fn deinit(t: *TestClientBus) void { + if (t.reply) |reply| { + t.message_pool.unref(reply); + t.reply = null; + } + t.message_bus.deinit(allocator); + t.message_pool.deinit(allocator); + allocator.destroy(t.message_pool); + allocator.destroy(t); + } + + fn on_messages(message_bus: *Cluster.MessageBus, buffer: *MessageBuffer) void { + const t: *TestClientBus = @fieldParentPtr("message_bus", message_bus); + while (buffer.next_header()) |header| { + const message = buffer.consume_message(t.message_pool, &header); + defer t.message_pool.unref(message); + + assert(message.header.cluster == t.context.cluster.options.cluster_id); + + switch (message.header.command) { + .reply, .eviction => { + assert(t.reply == null); + t.reply = message.ref(); + }, + .pong_client => {}, + else => unreachable, + } + } + } + + pub fn request( + t: *TestClientBus, + replica: u8, + header: *const vsr.Header.Request, + body: []const u8, + ) void { + assert(replica < t.context.cluster.replicas.len); + assert(body.len <= constants.message_body_size_max); + + const message = t.message_pool.get_message(.request); + defer t.message_pool.unref(message); + + message.header.* = header.*; + stdx.copy_disjoint(.inexact, u8, message.buffer[@sizeOf(vsr.Header)..], body); + + t.message_bus.send_message_to_replica(replica, message.base()); + } +}; diff --git a/ocam/src/vsr/superblock.zig b/ocam/src/vsr/superblock.zig new file mode 100644 index 00000000..ec5e93aa --- /dev/null +++ b/ocam/src/vsr/superblock.zig @@ -0,0 +1,1603 @@ +//! SuperBlock invariants: +//! +//! * vsr_state +//! - vsr_state.replica and vsr_state.replica_count are immutable for now. +//! - vsr_state.checkpoint.header.op is initially 0 (for a newly-formatted replica). +//! - vsr_state.checkpoint.header.op ≤ vsr_state.commit_max +//! - vsr_state.checkpoint.header.op_before ≤ vsr_state.checkpoint.header.op +//! - vsr_state.log_view ≤ vsr_state.view +//! - vsr_state.sync_op_min ≤ vsr_state.sync_op_max +//! +//! - vsr_state.checkpoint.manifest_block_count = 0 implies: +//! vsr_state.checkpoint.manifest_oldest_address=0 +//! vsr_state.checkpoint.manifest_oldest_checksum=0 +//! vsr_state.checkpoint.manifest_newest_address=0 +//! vsr_state.checkpoint.manifest_newest_checksum=0 +//! vsr_state.checkpoint.manifest_oldest_address=0 +//! +//! - vsr_state.checkpoint.manifest_block_count > 0 implies: +//! vsr_state.checkpoint.manifest_oldest_address>0 +//! vsr_state.checkpoint.manifest_newest_address>0 +//! +//! - checkpoint() must advance the superblock's vsr_state.checkpoint.header.op. +//! - view_change() must not advance the superblock's vsr_state.checkpoint.header.op. +//! - The following are monotonically increasing: +//! - vsr_state.log_view +//! - vsr_state.view +//! - vsr_state.commit_max +//! - vsr_state.checkpoint.header.op may backtrack due to state sync. +//! +const std = @import("std"); +const assert = std.debug.assert; +const maybe = stdx.maybe; +const mem = std.mem; +const meta = std.meta; + +const constants = @import("../constants.zig"); +const stdx = @import("stdx"); +const vsr = @import("../vsr.zig"); +const log = std.log.scoped(.superblock); + +pub const Quorums = @import("superblock_quorums.zig").QuorumsType(.{ + .superblock_copies = constants.superblock_copies, +}); + +pub const SuperBlockVersion: u16 = + // Make sure that data files created by development builds are distinguished through version. + if (constants.config.process.release.value == vsr.Release.minimum.value) 0 else 2; + +const view_headers_reserved_size = constants.sector_size - + ((constants.view_headers_max * @sizeOf(vsr.Header)) % constants.sector_size); + +// Fields are aligned to work as an extern or packed struct. +pub const SuperBlockHeader = extern struct { + checksum: u128 = undefined, + checksum_padding: u128 = 0, + + /// Protects against misdirected reads at startup. + /// For example, if multiple reads are all misdirected to a single copy of the superblock. + /// Excluded from the checksum calculation to ensure that all copies have the same checksum. + /// This simplifies writing and comparing multiple copies. + /// TODO: u8 should be enough here, we use u16 only for alignment. + copy: u16 = 0, + + /// The version of the superblock format in use, reserved for major breaking changes. + version: u16, + + /// The release that the data file was originally formatted by. + /// (Upgrades do not update this field.) + release_format: vsr.Release, + + /// A monotonically increasing counter to locate the latest superblock at startup. + sequence: u64, + + /// Protects against writing to or reading from the wrong data file. + cluster: u128, + + /// The checksum of the previous superblock to hash chain across sequence numbers. + parent: u128, + parent_padding: u128 = 0, + + /// State stored on stable storage for the Viewstamped Replication consensus protocol. + vsr_state: VSRState, + + /// Reserved for future minor features (e.g. changing a compression algorithm). + flags: u64 = 0, + + /// The number of headers in view_headers_all. + view_headers_count: u32, + + reserved: [1940]u8 = @splat(0), + + /// View/JV header suffix. Headers are ordered from high-to-low op. + /// Unoccupied headers (after view_headers_count) are zeroed. + /// + /// When `vsr_state.log_view < vsr_state.view`, the headers are for a JV. + /// When `vsr_state.log_view = vsr_state.view`, the headers are for a View. + view_headers_all: [constants.view_headers_max]vsr.Header.Prepare, + view_headers_reserved: [view_headers_reserved_size]u8 = @splat(0), + + comptime { + assert(@sizeOf(SuperBlockHeader) % constants.sector_size == 0); + assert(@divExact(@sizeOf(SuperBlockHeader), constants.sector_size) >= 2); + assert(@offsetOf(SuperBlockHeader, "parent") % @sizeOf(u256) == 0); + assert(@offsetOf(SuperBlockHeader, "vsr_state") % @sizeOf(u256) == 0); + assert(@offsetOf(SuperBlockHeader, "view_headers_all") == constants.sector_size); + // Assert that there is no implicit padding in the struct. + assert(stdx.no_padding(SuperBlockHeader)); + } + + pub const VSRState = extern struct { + checkpoint: CheckpointState, + + /// Globally unique identifier of the replica, must be non-zero. + replica_id: u128, + + members: vsr.Members, + + /// The highest operation up to which we may commit. + commit_max: u64, + + /// See `sync_op_max`. + sync_op_min: u64, + + /// When zero, all of the grid blocks and replies are synced. + /// (When zero, `sync_op_min` is also zero.) + /// + /// When nonzero, we must repair grid-blocks/client-replies that would have been written + /// during the commits between `sync_op_min` and `sync_op_max` (inclusive). + /// (Those grid-blocks and client-replies were not written normally because we "skipped" + /// past them via state sync.) + sync_op_max: u64, + + /// This field was used by the old state sync protocol, but is now unused and is always set + /// to zero. + /// TODO: rename to reserved and assert that it is zero, once it is actually set to zero + /// in all superblocks (in the next release). + sync_view: u32 = 0, + + /// The last view in which the replica's status was normal. + log_view: u32, + + /// The view number of the replica. + view: u32, + + /// Number of replicas (determines sizes of the quorums), part of VSR configuration. + replica_count: u8, + + reserved: [779]u8 = @splat(0), + + comptime { + assert(@sizeOf(VSRState) == 2048); + // Assert that there is no implicit padding in the struct. + assert(stdx.no_padding(VSRState)); + } + + pub fn root(options: struct { + cluster: u128, + replica_id: u128, + members: vsr.Members, + replica_count: u8, + release: vsr.Release, + view: u32, + }) VSRState { + return .{ + .checkpoint = .{ + .header = vsr.Header.Prepare.root(options.cluster), + .parent_checkpoint_id = 0, + .grandparent_checkpoint_id = 0, + .free_set_blocks_acquired_checksum = comptime vsr.checksum(&.{}), + .free_set_blocks_released_checksum = comptime vsr.checksum(&.{}), + .free_set_blocks_acquired_last_block_checksum = 0, + .free_set_blocks_released_last_block_checksum = 0, + .free_set_blocks_acquired_last_block_address = 0, + .free_set_blocks_released_last_block_address = 0, + .free_set_blocks_acquired_size = 0, + .free_set_blocks_released_size = 0, + .client_sessions_checksum = comptime vsr.checksum(&.{}), + .client_sessions_last_block_checksum = 0, + .client_sessions_last_block_address = 0, + .client_sessions_size = 0, + .manifest_oldest_checksum = 0, + .manifest_oldest_address = 0, + .manifest_newest_checksum = 0, + .manifest_newest_address = 0, + .manifest_block_count = 0, + .snapshots_block_checksum = 0, + .snapshots_block_address = 0, + .storage_size = data_file_size_min, + .release = options.release, + }, + .replica_id = options.replica_id, + .members = options.members, + .replica_count = options.replica_count, + .commit_max = 0, + .sync_op_min = 0, + .sync_op_max = 0, + .log_view = 0, + .view = options.view, + }; + } + + pub fn assert_internally_consistent(state: VSRState) void { + assert(state.commit_max >= state.checkpoint.header.op); + assert(state.sync_op_max >= state.sync_op_min); + assert(state.view >= state.log_view); + assert(state.replica_count > 0); + assert(state.replica_count <= constants.replicas_max); + assert(vsr.member_index(&state.members, state.replica_id) != null); + + // These fields are unused at the moment: + assert(state.checkpoint.snapshots_block_checksum == 0); + assert(state.checkpoint.snapshots_block_address == 0); + + assert(state.checkpoint.manifest_oldest_checksum_padding == 0); + assert(state.checkpoint.manifest_newest_checksum_padding == 0); + assert(state.checkpoint.snapshots_block_checksum_padding == 0); + assert(state.checkpoint.free_set_blocks_acquired_last_block_checksum_padding == 0); + assert(state.checkpoint.free_set_blocks_released_last_block_checksum_padding == 0); + + assert(state.checkpoint.client_sessions_last_block_checksum_padding == 0); + assert(state.checkpoint.storage_size >= data_file_size_min); + + if (state.checkpoint.free_set_blocks_acquired_last_block_address == 0) { + assert(state.checkpoint.free_set_blocks_acquired_size == 0); + assert(state.checkpoint.free_set_blocks_acquired_checksum == + comptime vsr.checksum(&.{})); + assert(state.checkpoint.free_set_blocks_acquired_last_block_checksum == 0); + } else { + assert(state.checkpoint.free_set_blocks_acquired_size > 0); + } + + if (state.checkpoint.free_set_blocks_released_last_block_address == 0) { + assert(state.checkpoint.free_set_blocks_released_size == 0); + assert(state.checkpoint.free_set_blocks_released_checksum == + comptime vsr.checksum(&.{})); + assert(state.checkpoint.free_set_blocks_released_last_block_checksum == 0); + } else { + assert(state.checkpoint.free_set_blocks_released_size > 0); + } + + if (state.checkpoint.client_sessions_last_block_address == 0) { + assert(state.checkpoint.client_sessions_last_block_checksum == 0); + assert(state.checkpoint.client_sessions_size == 0); + assert(state.checkpoint.client_sessions_checksum == comptime vsr.checksum(&.{})); + } else { + assert(state.checkpoint.client_sessions_size == vsr.ClientSessions.encode_size); + } + + if (state.checkpoint.manifest_block_count == 0) { + assert(state.checkpoint.manifest_oldest_address == 0); + assert(state.checkpoint.manifest_newest_address == 0); + assert(state.checkpoint.manifest_oldest_checksum == 0); + assert(state.checkpoint.manifest_newest_checksum == 0); + } else { + assert(state.checkpoint.manifest_oldest_address != 0); + assert(state.checkpoint.manifest_newest_address != 0); + + assert((state.checkpoint.manifest_block_count == 1) == + (state.checkpoint.manifest_oldest_address == + state.checkpoint.manifest_newest_address)); + + assert((state.checkpoint.manifest_block_count == 1) == + (state.checkpoint.manifest_oldest_checksum == + state.checkpoint.manifest_newest_checksum)); + } + } + + pub fn monotonic(old: VSRState, new: VSRState) bool { + old.assert_internally_consistent(); + new.assert_internally_consistent(); + if (old.checkpoint.header.op == new.checkpoint.header.op) { + if (old.checkpoint.header.checksum == 0 and old.checkpoint.header.op == 0) { + // "old" is the root VSRState. + assert(old.commit_max == 0); + assert(old.sync_op_min == 0); + assert(old.sync_op_max == 0); + assert(old.log_view == 0); + assert(old.view == 0); + } else { + assert(stdx.equal_bytes(CheckpointState, &old.checkpoint, &new.checkpoint)); + } + } else { + assert(old.checkpoint.header.checksum != new.checkpoint.header.checksum); + assert(old.checkpoint.parent_checkpoint_id != + new.checkpoint.parent_checkpoint_id); + } + assert(old.replica_id == new.replica_id); + assert(old.replica_count == new.replica_count); + assert(stdx.equal_bytes([constants.members_max]u128, &old.members, &new.members)); + + if (old.checkpoint.header.op > new.checkpoint.header.op) return false; + if (old.view > new.view) return false; + if (old.log_view > new.log_view) return false; + if (old.commit_max > new.commit_max) return false; + + return true; + } + + pub fn would_be_updated_by(old: VSRState, new: VSRState) bool { + assert(monotonic(old, new)); + + return !stdx.equal_bytes(VSRState, &old, &new); + } + + /// Compaction is one bar ahead of superblock's commit_min. + /// The commits from the bar following commit_min were in the mutable table, and + /// thus not preserved in the checkpoint. + /// But the corresponding `compact()` updates were preserved, and must not be repeated + /// to ensure deterministic storage. + pub fn op_compacted(state: VSRState, op: u64) bool { + // If commit_min is 0, we have never checkpointed, so no compactions are checkpointed. + return state.checkpoint.header.op > 0 and + op <= vsr.Checkpoint.trigger_for_checkpoint(state.checkpoint.header.op).?; + } + }; + + /// The content of CheckpointState is deterministic for the corresponding checkpoint. + /// + /// This struct is sent in a View message from the primary to a syncing replica. + pub const CheckpointState = extern struct { + /// The last prepare of the checkpoint committed to the state machine. + /// At startup, replay the log hereafter. + header: vsr.Header.Prepare, + + free_set_blocks_acquired_last_block_checksum: u128, + free_set_blocks_acquired_last_block_checksum_padding: u128 = 0, + + free_set_blocks_released_last_block_checksum: u128, + free_set_blocks_released_last_block_checksum_padding: u128 = 0, + + client_sessions_last_block_checksum: u128, + client_sessions_last_block_checksum_padding: u128 = 0, + manifest_oldest_checksum: u128, + manifest_oldest_checksum_padding: u128 = 0, + manifest_newest_checksum: u128, + manifest_newest_checksum_padding: u128 = 0, + snapshots_block_checksum: u128, + snapshots_block_checksum_padding: u128 = 0, + + /// Checksum covering the entire encoded free set. Strictly speaking it is redundant: + /// free_set_last_block_checksum indirectly covers the same data. It is still useful + /// to protect from encoding-decoding bugs as a defense in depth. + free_set_blocks_acquired_checksum: u128, + free_set_blocks_released_checksum: u128, + + /// Checksum covering the entire client sessions, as defense-in-depth. + client_sessions_checksum: u128, + + /// The checkpoint_id() of the checkpoint which last updated our commit_min. + /// Following state sync, this is set to the last checkpoint that we skipped. + parent_checkpoint_id: u128, + /// The parent_checkpoint_id of the parent checkpoint. + /// TODO We might be able to remove this when + /// https://github.com/tigerbeetle/tigerbeetle/issues/1378 is fixed. + grandparent_checkpoint_id: u128, + + free_set_blocks_acquired_last_block_address: u64, + free_set_blocks_released_last_block_address: u64, + + client_sessions_last_block_address: u64, + manifest_oldest_address: u64, + manifest_newest_address: u64, + snapshots_block_address: u64, + + // Logical storage size in bytes. + // + // If storage_size is less than the data file size, then the grid blocks beyond storage_size + // were used previously, but have since been freed. + // + // If storage_size is more than the data file size, then the data file might have been + // truncated/corrupted. + storage_size: u64, + + // Size of the encoded trailers in bytes. + // It is equal to the sum of sizes of individual trailer blocks and is used for assertions. + free_set_blocks_acquired_size: u64, + free_set_blocks_released_size: u64, + + client_sessions_size: u64, + + /// The number of manifest blocks in the manifest log. + manifest_block_count: u32, + + /// All prepares between `CheckpointState.commit_min` (i.e. `op_checkpoint`) and + /// `trigger_for_checkpoint(checkpoint_after(commit_min))` must be executed by this release. + /// (Prepares with `operation=upgrade` are the exception – upgrades in the last + /// `lsm_compaction_ops` before a checkpoint trigger may be replayed by a different release. + release: vsr.Release, + + reserved: [408]u8 = @splat(0), + + comptime { + assert(@sizeOf(CheckpointState) % @sizeOf(u128) == 0); + assert(@sizeOf(CheckpointState) == 1024); + assert(stdx.no_padding(CheckpointState)); + } + }; + + pub fn calculate_checksum(superblock: *const SuperBlockHeader) u128 { + comptime assert(meta.fieldIndex(SuperBlockHeader, "checksum") == 0); + comptime assert(meta.fieldIndex(SuperBlockHeader, "checksum_padding") == 1); + comptime assert(meta.fieldIndex(SuperBlockHeader, "copy") == 2); + + const checksum_size = @sizeOf(@TypeOf(superblock.checksum)); + comptime assert(checksum_size == @sizeOf(u128)); + + const checksum_padding_size = @sizeOf(@TypeOf(superblock.checksum_padding)); + comptime assert(checksum_padding_size == @sizeOf(u128)); + + const copy_size = @sizeOf(@TypeOf(superblock.copy)); + comptime assert(copy_size == 2); + + const ignore_size = checksum_size + checksum_padding_size + copy_size; + + return vsr.checksum(std.mem.asBytes(superblock)[ignore_size..]); + } + + pub fn set_checksum(superblock: *SuperBlockHeader) void { + // `copy` is not covered by the checksum, but for our staging/working superblock headers it + // should always be zero. + assert(superblock.copy < constants.superblock_copies); + assert(superblock.copy == 0); + + assert(superblock.version == SuperBlockVersion); + assert(superblock.release_format.value > 0); + assert(superblock.flags == 0); + + assert(stdx.zeroed(&superblock.reserved)); + assert(stdx.zeroed(&superblock.vsr_state.reserved)); + assert(stdx.zeroed(&superblock.vsr_state.checkpoint.reserved)); + assert(stdx.zeroed(&superblock.view_headers_reserved)); + + assert(superblock.checksum_padding == 0); + assert(superblock.parent_padding == 0); + + superblock.checksum = superblock.calculate_checksum(); + } + + pub fn valid_checksum(superblock: *const SuperBlockHeader) bool { + return superblock.checksum == superblock.calculate_checksum() and + superblock.checksum_padding == 0; + } + + pub fn checkpoint_id(superblock: *const SuperBlockHeader) u128 { + return vsr.checksum(std.mem.asBytes(&superblock.vsr_state.checkpoint)); + } + + pub fn parent_checkpoint_id(superblock: *const SuperBlockHeader) u128 { + return superblock.vsr_state.checkpoint.parent_checkpoint_id; + } + + /// Does not consider { checksum, copy } when comparing equality. + pub fn equal(a: *const SuperBlockHeader, b: *const SuperBlockHeader) bool { + assert(a.release_format.value == b.release_format.value); + + assert(stdx.zeroed(&a.reserved)); + assert(stdx.zeroed(&b.reserved)); + + assert(stdx.zeroed(&a.vsr_state.reserved)); + assert(stdx.zeroed(&b.vsr_state.reserved)); + + assert(stdx.zeroed(&a.view_headers_reserved)); + assert(stdx.zeroed(&b.view_headers_reserved)); + + assert(a.checksum_padding == 0); + assert(b.checksum_padding == 0); + assert(a.parent_padding == 0); + assert(b.parent_padding == 0); + + if (a.version != b.version) return false; + if (a.cluster != b.cluster) return false; + if (a.sequence != b.sequence) return false; + if (a.parent != b.parent) return false; + if (!stdx.equal_bytes(VSRState, &a.vsr_state, &b.vsr_state)) return false; + if (a.view_headers_count != b.view_headers_count) return false; + if (!stdx.equal_bytes( + [constants.view_headers_max]vsr.Header.Prepare, + &a.view_headers_all, + &b.view_headers_all, + )) return false; + + return true; + } + + pub fn view_headers(superblock: *const SuperBlockHeader) vsr.Headers.ViewChangeSlice { + return vsr.Headers.ViewChangeSlice.init( + if (superblock.vsr_state.log_view < superblock.vsr_state.view) + .join_view + else + .view, + superblock.view_headers_all[0..superblock.view_headers_count], + ); + } + + pub fn manifest_references(superblock: *const SuperBlockHeader) ManifestReferences { + const checkpoint_state = &superblock.vsr_state.checkpoint; + return .{ + .oldest_address = checkpoint_state.manifest_oldest_address, + .oldest_checksum = checkpoint_state.manifest_oldest_checksum, + .newest_address = checkpoint_state.manifest_newest_address, + .newest_checksum = checkpoint_state.manifest_newest_checksum, + .block_count = checkpoint_state.manifest_block_count, + }; + } + + pub fn free_set_reference( + superblock: *const SuperBlockHeader, + bitset: vsr.FreeSet.BitsetKind, + ) TrailerReference { + switch (bitset) { + .blocks_acquired => { + return .{ + .checksum = superblock.vsr_state.checkpoint + .free_set_blocks_acquired_checksum, + .last_block_address = superblock.vsr_state.checkpoint + .free_set_blocks_acquired_last_block_address, + .last_block_checksum = superblock.vsr_state.checkpoint + .free_set_blocks_acquired_last_block_checksum, + .trailer_size = superblock.vsr_state.checkpoint + .free_set_blocks_acquired_size, + }; + }, + .blocks_released => { + return .{ + .checksum = superblock.vsr_state.checkpoint + .free_set_blocks_released_checksum, + .last_block_address = superblock.vsr_state.checkpoint + .free_set_blocks_released_last_block_address, + .last_block_checksum = superblock.vsr_state.checkpoint + .free_set_blocks_released_last_block_checksum, + .trailer_size = superblock.vsr_state.checkpoint + .free_set_blocks_released_size, + }; + }, + } + } + + pub fn client_sessions_reference(superblock: *const SuperBlockHeader) TrailerReference { + const checkpoint = &superblock.vsr_state.checkpoint; + return .{ + .checksum = checkpoint.client_sessions_checksum, + .last_block_address = checkpoint.client_sessions_last_block_address, + .last_block_checksum = checkpoint.client_sessions_last_block_checksum, + .trailer_size = checkpoint.client_sessions_size, + }; + } +}; + +pub const ManifestReferences = struct { + /// The chronologically first manifest block in the chain. + oldest_checksum: u128, + oldest_address: u64, + /// The chronologically last manifest block in the chain. + newest_checksum: u128, + newest_address: u64, + /// The number of manifest blocks in the chain. + block_count: u32, + + pub fn empty(references: *const ManifestReferences) bool { + if (references.block_count == 0) { + assert(references.oldest_address == 0); + assert(references.oldest_checksum == 0); + assert(references.newest_address == 0); + assert(references.newest_checksum == 0); + return true; + } else { + assert(references.oldest_address != 0); + assert(references.newest_address != 0); + return false; + } + } +}; + +pub const TrailerReference = struct { + /// Checksum over the entire encoded trailer. + checksum: u128, + last_block_address: u64, + last_block_checksum: u128, + trailer_size: u64, + + pub fn empty(reference: *const TrailerReference) bool { + if (reference.trailer_size == 0) { + assert(reference.checksum == vsr.checksum(&.{})); + assert(reference.last_block_address == 0); + assert(reference.last_block_checksum == 0); + return true; + } else { + assert(reference.last_block_address > 0); + return false; + } + } +}; + +comptime { + switch (constants.superblock_copies) { + 4, 6, 8 => {}, + else => @compileError("superblock_copies must be either { 4, 6, 8 } for flexible quorums."), + } +} + +/// The size of the entire superblock storage zone. +pub const superblock_zone_size = superblock_copy_size * constants.superblock_copies; + +/// Leave enough padding after every superblock copy so that it is feasible, in the future, to +/// modify the `pipeline_prepare_queue_max` of an existing cluster (up to a maximum of clients_max). +/// (That is, this space is reserved for potential `view_headers`). +const superblock_copy_padding: comptime_int = stdx.div_ceil( + (constants.clients_max - constants.pipeline_prepare_queue_max) * @sizeOf(vsr.Header), + constants.sector_size, +) * constants.sector_size; + +/// The size of an individual superblock header copy, including padding. +pub const superblock_copy_size = @sizeOf(SuperBlockHeader) + superblock_copy_padding; +comptime { + assert(superblock_copy_padding % constants.sector_size == 0); + assert(superblock_copy_size % constants.sector_size == 0); +} + +/// The size of a data file that has an empty grid. +pub const data_file_size_min = + superblock_zone_size + + constants.journal_size + + constants.client_replies_size + + vsr.Zone.size(.grid_padding).?; + +/// This table shows the sequence number progression of the SuperBlock's headers. +/// +/// action working staging disk +/// format seq seq seq +/// 0 - Initially the file has no headers. +/// 0 1 - +/// 0 1 1 Write a copyset for the first sequence. +/// 1 1 1 Read quorum; verify 3/4 are valid. +/// +/// open seq seq seq +/// a +/// a a Read quorum; verify 2/4 are valid. +/// a (a) a Repair any broken copies of `a`. +/// +/// checkpoint seq seq seq +/// a a a +/// a a+1 +/// a a+1 a+1 +/// a+1 a+1 a+1 Read quorum; verify 3/4 are valid. +/// +/// view_change seq seq seq +/// a a +/// a a+1 a The new sequence reuses the original parent. +/// a a+1 a+1 +/// a+1 a+1 a+1 Read quorum; verify 3/4 are valid. +/// working staging disk +/// +pub fn SuperBlockType(comptime Storage: type) type { + return struct { + const SuperBlock = @This(); + + pub const Context = struct { + superblock: *SuperBlock, + callback: *const fn (context: *Context) void, + caller: Caller, + + write: Storage.Write = undefined, + read: Storage.Read = undefined, + read_threshold: ?Quorums.Threshold = null, + copy: ?u8 = null, + /// Used by format(), checkpoint(), view_change(). + vsr_state: ?SuperBlockHeader.VSRState = null, + /// Used by format() and view_change(). + view_headers: ?vsr.Headers.ViewChangeArray = null, + repairs: ?Quorums.RepairIterator = null, // Used by open(). + }; + + storage: *Storage, + + /// The superblock that was recovered at startup after a crash or that was last written. + working: *align(constants.sector_size) SuperBlockHeader, + + /// The superblock that will replace the current working superblock once written. + /// We cannot mutate any working state directly until it is safely on stable storage. + /// Otherwise, we may accidentally externalize guarantees that are not yet durable. + staging: *align(constants.sector_size) SuperBlockHeader, + + /// The copies that we read into at startup or when verifying the written superblock. + reading: []align(constants.sector_size) SuperBlockHeader, + + /// It might seem that, at startup, we simply install the copy with the highest sequence. + /// + /// However, there's a scenario where: + /// 1. We are able to write sequence 7 to 3/4 copies, with the last write being lost. + /// 2. We startup and read all copies, with reads misdirected to the copy with sequence 6. + /// + /// Another scenario: + /// 1. We begin to write sequence 7 to 1 copy and then crash. + /// 2. At startup, the read to this copy fails, and we recover at sequence=6. + /// 3. We then checkpoint another sequence 7 to 3/4 copies and crash. + /// 4. At startup, we then see 4 copies with the same sequence with 1 checksum different. + /// + /// To mitigate these scenarios, we ensure that we are able to read a quorum of copies. + /// This also gives us confidence that our working superblock has sufficient redundancy. + quorums: Quorums = Quorums{}, + + /// Whether the superblock has been opened. An open superblock may not be formatted. + opened: bool = false, + /// Runtime limit on the size of the datafile. + storage_size_limit: u64, + + /// There may only be a single caller queued at a time, to ensure that the VSR protocol is + /// careful to submit at most one view change at a time. + queue_head: ?*Context = null, + queue_tail: ?*Context = null, + + /// Set to non-null after open(). + /// Used for logging. + replica_index: ?u8 = null, + + pub fn init(gpa: mem.Allocator, storage: *Storage, options: struct { + storage_size_limit: u64, + }) !SuperBlock { + assert(options.storage_size_limit >= data_file_size_min); + assert(options.storage_size_limit <= constants.storage_size_limit_max); + assert(options.storage_size_limit % constants.sector_size == 0); + + const a = try gpa.alignedAlloc(SuperBlockHeader, constants.sector_size, 1); + errdefer gpa.free(a); + + const b = try gpa.alignedAlloc(SuperBlockHeader, constants.sector_size, 1); + errdefer gpa.free(b); + + const reading = try gpa.alignedAlloc( + [constants.superblock_copies]SuperBlockHeader, + constants.sector_size, + 1, + ); + errdefer gpa.free(reading); + + return SuperBlock{ + .storage = storage, + .working = &a[0], + .staging = &b[0], + .reading = &reading[0], + .storage_size_limit = options.storage_size_limit, + }; + } + + pub fn deinit(superblock: *SuperBlock, gpa: mem.Allocator) void { + gpa.destroy(superblock.working); + gpa.destroy(superblock.staging); + gpa.free(superblock.reading); + } + + pub const FormatOptions = struct { + cluster: u128, + release: vsr.Release, + replica: u8, + replica_count: u8, + /// Set to null during initial cluster formatting. + /// Set to the target view when constructing a new data file for a reformatted replica. + view: ?u32, + }; + + pub fn format( + superblock: *SuperBlock, + callback: *const fn (context: *Context) void, + context: *Context, + options: FormatOptions, + ) void { + assert(!superblock.opened); + assert(superblock.replica_index == null); + + assert(options.release.value > 0); + assert(options.replica_count > 0); + assert(options.replica_count <= constants.replicas_max); + assert(options.replica < options.replica_count + constants.standbys_max); + if (options.view) |view| { + assert(view > 1); + assert(options.replica < options.replica_count); + } + + const members = vsr.root_members(options.cluster); + const replica_id = members[options.replica]; + + superblock.replica_index = vsr.member_index(&members, replica_id); + + // This working copy provides the parent checksum, and will not be written to disk. + // We therefore use zero values to make this parent checksum as stable as possible. + superblock.working.* = .{ + .copy = 0, + .version = SuperBlockVersion, + .sequence = 0, + .release_format = options.release, + .cluster = options.cluster, + .parent = 0, + .vsr_state = .{ + .checkpoint = .{ + .header = mem.zeroes(vsr.Header.Prepare), + .parent_checkpoint_id = 0, + .grandparent_checkpoint_id = 0, + .manifest_oldest_checksum = 0, + .manifest_oldest_address = 0, + .manifest_newest_checksum = 0, + .manifest_newest_address = 0, + .manifest_block_count = 0, + .free_set_blocks_acquired_checksum = 0, + .free_set_blocks_released_checksum = 0, + .free_set_blocks_acquired_last_block_checksum = 0, + .free_set_blocks_released_last_block_checksum = 0, + .free_set_blocks_acquired_last_block_address = 0, + .free_set_blocks_released_last_block_address = 0, + .free_set_blocks_acquired_size = 0, + .free_set_blocks_released_size = 0, + .client_sessions_checksum = 0, + .client_sessions_last_block_checksum = 0, + .client_sessions_last_block_address = 0, + .client_sessions_size = 0, + .storage_size = 0, + .snapshots_block_checksum = 0, + .snapshots_block_address = 0, + .release = vsr.Release.zero, + }, + .replica_id = replica_id, + .members = members, + .commit_max = 0, + .sync_op_min = 0, + .sync_op_max = 0, + .sync_view = 0, + .log_view = 0, + .view = 0, + .replica_count = options.replica_count, + }, + .view_headers_count = 0, + .view_headers_all = @splat(mem.zeroes(vsr.Header.Prepare)), + }; + + superblock.working.set_checksum(); + + context.* = .{ + .superblock = superblock, + .callback = callback, + .caller = .format, + .vsr_state = SuperBlockHeader.VSRState.root(.{ + .cluster = options.cluster, + .release = options.release, + .replica_id = replica_id, + .members = members, + .replica_count = options.replica_count, + .view = options.view orelse 0, + }), + .view_headers = vsr.Headers.ViewChangeArray.root(options.cluster), + }; + + superblock.acquire(context); + } + + pub fn open( + superblock: *SuperBlock, + callback: *const fn (context: *Context) void, + context: *Context, + ) void { + assert(!superblock.opened); + + context.* = .{ + .superblock = superblock, + .callback = callback, + .caller = .open, + }; + + superblock.acquire(context); + } + + const UpdateCheckpoint = struct { + header: vsr.Header.Prepare, + view_attributes: ?struct { + log_view: u32, + view: u32, + headers: *const vsr.Headers.ViewChangeArray, + }, + commit_max: u64, + sync_op_min: u64, + sync_op_max: u64, + manifest_references: ManifestReferences, + free_set_references: struct { + blocks_acquired: TrailerReference, + blocks_released: TrailerReference, + }, + client_sessions_reference: TrailerReference, + storage_size: u64, + release: vsr.Release, + }; + + /// Must update the commit_min and commit_min_checksum. + pub fn checkpoint( + superblock: *SuperBlock, + callback: *const fn (context: *Context) void, + context: *Context, + update: UpdateCheckpoint, + ) void { + assert(superblock.opened); + assert(update.header.op <= update.commit_max); + assert(update.header.op > superblock.staging.vsr_state.checkpoint.header.op); + assert(update.header.checksum != + superblock.staging.vsr_state.checkpoint.header.checksum); + assert(update.sync_op_min <= update.sync_op_max); + assert(update.release.value >= superblock.staging.vsr_state.checkpoint.release.value); + + assert(update.storage_size <= superblock.storage_size_limit); + assert(update.storage_size >= data_file_size_min); + assert((update.storage_size == data_file_size_min) == + (update.free_set_references.blocks_acquired.empty() and + update.free_set_references.blocks_released.empty())); + + // NOTE: Within the vsr_state.checkpoint assignment below, do not read from vsr_state + // directly. A miscompilation bug (as of Zig 0.11.0) causes fields to receive the + // incorrect values. + const vsr_state_staging = superblock.staging.vsr_state; + const update_client_sessions = &update.client_sessions_reference; + + var vsr_state = superblock.staging.vsr_state; + vsr_state.checkpoint = .{ + .header = update.header, + .parent_checkpoint_id = superblock.staging.checkpoint_id(), + .grandparent_checkpoint_id = vsr_state_staging.checkpoint.parent_checkpoint_id, + + .free_set_blocks_acquired_checksum = update.free_set_references + .blocks_acquired.checksum, + .free_set_blocks_released_checksum = update.free_set_references + .blocks_released.checksum, + + .free_set_blocks_acquired_size = update.free_set_references + .blocks_acquired.trailer_size, + .free_set_blocks_released_size = update.free_set_references + .blocks_released.trailer_size, + + .free_set_blocks_acquired_last_block_checksum = update.free_set_references + .blocks_acquired.last_block_checksum, + .free_set_blocks_released_last_block_checksum = update.free_set_references + .blocks_released.last_block_checksum, + + .free_set_blocks_acquired_last_block_address = update.free_set_references + .blocks_acquired.last_block_address, + .free_set_blocks_released_last_block_address = update.free_set_references + .blocks_released.last_block_address, + + .client_sessions_checksum = update_client_sessions.checksum, + .client_sessions_last_block_checksum = update_client_sessions.last_block_checksum, + .client_sessions_last_block_address = update_client_sessions.last_block_address, + .client_sessions_size = update.client_sessions_reference.trailer_size, + + .manifest_oldest_checksum = update.manifest_references.oldest_checksum, + .manifest_oldest_address = update.manifest_references.oldest_address, + .manifest_newest_checksum = update.manifest_references.newest_checksum, + .manifest_newest_address = update.manifest_references.newest_address, + .manifest_block_count = update.manifest_references.block_count, + + .storage_size = update.storage_size, + .snapshots_block_checksum = vsr_state_staging.checkpoint.snapshots_block_checksum, + .snapshots_block_address = vsr_state_staging.checkpoint.snapshots_block_address, + .release = update.release, + }; + vsr_state.commit_max = update.commit_max; + vsr_state.sync_op_min = update.sync_op_min; + vsr_state.sync_op_max = update.sync_op_max; + vsr_state.sync_view = 0; + if (update.view_attributes) |*view_attributes| { + assert(view_attributes.log_view <= view_attributes.view); + view_attributes.headers.verify(); + vsr_state.log_view = view_attributes.log_view; + vsr_state.view = view_attributes.view; + } + + assert(superblock.staging.vsr_state.would_be_updated_by(vsr_state)); + + context.* = .{ + .superblock = superblock, + .callback = callback, + .caller = .checkpoint, + .vsr_state = vsr_state, + .view_headers = if (update.view_attributes) |*view_attributes| + view_attributes.headers.* + else + vsr.Headers.ViewChangeArray.init( + superblock.staging.view_headers().command, + superblock.staging.view_headers().slice, + ), + }; + superblock.log_context(context); + superblock.acquire(context); + } + + const UpdateViewChange = struct { + commit_max: u64, + log_view: u32, + view: u32, + headers: *const vsr.Headers.ViewChangeArray, + sync_checkpoint: ?struct { + checkpoint: *const vsr.CheckpointState, + sync_op_min: u64, + sync_op_max: u64, + }, + }; + + /// The replica calls view_change(): + /// + /// - to persist its view/log_view — it cannot advertise either value until it is certain + /// they will never backtrack. + /// - to update checkpoint during sync + /// + /// The update must advance view/log_view (monotonically increasing) or checkpoint. + // TODO: the current naming confusing and needs changing: during sync, this function doesn't + // necessary advance the view. + pub fn view_change( + superblock: *SuperBlock, + callback: *const fn (context: *Context) void, + context: *Context, + update: UpdateViewChange, + ) void { + assert(superblock.opened); + assert(superblock.staging.vsr_state.commit_max <= update.commit_max); + assert(superblock.staging.vsr_state.view <= update.view); + assert(superblock.staging.vsr_state.log_view <= update.log_view); + assert(superblock.staging.vsr_state.log_view < update.log_view or + superblock.staging.vsr_state.view < update.view or + update.sync_checkpoint != null); + assert((update.headers.command == .view and update.log_view == update.view) or + (update.headers.command == .join_view and update.log_view < update.view)); + assert( + superblock.staging.vsr_state.checkpoint.header.op <= update.headers.array.get(0).op, + ); + + update.headers.verify(); + assert(update.view >= update.log_view); + + var vsr_state = superblock.staging.vsr_state; + vsr_state.commit_max = update.commit_max; + vsr_state.log_view = update.log_view; + vsr_state.view = update.view; + if (update.sync_checkpoint) |*sync_checkpoint| { + assert(superblock.staging.vsr_state.checkpoint.header.op < + sync_checkpoint.checkpoint.header.op); + + const checkpoint_next = vsr.Checkpoint.checkpoint_after( + superblock.staging.vsr_state.checkpoint.header.op, + ); + const checkpoint_next_next = vsr.Checkpoint.checkpoint_after(checkpoint_next); + + if (sync_checkpoint.checkpoint.header.op == checkpoint_next) { + assert(sync_checkpoint.checkpoint.parent_checkpoint_id == + superblock.staging.checkpoint_id()); + } else if (sync_checkpoint.checkpoint.header.op == checkpoint_next_next) { + assert(sync_checkpoint.checkpoint.grandparent_checkpoint_id == + superblock.staging.checkpoint_id()); + } + + vsr_state.checkpoint = sync_checkpoint.checkpoint.*; + vsr_state.sync_op_min = sync_checkpoint.sync_op_min; + vsr_state.sync_op_max = sync_checkpoint.sync_op_max; + } + assert(superblock.staging.vsr_state.would_be_updated_by(vsr_state)); + + context.* = .{ + .superblock = superblock, + .callback = callback, + .caller = .view_change, + .vsr_state = vsr_state, + .view_headers = update.headers.*, + }; + superblock.log_context(context); + superblock.acquire(context); + } + + pub fn grid_size_limit(superblock: *const SuperBlock) usize { + return superblock.storage_size_limit - data_file_size_min; + } + + pub fn updating(superblock: *const SuperBlock, caller: Caller) bool { + assert(superblock.opened); + + if (superblock.queue_head) |head| { + if (head.caller == caller) return true; + } + + if (superblock.queue_tail) |tail| { + if (tail.caller == caller) return true; + } + + return false; + } + + fn write_staging(superblock: *SuperBlock, context: *Context) void { + assert(context.caller != .open); + assert(context.caller == .format or superblock.opened); + assert(context.copy == null); + context.vsr_state.?.assert_internally_consistent(); + assert(superblock.queue_head == context); + assert(superblock.queue_tail == null); + + superblock.staging.* = superblock.working.*; + superblock.staging.sequence = superblock.staging.sequence + 1; + superblock.staging.parent = superblock.staging.checksum; + superblock.staging.vsr_state = context.vsr_state.?; + + if (context.view_headers) |*headers| { + assert(context.caller.updates_view_headers()); + + superblock.staging.view_headers_count = headers.array.count_as(u32); + stdx.copy_disjoint( + .exact, + vsr.Header.Prepare, + superblock.staging.view_headers_all[0..headers.array.count()], + headers.array.const_slice(), + ); + @memset( + superblock.staging.view_headers_all[headers.array.count()..], + std.mem.zeroes(vsr.Header.Prepare), + ); + } else { + assert(!context.caller.updates_view_headers()); + } + + context.copy = 0; + superblock.staging.set_checksum(); + superblock.write_header(context); + } + + fn write_header(superblock: *SuperBlock, context: *Context) void { + assert(superblock.queue_head == context); + + // We update the working superblock for a checkpoint/format/view_change: + // open() does not update the working superblock, since it only writes to repair. + if (context.caller == .open) { + assert(superblock.staging.sequence == superblock.working.sequence); + } else { + assert(superblock.staging.sequence == superblock.working.sequence + 1); + assert(superblock.staging.parent == superblock.working.checksum); + } + + // The superblock cluster and replica should never change once formatted: + assert(superblock.staging.cluster == superblock.working.cluster); + assert(superblock.staging.vsr_state.replica_id == + superblock.working.vsr_state.replica_id); + + const storage_size = superblock.staging.vsr_state.checkpoint.storage_size; + assert(storage_size >= data_file_size_min); + assert(storage_size <= constants.storage_size_limit_max); + + assert(context.copy.? < constants.superblock_copies); + superblock.staging.copy = context.copy.?; + // Updating the copy number should not affect the checksum, which was previously set: + assert(superblock.staging.valid_checksum()); + + const buffer = mem.asBytes(superblock.staging); + const offset = superblock_copy_size * @as(u32, context.copy.?); + + log.debug("{?}: {s}: write_header: " ++ + "checksum={x:0>32} sequence={} copy={} size={} offset={}", .{ + superblock.replica_index, + @tagName(context.caller), + superblock.staging.checksum, + superblock.staging.sequence, + context.copy.?, + buffer.len, + offset, + }); + + SuperBlock.assert_bounds(offset, buffer.len); + + superblock.storage.write_sectors( + write_header_callback, + &context.write, + buffer, + .superblock, + offset, + ); + } + + fn write_header_callback(write: *Storage.Write) void { + const context: *Context = @alignCast(@fieldParentPtr("write", write)); + const superblock = context.superblock; + const copy = context.copy.?; + + assert(superblock.queue_head == context); + + assert(copy < constants.superblock_copies); + assert(copy == superblock.staging.copy); + + if (context.caller == .open) { + context.copy = null; + superblock.repair(context); + return; + } + + if (copy + 1 == constants.superblock_copies) { + context.copy = null; + superblock.read_working(context, .verify); + } else { + context.copy = copy + 1; + superblock.write_header(context); + } + } + + fn read_working( + superblock: *SuperBlock, + context: *Context, + threshold: Quorums.Threshold, + ) void { + assert(superblock.queue_head == context); + assert(context.copy == null); + assert(context.read_threshold == null); + + // We do not submit reads in parallel, as while this would shave off 1ms, it would also + // increase the risk that a single fault applies to more reads due to temporal locality. + // This would make verification reads more flaky when we do experience a read fault. + // See "An Analysis of Data Corruption in the Storage Stack". + + context.copy = 0; + context.read_threshold = threshold; + for (superblock.reading) |*copy| copy.* = undefined; + superblock.read_header(context); + } + + fn read_header(superblock: *SuperBlock, context: *Context) void { + assert(superblock.queue_head == context); + assert(context.copy.? < constants.superblock_copies); + assert(context.read_threshold != null); + + const buffer = mem.asBytes(&superblock.reading[context.copy.?]); + const offset = superblock_copy_size * @as(u32, context.copy.?); + + log.debug("{?}: {s}: read_header: copy={} size={} offset={}", .{ + superblock.replica_index, + @tagName(context.caller), + context.copy.?, + buffer.len, + offset, + }); + + SuperBlock.assert_bounds(offset, buffer.len); + + superblock.storage.read_sectors( + read_header_callback, + &context.read, + buffer, + .superblock, + offset, + ); + } + + fn read_header_callback(read: *Storage.Read) void { + const context: *Context = @alignCast(@fieldParentPtr("read", read)); + const superblock = context.superblock; + const threshold = context.read_threshold.?; + + assert(superblock.queue_head == context); + + assert(context.copy.? < constants.superblock_copies); + if (context.copy.? + 1 != constants.superblock_copies) { + context.copy = context.copy.? + 1; + superblock.read_header(context); + return; + } + + context.read_threshold = null; + context.copy = null; + + if (superblock.quorums.working(superblock.reading, threshold)) |quorum| { + assert(quorum.valid); + assert(quorum.copies.count() >= threshold.count()); + maybe(quorum.header.copy >= constants.superblock_copies); // `copy` may be corrupt. + + const working = quorum.header; + + if (working.version != SuperBlockVersion) { + log.err("found incompatible superblock version {}", .{working.version}); + @panic("cannot read superblock with incompatible version"); + } + + if (threshold == .verify) { + if (working.checksum != superblock.staging.checksum) { + @panic("superblock failed verification after writing"); + } + assert(working.equal(superblock.staging)); + } + + if (context.caller == .format) { + assert(working.sequence == 1); + assert(working.vsr_state.checkpoint.header.checksum == + vsr.Header.Prepare.root(working.cluster).checksum); + assert(working.vsr_state.checkpoint.free_set_blocks_acquired_size == 0); + assert(working.vsr_state.checkpoint.free_set_blocks_released_size == 0); + assert(working.vsr_state.checkpoint.client_sessions_size == 0); + assert(working.vsr_state.checkpoint.storage_size == data_file_size_min); + assert(working.vsr_state.checkpoint.header.op == 0); + assert(working.vsr_state.commit_max == 0); + assert(working.vsr_state.log_view == 0); + maybe(working.vsr_state.view == 0); // On reformat view≠0. + assert(working.view_headers_count == 1); + + assert(working.vsr_state.replica_count <= constants.replicas_max); + assert(vsr.member_index( + &working.vsr_state.members, + working.vsr_state.replica_id, + ) != null); + } + + superblock.working.* = working.*; + superblock.staging.* = working.*; + + // Reset the copies, which may be nonzero due to corruption. + superblock.working.copy = 0; + superblock.staging.copy = 0; + + const working_checkpoint = &superblock.working.vsr_state.checkpoint; + + log.debug( + "{[replica]?}: " ++ + "{[caller]s}: installed working superblock: checksum={[checksum]x:0>32} " ++ + "sequence={[sequence]} " ++ + "release={[release]} " ++ + "cluster={[cluster]x:0>32} replica_id={[replica_id]} " ++ + "size={[size]} " ++ + "free_set_blocks_acquired_size={[free_set_blocks_acquired_size]} " ++ + "free_set_blocks_released_size={[free_set_blocks_released_size]} " ++ + "client_sessions_size={[client_sessions_size]} " ++ + "checkpoint_id={[checkpoint_id]x:0>32} " ++ + "commit_min_checksum={[commit_min_checksum]x:0>32} " ++ + "commit_min={[commit_min]} " ++ + "commit_max={[commit_max]} log_view={[log_view]} view={[view]} " ++ + "sync_op_min={[sync_op_min]} sync_op_max={[sync_op_max]} " ++ + "manifest_oldest_checksum={[manifest_oldest_checksum]x:0>32} " ++ + "manifest_oldest_address={[manifest_oldest_address]} " ++ + "manifest_newest_checksum={[manifest_newest_checksum]x:0>32} " ++ + "manifest_newest_address={[manifest_newest_address]} " ++ + "manifest_block_count={[manifest_block_count]} " ++ + "snapshots_block_checksum={[snapshots_block_checksum]x:0>32} " ++ + "snapshots_block_address={[snapshots_block_address]}", + .{ + .replica = superblock.replica_index, + .caller = @tagName(context.caller), + .checksum = superblock.working.checksum, + .sequence = superblock.working.sequence, + .release = working_checkpoint.release, + .cluster = superblock.working.cluster, + .replica_id = superblock.working.vsr_state.replica_id, + .size = working_checkpoint.storage_size, + .free_set_blocks_acquired_size = working_checkpoint + .free_set_blocks_acquired_size, + .free_set_blocks_released_size = working_checkpoint + .free_set_blocks_released_size, + .client_sessions_size = working_checkpoint.client_sessions_size, + .checkpoint_id = superblock.working.checkpoint_id(), + .commit_min_checksum = working_checkpoint.header.checksum, + .commit_min = working_checkpoint.header.op, + .commit_max = superblock.working.vsr_state.commit_max, + .sync_op_min = superblock.working.vsr_state.sync_op_min, + .sync_op_max = superblock.working.vsr_state.sync_op_max, + .log_view = superblock.working.vsr_state.log_view, + .view = superblock.working.vsr_state.view, + .manifest_oldest_checksum = working_checkpoint.manifest_oldest_checksum, + .manifest_oldest_address = working_checkpoint.manifest_oldest_address, + .manifest_newest_checksum = working_checkpoint.manifest_newest_checksum, + .manifest_newest_address = working_checkpoint.manifest_newest_address, + .manifest_block_count = working_checkpoint.manifest_block_count, + .snapshots_block_checksum = working_checkpoint.snapshots_block_checksum, + .snapshots_block_address = working_checkpoint.snapshots_block_address, + }, + ); + for (superblock.working.view_headers().slice) |*header| { + log.debug("{?}: {s}: vsr_header: op={} checksum={x:0>32}", .{ + superblock.replica_index, + @tagName(context.caller), + header.op, + header.checksum, + }); + } + + if (superblock.working.vsr_state.checkpoint.storage_size > + superblock.storage_size_limit) + { + vsr.fatal( + .storage_size_exceeds_limit, + "data file too large size={} > limit={}, " ++ + "restart the replica increasing '--limit-storage'", + .{ + superblock.working.vsr_state.checkpoint.storage_size, + superblock.storage_size_limit, + }, + ); + } + + if (context.caller == .open) { + if (context.repairs) |_| { + // We just verified that the repair completed. + assert(threshold == .verify); + superblock.release(context); + } else { + assert(threshold == .open); + + context.repairs = quorum.repairs(); + context.copy = null; + superblock.repair(context); + } + } else { + // TODO Consider calling TRIM() on Grid's free suffix after checkpointing. + superblock.release(context); + } + } else |err| switch (err) { + error.Fork => @panic("superblock forked"), + error.NotFound => @panic("superblock not found"), + error.QuorumLost => @panic("superblock quorum lost"), + error.ParentNotConnected => @panic("superblock parent not connected"), + error.ParentSkipped => @panic("superblock parent superseded"), + error.VSRStateNotMonotonic => @panic("superblock vsr state not monotonic"), + } + } + + fn repair(superblock: *SuperBlock, context: *Context) void { + assert(context.caller == .open); + assert(context.copy == null); + assert(superblock.queue_head == context); + + if (context.repairs.?.next()) |repair_copy| { + context.copy = repair_copy; + log.warn("{?}: repair: copy={}", .{ superblock.replica_index, repair_copy }); + + superblock.staging.* = superblock.working.*; + superblock.write_header(context); + } else { + superblock.release(context); + } + } + + fn acquire(superblock: *SuperBlock, context: *Context) void { + if (superblock.queue_head) |head| { + // All operations are mutually exclusive with themselves. + assert(head.caller != context.caller); + assert(Caller.transitions.get(head.caller).?.contains(context.caller)); + assert(superblock.queue_tail == null); + + log.debug("{?}: {s}: enqueued after {s}", .{ + superblock.replica_index, + @tagName(context.caller), + @tagName(head.caller), + }); + + superblock.queue_tail = context; + } else { + assert(superblock.queue_tail == null); + + superblock.queue_head = context; + log.debug("{?}: {s}: started", .{ + superblock.replica_index, + @tagName(context.caller), + }); + + if (Storage == @import("../testing/storage.zig").Storage) { + // We should have finished all pending superblock io before starting any more. + superblock.storage.assert_no_pending_reads(.superblock); + superblock.storage.assert_no_pending_writes(.superblock); + } + + if (context.caller == .open) { + superblock.read_working(context, .open); + } else { + superblock.write_staging(context); + } + } + } + + fn release(superblock: *SuperBlock, context: *Context) void { + assert(superblock.queue_head == context); + + log.debug("{?}: {s}: complete", .{ + superblock.replica_index, + @tagName(context.caller), + }); + + if (Storage == @import("../testing/storage.zig").Storage) { + // We should have finished all pending io by now. + superblock.storage.assert_no_pending_reads(.superblock); + superblock.storage.assert_no_pending_writes(.superblock); + } + + switch (context.caller) { + .format => {}, + .open => { + assert(!superblock.opened); + superblock.opened = true; + superblock.replica_index = vsr.member_index( + &superblock.working.vsr_state.members, + superblock.working.vsr_state.replica_id, + ).?; + }, + .checkpoint, + .view_change, + => { + assert(stdx.equal_bytes( + SuperBlockHeader.VSRState, + &superblock.staging.vsr_state, + &context.vsr_state.?, + )); + assert(stdx.equal_bytes( + SuperBlockHeader.VSRState, + &superblock.working.vsr_state, + &context.vsr_state.?, + )); + }, + } + + const queue_tail = superblock.queue_tail; + superblock.queue_head = null; + superblock.queue_tail = null; + if (queue_tail) |tail| superblock.acquire(tail); + + context.callback(context); + } + + fn assert_bounds(offset: u64, size: u64) void { + assert(offset + size <= superblock_zone_size); + } + + fn log_context(superblock: *const SuperBlock, context: *const Context) void { + log.debug("{[replica]?}: {[caller]s}: " ++ + "commit_min={[commit_min_old]}..{[commit_min_new]} " ++ + "commit_max={[commit_max_old]}..{[commit_max_new]} " ++ + "commit_min_checksum={[commit_min_checksum_old]x:0>32}.." ++ + "{[commit_min_checksum_new]x:0>32} " ++ + "log_view={[log_view_old]}..{[log_view_new]} " ++ + "view={[view_old]}..{[view_new]} " ++ + "head={[head_old]x:0>32}..{[head_new]x:0>32}", .{ + .replica = superblock.replica_index, + .caller = @tagName(context.caller), + + .commit_min_old = superblock.staging.vsr_state.checkpoint.header.op, + .commit_min_new = context.vsr_state.?.checkpoint.header.op, + + .commit_max_old = superblock.staging.vsr_state.commit_max, + .commit_max_new = context.vsr_state.?.commit_max, + + .commit_min_checksum_old = superblock.staging.vsr_state.checkpoint.header.checksum, + .commit_min_checksum_new = context.vsr_state.?.checkpoint.header.checksum, + + .log_view_old = superblock.staging.vsr_state.log_view, + .log_view_new = context.vsr_state.?.log_view, + + .view_old = superblock.staging.vsr_state.view, + .view_new = context.vsr_state.?.view, + + .head_old = superblock.staging.view_headers().slice[0].checksum, + .head_new = if (context.view_headers) |*headers| + headers.array.get(0).checksum + else + 0, + }); + } + }; +} + +pub const Caller = enum { + format, + open, + checkpoint, + view_change, + + /// Beyond formatting and opening of the superblock, which are mutually exclusive of all + /// other operations, only the following queue combinations are allowed: + /// + /// from state → to states + const transitions = sets: { + const Set = std.enums.EnumSet(Caller); + break :sets std.enums.EnumMap(Caller, Set).init(.{ + .format = Set.init(.{}), + .open = Set.init(.{}), + .checkpoint = Set.init(.{ .view_change = true }), + .view_change = Set.init(.{ .checkpoint = true }), + }); + }; + + fn updates_view_headers(caller: Caller) bool { + return switch (caller) { + .format => true, + .open => unreachable, + .checkpoint => true, + .view_change => true, + }; + } +}; + +test "SuperBlockHeader" { + const expect = std.testing.expect; + + var a = std.mem.zeroes(SuperBlockHeader); + a.version = SuperBlockVersion; + a.release_format = vsr.Release.minimum; + a.set_checksum(); + + assert(a.copy == 0); + try expect(a.valid_checksum()); + + a.copy += 1; + try expect(a.valid_checksum()); + + a.version += 1; + try expect(!a.valid_checksum()); +} diff --git a/ocam/src/vsr/superblock_fuzz.zig b/ocam/src/vsr/superblock_fuzz.zig new file mode 100644 index 00000000..0c2e5ee9 --- /dev/null +++ b/ocam/src/vsr/superblock_fuzz.zig @@ -0,0 +1,484 @@ +//! Fuzz SuperBlock open()/checkpoint()/view_change(). +//! +//! Invariants checked: +//! +//! - Crashing during a checkpoint() or view_change(). +//! - open() finds a quorum, even with the interference of disk faults. +//! - open()'s quorum never regresses. +//! - Calling checkpoint() and view_change() concurrently is safe. +//! - VSRState will not leak before the corresponding checkpoint()/view_change(). +//! - Trailers will not leak before the corresponding checkpoint(). +//! - updating() reports the correct state. +//! +const std = @import("std"); +const assert = std.debug.assert; + +const constants = @import("../constants.zig"); +const vsr = @import("../vsr.zig"); +const Storage = @import("../testing/storage.zig").Storage; +const StorageFaultAtlas = @import("../testing/storage.zig").ClusterFaultAtlas; +const superblock_zone_size = @import("superblock.zig").superblock_zone_size; +const data_file_size_min = @import("superblock.zig").data_file_size_min; +const VSRState = @import("superblock.zig").SuperBlockHeader.VSRState; +const SuperBlockHeader = @import("superblock.zig").SuperBlockHeader; +const SuperBlockType = @import("superblock.zig").SuperBlockType; +const Caller = @import("superblock.zig").Caller; +const SuperBlock = SuperBlockType(Storage); +const fixtures = @import("../testing/fixtures.zig"); +const fuzz = @import("../testing/fuzz.zig"); +const stdx = @import("stdx"); +const ratio = stdx.PRNG.ratio; + +const cluster = fixtures.cluster; +const replica = fixtures.replica; +const replica_count = fixtures.replica_count; + +pub fn main(gpa: std.mem.Allocator, args: fuzz.FuzzArgs) !void { + // Total calls to checkpoint() + view_change(). + const transitions_count_total = args.events_max orelse 10; + + try run_fuzz(gpa, args.seed, transitions_count_total); +} + +fn run_fuzz(gpa: std.mem.Allocator, seed: u64, transitions_count_total: usize) !void { + var prng = stdx.PRNG.from_seed(seed); + + var storage_fault_atlas = try StorageFaultAtlas.init(gpa, 1, &prng, .{ + .faulty_superblock = true, + .faulty_wal_headers = false, + .faulty_wal_prepares = false, + .faulty_client_replies = false, + .faulty_grid = false, + }); + defer storage_fault_atlas.deinit(gpa); + + const storage_options: Storage.Options = .{ + .seed = prng.int(u64), + .size = superblock_zone_size, + // SuperBlock's IO is all serial, so latencies never reorder reads/writes. + .read_latency_min = .{ .ns = 0 }, + .read_latency_mean = .{ .ns = 0 }, + .write_latency_min = .{ .ns = 0 }, + .write_latency_mean = .{ .ns = 0 }, + // Storage will never inject more faults than the superblock is able to recover from, + // so a 100% fault probability is allowed. + .read_fault_probability = ratio( + prng.range_inclusive(u64, 25, 100), + 100, + ), + .write_fault_probability = ratio( + prng.range_inclusive(u64, 25, 100), + 100, + ), + .crash_fault_probability = ratio( + prng.range_inclusive(u64, 50, 100), + 100, + ), + .replica_index = fixtures.replica, + .fault_atlas = &storage_fault_atlas, + }; + + var storage = try fixtures.init_storage(gpa, storage_options); + defer storage.deinit(gpa); + + var storage_verify = try fixtures.init_storage(gpa, storage_options); + defer storage_verify.deinit(gpa); + + var superblock = try fixtures.init_superblock(gpa, &storage, .{ + .storage_size_limit = constants.storage_size_limit_default, + }); + defer superblock.deinit(gpa); + + var superblock_verify = try fixtures.init_superblock(gpa, &storage_verify, .{ + .storage_size_limit = constants.storage_size_limit_default, + }); + defer superblock_verify.deinit(gpa); + + var sequence_states = Environment.SequenceStates.init(gpa); + defer sequence_states.deinit(); + + const members = vsr.root_members(cluster); + var env = Environment{ + .members = members, + .sequence_states = sequence_states, + .superblock = &superblock, + .superblock_verify = &superblock_verify, + .latest_vsr_state = SuperBlockHeader.VSRState{ + .checkpoint = .{ + .header = std.mem.zeroes(vsr.Header.Prepare), + .parent_checkpoint_id = 0, + .grandparent_checkpoint_id = 0, + .free_set_blocks_acquired_checksum = comptime vsr.checksum(&.{}), + .free_set_blocks_released_checksum = comptime vsr.checksum(&.{}), + .free_set_blocks_acquired_last_block_checksum = 0, + .free_set_blocks_released_last_block_checksum = 0, + .free_set_blocks_acquired_last_block_address = 0, + .free_set_blocks_released_last_block_address = 0, + .free_set_blocks_acquired_size = 0, + .free_set_blocks_released_size = 0, + .client_sessions_checksum = vsr.checksum(&.{}), + .client_sessions_last_block_checksum = 0, + .client_sessions_last_block_address = 0, + .client_sessions_size = 0, + .manifest_oldest_checksum = 0, + .manifest_oldest_address = 0, + .manifest_newest_checksum = 0, + .manifest_newest_address = 0, + .snapshots_block_checksum = 0, + .snapshots_block_address = 0, + .manifest_block_count = 0, + .storage_size = data_file_size_min, + .release = vsr.Release.minimum, + }, + .commit_max = 0, + .sync_op_min = 0, + .sync_op_max = 0, + .log_view = 0, + .view = 0, + .replica_id = members[replica], + .members = members, + .replica_count = replica_count, + }, + }; + + try env.format(); + while (env.pending.count() > 0) env.superblock.storage.run(); + + env.open(); + + try env.verify(); + assert(env.pending.count() == 0); + assert(env.latest_sequence == 1); + + var transitions: usize = 0; + while (transitions < transitions_count_total or env.pending.count() > 0) { + if (transitions < transitions_count_total) { + // TODO bias the RNG + if (env.pending.count() == 0) { + transitions += 1; + if (prng.boolean()) { + try env.checkpoint(); + } else { + try env.view_change(); + } + } + + if (env.pending.count() == 1 and prng.chance(ratio(1, 6))) { + transitions += 1; + if (env.pending.contains(.view_change)) { + try env.checkpoint(); + } else { + try env.view_change(); + } + } + } + + assert(env.pending.count() > 0); + assert(env.pending.count() <= 2); + try env.tick(); + } +} + +const Environment = struct { + /// Track the expected value of parameters at a particular sequence. + /// Indexed by sequence. + const SequenceStates = std.ArrayList(struct { + vsr_state: VSRState, + view_headers: vsr.Headers.Array, + }); + + sequence_states: SequenceStates, + + members: vsr.Members, + + superblock: *SuperBlock, + superblock_verify: *SuperBlock, + + /// Verify that the working superblock after open() never regresses. + latest_sequence: u64 = 0, + latest_checksum: u128 = 0, + latest_parent: u128 = 0, + latest_vsr_state: VSRState, + + context_format: SuperBlock.Context = undefined, + context_open: SuperBlock.Context = undefined, + context_checkpoint: SuperBlock.Context = undefined, + context_view_change: SuperBlock.Context = undefined, + context_verify: SuperBlock.Context = undefined, + + // Set bits indicate pending operations. + pending: std.enums.EnumSet(Caller) = .{}, + pending_verify: bool = false, + + /// After every write to `superblock`'s storage, verify that the superblock can be opened, + /// and the quorum never regresses. + fn tick(env: *Environment) !void { + assert(env.pending.count() <= 2); + assert(env.superblock.storage.reads.count() + env.superblock.storage.writes.count() <= 1); + assert(!env.pending.contains(.format)); + assert(!env.pending.contains(.open)); + assert(!env.pending_verify); + assert(env.pending.contains(.view_change) == env.superblock.updating(.view_change)); + + while (env.superblock.storage.step()) { + try env.verify(); + } + env.superblock.storage.tick(); + } + + /// Verify that the superblock will recover safely if the replica crashes immediately after + /// the most recent write. + fn verify(env: *Environment) !void { + assert(!env.pending_verify); + + // Reset `superblock_verify` so that it can be reused. + env.superblock_verify.opened = false; + // Duplicate the `superblock`'s storage so it is not modified by `superblock_verify`'s + // repairs. Immediately reset() it to simulate a crash (potentially injecting additional + // faults for pending writes) and clear the read/write queues. + env.superblock_verify.storage.copy(env.superblock.storage); + env.superblock_verify.storage.reset(); + env.superblock_verify.open(verify_callback, &env.context_verify); + + env.pending_verify = true; + while (env.pending_verify) env.superblock_verify.storage.run(); + + assert(env.superblock_verify.working.checksum == env.superblock.working.checksum or + env.superblock_verify.working.checksum == env.superblock.staging.checksum); + + // Verify the sequence we read from disk is monotonically increasing. + if (env.latest_sequence < env.superblock_verify.working.sequence) { + assert(env.latest_sequence + 1 == env.superblock_verify.working.sequence); + + if (env.latest_checksum != 0) { + if (env.latest_sequence + 1 == env.superblock_verify.working.sequence) { + // After a checkpoint() or view_change(), the parent points to the previous + // working header. + assert(env.superblock_verify.working.parent == env.latest_checksum); + } + } + + assert(env.latest_vsr_state.monotonic(env.superblock_verify.working.vsr_state)); + + const expect = env.sequence_states.items[env.superblock_verify.working.sequence]; + try std.testing.expectEqualDeep( + expect.vsr_state, + env.superblock_verify.working.vsr_state, + ); + + env.latest_sequence = env.superblock_verify.working.sequence; + env.latest_checksum = env.superblock_verify.working.checksum; + env.latest_parent = env.superblock_verify.working.parent; + env.latest_vsr_state = env.superblock_verify.working.vsr_state; + } else { + assert(env.latest_sequence == env.superblock_verify.working.sequence); + assert(env.latest_checksum == env.superblock_verify.working.checksum); + assert(env.latest_parent == env.superblock_verify.working.parent); + } + } + + fn verify_callback(context: *SuperBlock.Context) void { + const env: *Environment = @fieldParentPtr("context_verify", context); + assert(env.pending_verify); + env.pending_verify = false; + } + + fn format(env: *Environment) !void { + assert(env.pending.count() == 0); + env.pending.insert(.format); + env.superblock.format(format_callback, &env.context_format, .{ + .cluster = cluster, + .release = vsr.Release.minimum, + .replica = replica, + .replica_count = replica_count, + .view = null, + }); + + var view_headers = vsr.Headers.Array{}; + view_headers.push(vsr.Header.Prepare.root(cluster)); + + assert(env.sequence_states.items.len == 0); + try env.sequence_states.append(undefined); // skip sequence=0 + try env.sequence_states.append(.{ + .vsr_state = VSRState.root(.{ + .cluster = cluster, + .release = vsr.Release.minimum, + .replica_id = env.members[replica], + .members = env.members, + .replica_count = replica_count, + .view = 0, + }), + .view_headers = view_headers, + }); + } + + fn format_callback(context: *SuperBlock.Context) void { + const env: *Environment = @fieldParentPtr("context_format", context); + assert(env.pending.contains(.format)); + env.pending.remove(.format); + } + + fn open(env: *Environment) void { + assert(env.pending.count() == 0); + fixtures.open_superblock(env.superblock); + assert(env.superblock.working.sequence == 1); + assert(env.superblock.working.vsr_state.replica_id == env.members[replica]); + assert(env.superblock.working.vsr_state.replica_count == replica_count); + assert(env.superblock.working.cluster == cluster); + } + + fn view_change(env: *Environment) !void { + assert(!env.pending.contains(.view_change)); + assert(env.pending.count() < 2); + + const vsr_state = VSRState{ + .checkpoint = env.superblock.staging.vsr_state.checkpoint, + .commit_max = env.superblock.staging.vsr_state.commit_max + 3, + .sync_op_min = 0, + .sync_op_max = 0, + .log_view = env.superblock.staging.vsr_state.log_view + 4, + .view = env.superblock.staging.vsr_state.view + 5, + .replica_id = env.members[replica], + .members = env.members, + .replica_count = replica_count, + }; + + var view_headers = vsr.Headers.Array{}; + var vsr_head = std.mem.zeroInit(vsr.Header.Prepare, .{ + .client = 1, + .request = 1, + .command = .prepare, + .release = vsr.Release.minimum, + .operation = @as(vsr.Operation, @enumFromInt(constants.vsr_operations_reserved + 1)), + .op = env.superblock.staging.vsr_state.checkpoint.header.op + 1, + .timestamp = 1, + }); + vsr_head.set_checksum_body(&.{}); + vsr_head.set_checksum(); + view_headers.push(vsr_head); + + assert(env.sequence_states.items.len == env.superblock.staging.sequence + 1); + try env.sequence_states.append(.{ + .vsr_state = vsr_state, + .view_headers = view_headers, + }); + + env.pending.insert(.view_change); + env.superblock.view_change(view_change_callback, &env.context_view_change, .{ + .commit_max = vsr_state.commit_max, + .log_view = vsr_state.log_view, + .view = vsr_state.view, + .headers = &.{ + .command = .join_view, + .array = view_headers, + }, + .sync_checkpoint = null, + }); + } + + fn view_change_callback(context: *SuperBlock.Context) void { + const env: *Environment = @fieldParentPtr("context_view_change", context); + assert(env.pending.contains(.view_change)); + env.pending.remove(.view_change); + } + + fn checkpoint(env: *Environment) !void { + assert(!env.pending.contains(.checkpoint)); + assert(env.pending.count() < 2); + + const vsr_state_old = env.superblock.staging.vsr_state; + const vsr_state = VSRState{ + .checkpoint = .{ + .header = header: { + var header = vsr.Header.Prepare.root(cluster); + header.op = vsr_state_old.checkpoint.header.op + 1; + header.set_checksum(); + break :header header; + }, + .parent_checkpoint_id = env.superblock.staging.checkpoint_id(), + .grandparent_checkpoint_id = vsr_state_old.checkpoint.parent_checkpoint_id, + .free_set_blocks_acquired_checksum = comptime vsr.checksum(&.{}), + .free_set_blocks_released_checksum = comptime vsr.checksum(&.{}), + .free_set_blocks_acquired_last_block_checksum = 0, + .free_set_blocks_released_last_block_checksum = 0, + .free_set_blocks_acquired_last_block_address = 0, + .free_set_blocks_released_last_block_address = 0, + .free_set_blocks_acquired_size = 0, + .free_set_blocks_released_size = 0, + .client_sessions_checksum = vsr.checksum(&.{}), + .client_sessions_last_block_checksum = 0, + .client_sessions_last_block_address = 0, + .client_sessions_size = 0, + .manifest_oldest_checksum = 0, + .manifest_newest_checksum = 0, + .manifest_oldest_address = 0, + .manifest_newest_address = 0, + .manifest_block_count = 0, + .storage_size = data_file_size_min, + .snapshots_block_checksum = 0, + .snapshots_block_address = 0, + .release = vsr.Release.minimum, + }, + .commit_max = vsr_state_old.commit_max + 1, + .sync_op_min = 0, + .sync_op_max = 0, + .log_view = vsr_state_old.log_view, + .view = vsr_state_old.view, + .replica_id = env.members[replica], + .members = env.members, + .replica_count = replica_count, + }; + + assert(env.sequence_states.items.len == env.superblock.staging.sequence + 1); + try env.sequence_states.append(.{ + .vsr_state = vsr_state, + .view_headers = vsr.Headers.Array.from_slice( + env.superblock.staging.view_headers().slice, + ) catch unreachable, + }); + + env.pending.insert(.checkpoint); + env.superblock.checkpoint(checkpoint_callback, &env.context_checkpoint, .{ + .manifest_references = .{ + .oldest_checksum = 0, + .newest_checksum = 0, + .oldest_address = 0, + .newest_address = 0, + .block_count = 0, + }, + .view_attributes = null, + .free_set_references = .{ + .blocks_acquired = .{ + .last_block_checksum = 0, + .last_block_address = 0, + .trailer_size = 0, + .checksum = vsr.checksum(&.{}), + }, + .blocks_released = .{ + .last_block_checksum = 0, + .last_block_address = 0, + .trailer_size = 0, + .checksum = vsr.checksum(&.{}), + }, + }, + .client_sessions_reference = .{ + .last_block_checksum = 0, + .last_block_address = 0, + .trailer_size = 0, + .checksum = vsr.checksum(&.{}), + }, + .header = vsr_state.checkpoint.header, + .commit_max = vsr_state.commit_max, + .sync_op_min = 0, + .sync_op_max = 0, + .storage_size = data_file_size_min, + .release = vsr.Release.minimum, + }); + } + + fn checkpoint_callback(context: *SuperBlock.Context) void { + const env: *Environment = @fieldParentPtr("context_checkpoint", context); + assert(env.pending.contains(.checkpoint)); + env.pending.remove(.checkpoint); + } +}; diff --git a/ocam/src/vsr/superblock_quorums.zig b/ocam/src/vsr/superblock_quorums.zig new file mode 100644 index 00000000..5e0da893 --- /dev/null +++ b/ocam/src/vsr/superblock_quorums.zig @@ -0,0 +1,369 @@ +const std = @import("std"); +const assert = std.debug.assert; +const log = std.log.scoped(.superblock_quorums); + +const stdx = @import("stdx"); +const maybe = stdx.maybe; + +const superblock = @import("./superblock.zig"); +const SuperBlockHeader = superblock.SuperBlockHeader; + +pub const Options = struct { + superblock_copies: u8, +}; + +pub fn QuorumsType(comptime options: Options) type { + return struct { + const Quorums = @This(); + + const Quorum = struct { + header: *const SuperBlockHeader, + valid: bool = false, + /// Track which copies are a member of the quorum. + /// Used to ignore duplicate copies of a header when determining a quorum. + copies: QuorumCount = .{}, + /// An integer value indicates the copy index found in the corresponding slot. + /// A `null` value indicates that the copy is invalid or not a member of the working + /// quorum. All copies belong to the same (valid, working) quorum. + slots: [options.superblock_copies]?u8 = @splat(null), + + pub fn repairs(quorum: Quorum) RepairIterator { + assert(quorum.valid); + return .{ .slots = quorum.slots }; + } + }; + + pub const QuorumCount = stdx.BitSetType(options.superblock_copies); + + pub const Error = error{ + Fork, + NotFound, + QuorumLost, + ParentNotConnected, + ParentSkipped, + VSRStateNotMonotonic, + }; + + /// We use flexible quorums for even quorums with write quorum > read quorum, for example: + /// * When writing, we must verify that at least 3/4 copies were written. + /// * At startup, we must verify that at least 2/4 copies were read. + /// + /// This ensures that our read and write quorums will intersect. + /// Using flexible quorums in this way increases resiliency of the superblock. + pub const Threshold = enum { + verify, + open, + // Working these threshold out by formula is easy to get wrong, so enumerate them: + // The rule is that the write quorum plus the read quorum must be exactly copies + 1. + + pub fn count(threshold: Threshold) u8 { + return switch (threshold) { + .verify => switch (options.superblock_copies) { + 4 => 3, + 6 => 4, + 8 => 5, + else => unreachable, + }, + // The open quorum must allow for at least two copy faults, because we update + // copies in place, temporarily impairing one copy. + .open => switch (options.superblock_copies) { + 4 => 2, + 6 => 3, + 8 => 4, + else => unreachable, + }, + }; + } + }; + + array: [options.superblock_copies]Quorum = undefined, + count: u8 = 0, + + /// Returns the working superblock according to the quorum with the highest sequence number. + /// + /// * When a member of the parent quorum is still present, verify that the highest quorum is + /// connected. + /// * When there are 2 quorums: 1/4 new and 3/4 old, favor the 3/4 old since it is safer to + /// repair. + /// TODO Re-examine this now that there are no superblock trailers to worry about. + pub fn working( + quorums: *Quorums, + copies: []const SuperBlockHeader, + threshold: Threshold, + ) Error!Quorum { + assert(copies.len == options.superblock_copies); + assert(threshold.count() >= 2 and threshold.count() <= 5); + + quorums.array = undefined; + quorums.count = 0; + + for (copies, 0..) |*copy, index| quorums.count_copy(copy, index, threshold); + + std.mem.sort(Quorum, quorums.slice(), {}, sort_priority_descending); + + for (quorums.slice()) |quorum| { + if (quorum.copies.full()) { + log.debug("quorum: checksum={x:0>32} parent={x:0>32} sequence={} count={} " ++ + "valid={}", .{ + quorum.header.checksum, + quorum.header.parent, + quorum.header.sequence, + quorum.copies.count(), + quorum.valid, + }); + } else { + log.warn("quorum: checksum={x:0>32} parent={x:0>32} sequence={} count={} " ++ + "valid={}", .{ + quorum.header.checksum, + quorum.header.parent, + quorum.header.sequence, + quorum.copies.count(), + quorum.valid, + }); + } + } + + // No working copies of any sequence number exist in the superblock storage zone at all. + if (quorums.slice().len == 0) return error.NotFound; + + // At least one copy or quorum exists. + const b = quorums.slice()[0]; + + // Verify that the remaining quorums are correctly sorted: + for (quorums.slice()[1..]) |a| { + assert(sort_priority_descending({}, b, a)); + assert(a.header.valid_checksum()); + } + + // Even the best copy with the most quorum still has inadequate quorum. + if (!b.valid) return error.QuorumLost; + + // If a parent quorum is present (either complete or incomplete) it must be connected to + // the new working quorum. The parent quorum can exist due to: + // - a crash during checkpoint()/view_change() before writing all copies + // - a lost or misdirected write + // - a latent sector error that prevented a write + for (quorums.slice()[1..]) |a| { + if (a.header.cluster != b.header.cluster) { + log.warn("superblock copy={} has cluster={} instead of {}", .{ + a.header.copy, + a.header.cluster, + b.header.cluster, + }); + continue; + } + + if (a.header.vsr_state.replica_id != b.header.vsr_state.replica_id) { + log.warn("superblock copy={} has replica_id={} instead of {}", .{ + a.header.copy, + a.header.vsr_state.replica_id, + b.header.vsr_state.replica_id, + }); + continue; + } + + if (a.header.sequence == b.header.sequence) { + // Two quorums, same cluster+replica+sequence, but different checksums. + // This shouldn't ever happen — but if it does, we can't safely repair. + assert(a.header.checksum != b.header.checksum); + return error.Fork; + } + + if (a.header.sequence > b.header.sequence + 1) { + // We read sequences such as (2,2,2,4) — 2 isn't safe to use, but there isn't a + // valid quorum for 4 either. + return error.ParentSkipped; + } + + if (a.header.sequence + 1 == b.header.sequence) { + assert(a.header.checksum != b.header.checksum); + assert(a.header.cluster == b.header.cluster); + assert(a.header.vsr_state.replica_id == b.header.vsr_state.replica_id); + + if (a.header.checksum != b.header.parent) { + return error.ParentNotConnected; + } else if (!a.header.vsr_state.monotonic(b.header.vsr_state)) { + return error.VSRStateNotMonotonic; + } else { + assert(b.header.valid_checksum()); + + return b; + } + } + } + + assert(b.header.valid_checksum()); + return b; + } + + fn count_copy( + quorums: *Quorums, + copy: *const SuperBlockHeader, + slot: usize, + threshold: Threshold, + ) void { + assert(slot < options.superblock_copies); + assert(threshold.count() >= 2 and threshold.count() <= 5); + + if (!copy.valid_checksum()) { + log.warn("copy: {}/{}: invalid checksum", .{ slot, options.superblock_copies }); + return; + } + + if (copy.copy == slot) { + log.debug( + "copy: {}/{}: valid checksum={x:0>32} parent={x:0>32} sequence={}", + .{ slot, options.superblock_copies, copy.checksum, copy.parent, copy.sequence }, + ); + } else { + // Either the entire copy was misdirected, or just the copy field is corrupted. + // We definitely still want to count the copy. + // We must just be careful to count it idempotently. + log.warn( + "copy: {}/{}: unexpected copy={} checksum={x:0>32} parent={x:0>32} sequence={}", + .{ + slot, + options.superblock_copies, + copy.copy, + copy.checksum, + copy.parent, + copy.sequence, + }, + ); + } + + var quorum = quorums.find_or_insert_quorum_for_copy(copy); + assert(quorum.header.checksum == copy.checksum); + assert(quorum.header.equal(copy)); + + if (copy.copy >= options.superblock_copies) { + // This header is a valid member of the quorum, but with an unexpected copy number. + // The "SuperBlockHeader.copy" field is not protected by the checksum, so if that + // byte (and only that byte) is corrupted, the superblock is still valid — but we + // don't know for certain which copy this was supposed to be. + // We make the assumption that this was not a double-fault (corrupt + misdirect) — + // that is, the copy is in the correct slot, and its copy index is simply corrupt. + quorum.slots[slot] = @intCast(slot); + quorum.copies.set(slot); + } else if (quorum.copies.is_set(copy.copy)) { + // Ignore the duplicate copy. + } else { + maybe(slot != copy.copy); + quorum.slots[slot] = @intCast(copy.copy); + quorum.copies.set(copy.copy); + } + assert(quorum.copies.count() >= 1); + + quorum.valid = quorum.copies.count() >= threshold.count(); + } + + fn find_or_insert_quorum_for_copy( + quorums: *Quorums, + copy: *const SuperBlockHeader, + ) *Quorum { + assert(copy.valid_checksum()); + + for (quorums.array[0..quorums.count]) |*quorum| { + if (copy.checksum == quorum.header.checksum) return quorum; + } else { + quorums.array[quorums.count] = Quorum{ .header = copy }; + quorums.count += 1; + + return &quorums.array[quorums.count - 1]; + } + } + + fn slice(quorums: *Quorums) []Quorum { + return quorums.array[0..quorums.count]; + } + + fn sort_priority_descending(_: void, a: Quorum, b: Quorum) bool { + assert(a.header.checksum != b.header.checksum); + + if (a.valid and !b.valid) return true; + if (b.valid and !a.valid) return false; + + if (a.header.sequence > b.header.sequence) return true; + if (b.header.sequence > a.header.sequence) return false; + + if (a.copies.count() > b.copies.count()) return true; + if (b.copies.count() > a.copies.count()) return false; + + // The sort order must be stable and deterministic: + return a.header.checksum > b.header.checksum; + } + + /// Repair a quorum's copies in the safest known order. + /// Repair is complete when every copy is on-disk (not necessarily in its home slot). + /// + /// We must be careful when repairing superblock headers to avoid endangering our quorum if + /// an additional fault occurs. We primarily guard against torn header writes — preventing a + /// misdirected write from derailing repair is far more expensive and complex — but they are + /// likewise far less likely to occur. + /// + /// For example, consider this case: + /// 0. Sequence is initially A. + /// 1. Checkpoint sequence B. + /// 2. Write B₀ — ok. + /// 3. Write B₁ — misdirected to B₂'s slot. + /// 4. Crash. + /// 5. Recover with quorum B[B₀,A₁,B₁,A₃]. + /// If we repair the superblock quorum while only considering the valid copies (and not + /// slots) the following scenario could occur: + /// 6. We already have a valid B₀ and B₁, so begin writing B₂. + /// 7. Crash, tearing the B₂ write. + /// 8. Recover with quorum A[B₀,A₁,_,A₂]. + /// The working quorum backtracked from B to A! + pub const RepairIterator = struct { + /// An integer value indicates the copy index found in the corresponding slot. + /// A `null` value indicates that the copy is invalid or not a member of the working + /// quorum. All copies belong to the same (valid, working) quorum. + slots: [options.superblock_copies]?u8, + + /// Returns the slot/copy to repair next. + /// We never (deliberately) write a copy to a slot other than its own. This is simpler + /// to implement, and also reduces risk when one of open()'s reads was misdirected. + pub fn next(iterator: *RepairIterator) ?u8 { + // Corrupt copy indices have already been normalized. + for (iterator.slots) |slot| { + assert(slot == null or slot.? < options.superblock_copies); + } + + // Set bits indicate that the corresponding copy was found at least once. + var copies_any: QuorumCount = .{}; + // Set bits indicate that the corresponding copy was found more than once. + var copies_duplicate: QuorumCount = .{}; + + for (iterator.slots) |slot| { + if (slot) |copy| { + if (copies_any.is_set(copy)) copies_duplicate.set(copy); + copies_any.set(copy); + } + } + + // In descending order, our priorities for repair are: + // 1. The slot holds no header, and the copy was not found anywhere. + // 2. The slot holds no header, but its copy was found elsewhere. + // 3. The slot holds a misdirected header, but that copy is in another slot as well. + var a: ?u8 = null; + var b: ?u8 = null; + var c: ?u8 = null; + for (iterator.slots, 0..) |slot, i| { + if (slot == null and !copies_any.is_set(i)) a = @intCast(i); + if (slot == null and copies_any.is_set(i)) b = @intCast(i); + if (slot) |slot_copy| { + if (slot_copy != i and copies_duplicate.is_set(slot_copy)) c = @intCast(i); + } + } + + const repair = a orelse b orelse c orelse { + for (iterator.slots) |slot| assert(slot != null); + return null; + }; + + iterator.slots[repair] = repair; + return repair; + } + }; + }; +} diff --git a/ocam/src/vsr/superblock_quorums_fuzz.zig b/ocam/src/vsr/superblock_quorums_fuzz.zig new file mode 100644 index 00000000..2b889b8f --- /dev/null +++ b/ocam/src/vsr/superblock_quorums_fuzz.zig @@ -0,0 +1,367 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const constants = @import("../constants.zig"); +const vsr = @import("../vsr.zig"); +const stdx = @import("stdx"); + +const superblock = @import("./superblock.zig"); +const SuperBlockHeader = superblock.SuperBlockHeader; +const SuperBlockVersion = superblock.SuperBlockVersion; + +const fuzz = @import("../testing/fuzz.zig"); +const superblock_quorums = @import("superblock_quorums.zig"); +const QuorumsType = superblock_quorums.QuorumsType; + +pub fn main(_: std.mem.Allocator, _: fuzz.FuzzArgs) !void { + // TODO: remove one CFO is updated. +} + +test "Quorums: fuzz working" { + // Don't print warnings from the Quorums. + const level = std.testing.log_level; + std.testing.log_level = .err; + defer std.testing.log_level = level; + + var prng = stdx.PRNG.from_seed_testing(); + + const r = &prng; + const t = test_quorums_working; + const o = CopyTemplate.make_valid; + const x = CopyTemplate.make_invalid_broken; + const X = {}; // Ignored; just for text alignment + contrast. + + // No faults: + try t(r, 2, &.{ o(3), o(3), o(3), o(3) }, 3); + try t(r, 3, &.{ o(3), o(3), o(3), o(3) }, 3); + + // Single fault: + try t(r, 3, &.{ x(X), o(4), o(4), o(4) }, 4); + // Double fault, same quorum: + try t(r, 2, &.{ x(X), x(X), o(4), o(4) }, 4); + try t(r, 3, &.{ x(X), x(X), o(4), o(4) }, error.QuorumLost); + // Double fault, different quorums: + try t(r, 2, &.{ x(X), x(X), o(3), o(4) }, error.QuorumLost); + // Triple fault. + try t(r, 2, &.{ x(X), x(X), x(X), o(4) }, error.QuorumLost); + + // Partial format (broken sequence=1): + try t(r, 2, &.{ x(X), o(1), o(1), o(1) }, 1); + try t(r, 3, &.{ x(X), o(1), o(1), o(1) }, 1); + try t(r, 2, &.{ x(X), x(X), o(1), o(1) }, 1); + try t(r, 3, &.{ x(X), x(X), o(1), o(1) }, error.QuorumLost); + try t(r, 2, &.{ x(X), x(X), x(X), o(1) }, error.QuorumLost); + try t(r, 2, &.{ x(X), x(X), x(X), x(X) }, error.NotFound); + + // Partial checkpoint() to sequence=4 (2 quorums): + try t(r, 2, &.{ o(3), o(2), o(2), o(2) }, 2); // open after 1/4 + try t(r, 2, &.{ o(3), o(3), o(2), o(2) }, 3); // open after 2/4 + try t(r, 2, &.{ o(3), o(3), o(3), o(2) }, 3); // open after 3/4 + // Partial checkpoint() to sequence=4 (3 quorums): + try t(r, 2, &.{ o(1), o(2), o(3), o(3) }, 3); + try t(r, 3, &.{ o(1), o(2), o(3), o(3) }, error.QuorumLost); + + // Skipped sequence. + try t(r, 2, &.{ o(2), o(2), o(2), o(4) }, error.ParentSkipped); // open after 1/4 + try t(r, 2, &.{ o(2), o(2), o(4), o(4) }, 4); // open after 2/4 + try t(r, 2, &.{ o(2), o(2), o(4), o(4) }, 4); // open after 3/4 + + // Forked sequence: same sequence number, different checksum, both valid. + const f = CopyTemplate.make_invalid_fork; + try t(r, 2, &.{ o(3), o(3), o(3), f(3) }, error.Fork); + try t(r, 2, &.{ o(3), o(3), f(3), f(3) }, error.Fork); + + // Parent has wrong cluster|replica. + const m = CopyTemplate.make_invalid_misdirect; + try t(r, 2, &.{ m(2), m(2), m(2), o(3) }, 2); + try t(r, 2, &.{ m(2), m(2), o(3), o(3) }, 3); + try t(r, 2, &.{ m(2), o(3), o(3), o(3) }, 3); + // Grandparent has wrong cluster|replica. + try t(r, 2, &.{ m(2), m(2), m(2), o(4) }, 2); + try t(r, 2, &.{ m(2), m(2), o(4), o(4) }, 4); + try t(r, 2, &.{ m(2), o(4), o(4), o(4) }, 4); + + // Parent/child hash chain is broken. + const p = CopyTemplate.make_invalid_parent; + try t(r, 2, &.{ o(2), o(2), o(2), p(3) }, 2); + try t(r, 2, &.{ o(2), o(2), p(3), p(3) }, error.ParentNotConnected); + try t(r, 2, &.{ o(2), p(3), p(3), p(3) }, error.ParentNotConnected); + try t(r, 2, &.{ p(3), p(3), p(3), p(3) }, 3); + + // Parent view is greater than child view. + const v = CopyTemplate.make_invalid_vsr_state; + try t(r, 2, &.{ v(2), v(2), o(3), o(3) }, error.VSRStateNotMonotonic); + + // A member of the quorum has an "invalid" copy, but an otherwise valid checksum. + const h = CopyTemplate.make_valid_high_copy; + try t(r, 2, &.{ o(2), o(2), o(3), h(3) }, 3); +} + +test "Quorums: fuzz repairs" { + // Don't print warnings from the Quorums. + const level = std.testing.log_level; + std.testing.log_level = .err; + defer std.testing.log_level = level; + + var prng = stdx.PRNG.from_seed_testing(); + try fuzz_quorum_repairs(&prng, .{ .superblock_copies = 4 }); + // TODO: Enable these once SuperBlockHeader is generic over its Constants. + // try fuzz_quorum_repairs(&prng, .{ .superblock_copies = 6 }); + // try fuzz_quorum_repairs(&prng, .{ .superblock_copies = 8 }); + +} + +fn test_quorums_working( + prng: *stdx.PRNG, + threshold_count: u8, + initial_copies: *const [4]CopyTemplate, + result: QuorumsType(.{ .superblock_copies = 4 }).Error!u64, +) !void { + const Quorums = QuorumsType(.{ .superblock_copies = 4 }); + const misdirect = prng.boolean(); // true:cluster false:replica + var quorums: Quorums = undefined; + var headers: [4]SuperBlockHeader = undefined; + var checksums: [6]u128 = undefined; + for (&checksums) |*c| c.* = prng.int(u128); + + var members: [constants.members_max]u128 = @splat(0); + for (members[0..6]) |*member| { + member.* = prng.int(u128); + } + + // Create headers in ascending-sequence order to build the checksum/parent hash chain. + var initial_templates = initial_copies.*; + const copies = &initial_templates; + std.mem.sort(CopyTemplate, copies, {}, CopyTemplate.less_than); + + for (&headers, 0..) |*header, i| { + header.* = std.mem.zeroInit(SuperBlockHeader, .{ + .copy = @as(u8, @intCast(i)), + .version = SuperBlockVersion, + .release_format = vsr.Release.minimum, + .sequence = copies[i].sequence, + .parent = checksums[copies[i].sequence - 1], + .vsr_state = std.mem.zeroInit(SuperBlockHeader.VSRState, .{ + .replica_id = members[1], + .members = members, + .replica_count = 6, + .commit_max = 123, + .checkpoint = std.mem.zeroInit(SuperBlockHeader.CheckpointState, .{ + .header = header: { + var checkpoint_header = vsr.Header.Prepare.root(0); + checkpoint_header.op = 123; + checkpoint_header.set_checksum(); + break :header checkpoint_header; + }, + .free_set_blocks_acquired_checksum = vsr.checksum(&.{}), + .free_set_blocks_released_checksum = vsr.checksum(&.{}), + .client_sessions_checksum = vsr.checksum(&.{}), + .storage_size = superblock.data_file_size_min, + }), + }), + }); + + var checksum: ?u128 = null; + switch (copies[i].variant) { + .valid => {}, + .valid_high_copy => header.copy = 4, + .invalid_broken => { + if (prng.boolean() and i > 0) { + // Error: duplicate header (if available). + header.* = headers[prng.int_inclusive(usize, i - 1)]; + checksum = prng.int(u128); + } else { + // Error: invalid checksum. + checksum = prng.int(u128); + } + }, + // Ensure we have a different checksum. + .invalid_fork => header.vsr_state.checkpoint.free_set_blocks_acquired_size += 1, + .invalid_parent => header.parent += 1, + .invalid_misdirect => { + if (misdirect) { + header.cluster += 1; + } else { + header.vsr_state.replica_id += 1; + } + }, + .invalid_vsr_state => header.vsr_state.view += 1, + } + header.checksum = checksum orelse header.calculate_checksum(); + + if (copies[i].variant == .valid or copies[i].variant == .invalid_vsr_state) { + checksums[header.sequence] = header.checksum; + } + } + + for (copies) |template| { + if (template.variant == .valid_high_copy) break; + } else { + // Shuffling copies can only change the working quorum when we have a corrupt copy index, + // because we guess that the true index is the slot. + prng.shuffle(SuperBlockHeader, &headers); + } + + const threshold = switch (threshold_count) { + 2 => Quorums.Threshold.open, + 3 => Quorums.Threshold.verify, + else => unreachable, + }; + assert(threshold.count() == threshold_count); + + if (quorums.working(&headers, threshold)) |working| { + try std.testing.expectEqual(result, working.header.sequence); + } else |err| { + try std.testing.expectEqual(result, err); + } +} + +pub const CopyTemplate = struct { + sequence: u64, + variant: Variant, + + const Variant = enum { + valid, + valid_high_copy, + invalid_broken, + invalid_fork, + invalid_misdirect, + invalid_parent, + invalid_vsr_state, + }; + + pub fn make_valid(sequence: u64) CopyTemplate { + return .{ .sequence = sequence, .variant = .valid }; + } + + /// Construct a copy with a corrupt copy index (≥superblock_copies). + pub fn make_valid_high_copy(sequence: u64) CopyTemplate { + return .{ .sequence = sequence, .variant = .valid_high_copy }; + } + + /// Construct a corrupt (invalid checksum) or duplicate copy copy. + pub fn make_invalid_broken(_: void) CopyTemplate { + // Use a high sequence so that invalid copies are the last generated by + // test_quorums_working(), so that they can become duplicates of (earlier) valid copies. + return .{ .sequence = 6, .variant = .invalid_broken }; + } + + /// Construct a copy with a valid checksum — but which differs from the "canonical" version + /// of this sequence. + pub fn make_invalid_fork(sequence: u64) CopyTemplate { + return .{ .sequence = sequence, .variant = .invalid_fork }; + } + + /// Construct a copy with either an incorrect "cluster" or "replica". + pub fn make_invalid_misdirect(sequence: u64) CopyTemplate { + return .{ .sequence = sequence, .variant = .invalid_misdirect }; + } + + /// Construct a copy with an invalid "parent" checksum. + pub fn make_invalid_parent(sequence: u64) CopyTemplate { + return .{ .sequence = sequence, .variant = .invalid_parent }; + } + + /// Construct a copy with a newer `VSRState` than its parent. + pub fn make_invalid_vsr_state(sequence: u64) CopyTemplate { + return .{ .sequence = sequence, .variant = .invalid_vsr_state }; + } + + fn less_than(_: void, a: CopyTemplate, b: CopyTemplate) bool { + return a.sequence < b.sequence; + } +}; + +// Verify that a torn header write during repair never compromises the existing quorum. +pub fn fuzz_quorum_repairs( + prng: *stdx.PRNG, + comptime options: superblock_quorums.Options, +) !void { + const superblock_copies = options.superblock_copies; + const Quorums = QuorumsType(options); + + var q1: Quorums = undefined; + var q2: Quorums = undefined; + + var members: [constants.members_max]u128 = @splat(0); + for (members[0..6]) |*member| { + member.* = prng.int(u128); + } + + const headers_valid = blk: { + var headers: [superblock_copies]SuperBlockHeader = undefined; + var header_base = std.mem.zeroInit(SuperBlockHeader, .{ + .version = SuperBlockVersion, + .release_format = vsr.Release.minimum, + .sequence = 123, + .vsr_state = std.mem.zeroInit(SuperBlockHeader.VSRState, .{ + .replica_id = members[1], + .members = members, + .replica_count = 6, + .checkpoint = std.mem.zeroInit(SuperBlockHeader.CheckpointState, .{ + .header = header: { + var checkpoint_header = vsr.Header.Prepare.root(0); + checkpoint_header.op = 123; + checkpoint_header.set_checksum(); + break :header checkpoint_header; + }, + }), + }), + }); + header_base.set_checksum(); + + for (&headers, 0..) |*header, i| { + header.* = header_base; + header.copy = @as(u8, @intCast(i)); + } + break :blk headers; + }; + + const header_invalid = blk: { + var header = headers_valid[0]; + header.checksum = 456; + break :blk header; + }; + + // Generate a random valid 2/4 quorum. + // 1 bits indicate valid headers. + // 0 bits indicate invalid headers. + var valid: stdx.BitSetType(superblock_copies) = .{}; + while (valid.count() < Quorums.Threshold.open.count() or prng.boolean()) { + valid.set(prng.int_inclusive(usize, superblock_copies - 1)); + } + + var working_headers: [superblock_copies]SuperBlockHeader = undefined; + for (&working_headers, 0..) |*header, i| { + header.* = if (valid.is_set(i)) headers_valid[i] else header_invalid; + } + prng.shuffle(SuperBlockHeader, &working_headers); + var repair_headers = working_headers; + + const working_quorum = q1.working(&working_headers, .open) catch unreachable; + var quorum_repairs = working_quorum.repairs(); + while (quorum_repairs.next()) |repair_copy| { + { + // Simulate a torn header write, crash, recover sequence. + var damaged_headers = repair_headers; + damaged_headers[repair_copy] = header_invalid; + const damaged_quorum = q2.working(&damaged_headers, .open) catch unreachable; + assert(damaged_quorum.header.checksum == working_quorum.header.checksum); + } + + // "Finish" the write so that we can test the next repair. + repair_headers[repair_copy] = headers_valid[repair_copy]; + + const quorum_repaired = q2.working(&repair_headers, .open) catch unreachable; + assert(quorum_repaired.header.checksum == working_quorum.header.checksum); + } + + // At the end of all repairs, we expect to have every copy of the superblock. + // They do not need to be in their home slot. + var copies: Quorums.QuorumCount = .{}; + for (repair_headers) |repair_header| { + assert(repair_header.checksum == working_quorum.header.checksum); + copies.set(repair_header.copy); + } + assert(repair_headers.len == copies.count()); +} diff --git a/ocam/src/vsr/sync.zig b/ocam/src/vsr/sync.zig new file mode 100644 index 00000000..16d63984 --- /dev/null +++ b/ocam/src/vsr/sync.zig @@ -0,0 +1,29 @@ +const std = @import("std"); + +const vsr = @import("../vsr.zig"); + +pub const Stage = union(enum) { + idle, + + /// The commit lifecycle is in a stage that cannot be interrupted/canceled. + /// We are waiting until that uninterruptible stage completes. + /// When it completes, we will abort the commit chain and resume sync. + /// (State sync will replace any changes the commit made anyway.) + canceling_commit, + + /// Waiting for `Grid.cancel()`. + canceling_grid, + + /// Superblock is being updated with the new checkpoint and log suffix (view headers). + updating_checkpoint: vsr.CheckpointState, + + pub fn valid_transition(from: std.meta.Tag(Stage), to: std.meta.Tag(Stage)) bool { + return switch (from) { + .idle => to == .canceling_commit or + to == .canceling_grid, + .canceling_commit => to == .canceling_grid, + .canceling_grid => to == .updating_checkpoint, + .updating_checkpoint => to == .idle, + }; + } +}; diff --git a/ocam/zig/download.ps1 b/ocam/zig/download.ps1 new file mode 100755 index 00000000..d6837b3d --- /dev/null +++ b/ocam/zig/download.ps1 @@ -0,0 +1,6 @@ +#!/bin/sh +echo `# <#` +./zig/download.sh +exit +#> > $null +./zig/download.win.ps1 diff --git a/ocam/zig/download.sh b/ocam/zig/download.sh new file mode 100755 index 00000000..a2736eb4 --- /dev/null +++ b/ocam/zig/download.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env sh +set -eu + +ZIG_MIRROR="https://ziglang.org/download" +ZIG_RELEASE="0.14.1" +ZIG_CHECKSUMS=$(cat< /dev/null; then + ZIG_CHECKSUM_ACTUAL=$(sha256sum "$ZIG_ARCHIVE" | cut -d ' ' -f 1) + elif command -v shasum > /dev/null; then + ZIG_CHECKSUM_ACTUAL=$(shasum -a 256 "$ZIG_ARCHIVE" | cut -d ' ' -f 1) + else + echo "Neither sha256sum nor shasum available." + exit 1 + fi + [ "$ZIG_CHECKSUM_ACTUAL" = "$ZIG_CHECKSUM_EXPECTED" ] +} + +if checksum_valid; then # Caching for CI. + echo "Skip downloading Zig $ZIG_RELEASE." +else + echo "Downloading Zig $ZIG_RELEASE ..." + mkdir -p ./zig/cache + # Download, making sure we download to the same output document, without + # wget adding "-1" etc. if the file was previously partially downloaded: + if command -v curl > /dev/null; then + curl --location --silent --show-error --output "$ZIG_ARCHIVE" "$ZIG_URL" + elif command -v wget > /dev/null; then + # -4 forces `wget` to connect to ipv4 addresses, as ipv6 fails to resolve on certain distros. + # Only A records (for ipv4) are used in DNS: + ipv4="-4" + # But Alpine doesn't support this argument + if [ -f /etc/alpine-release ]; then + ipv4="" + fi + + # shellcheck disable=SC2086 # We control ipv4 and it'll always either be empty or -4 + wget $ipv4 --quiet --output-document="$ZIG_ARCHIVE" "$ZIG_URL" + else + echo "Neither curl nor wget available." + exit 1 + fi + + # Verify the checksum. + if ! checksum_valid; then + echo "Checksum mismatch." + exit 1 + fi +fi + +echo "Extracting $ZIG_ARCHIVE ..." +case "$ZIG_EXTENSION" in + ".tar.xz") + tar -xf "$ZIG_ARCHIVE" + ;; + ".zip") + unzip -q "$ZIG_ARCHIVE" + ;; + *) + echo "Unexpected error extracting Zig archive." + exit 1 + ;; +esac +# NB: Keep archive for caching. + +# Replace these existing directories and files so that we can install or upgrade: +rm -rf zig/doc +rm -rf zig/lib +mv "$ZIG_DIRECTORY/LICENSE" zig/ +mv "$ZIG_DIRECTORY/README.md" zig/ +mv "$ZIG_DIRECTORY/doc" zig/ +mv "$ZIG_DIRECTORY/lib" zig/ +mv "$ZIG_DIRECTORY/zig" zig/ + +# We expect to have now moved all directories and files out of the extracted directory. +# Do not force remove so that we can get an error if the above list of files ever changes: +rmdir "$ZIG_DIRECTORY" + +# It's up to the user to add this to their path if they want to: +ZIG_BIN="$(pwd)/zig/zig" +echo "Downloading completed ($ZIG_BIN)! Enjoy!" diff --git a/ocam/zig/download.win.ps1 b/ocam/zig/download.win.ps1 new file mode 100644 index 00000000..ed033be1 --- /dev/null +++ b/ocam/zig/download.win.ps1 @@ -0,0 +1,68 @@ +$ErrorActionPreference = "Stop" + +$ZIG_MIRROR="https://ziglang.org/download" +$ZIG_RELEASE = "0.14.1" +$ZIG_CHECKSUMS = @" +$ZIG_MIRROR/0.14.1/zig-aarch64-windows-0.14.1.zip b5aac0ccc40dd91e8311b1f257717d8e3903b5fefb8f659de6d65a840ad1d0e7 +$ZIG_MIRROR/0.14.1/zig-x86_64-windows-0.14.1.zip 554f5378228923ffd558eac35e21af020c73789d87afeabf4bfd16f2e6feed2c +"@ + +$ZIG_ARCH = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { + "aarch64" +} elseif ($env:PROCESSOR_ARCHITECTURE -eq "AMD64") { + "x86_64" +} else { + Write-Error "Unsupported architecture: $($env:PROCESSOR_ARCHITECTURE)" + exit 1 +} +$ZIG_OS = "windows" +$ZIG_EXTENSION = ".zip" + +# Build URL: +$ZIG_URL = "$ZIG_MIRROR/$ZIG_RELEASE/zig-$ZIG_ARCH-$ZIG_OS-$ZIG_RELEASE$ZIG_EXTENSION" +$ZIG_ARCHIVE = "./zig/cache/" + [System.IO.Path]::GetFileName("$ZIG_URL") +$ZIG_DIRECTORY = "./" + ([System.IO.Path]::GetFileName("$ZIG_ARCHIVE") -replace [regex]::Escape($ZIG_EXTENSION), "") + +# Find expected checksum from list: +$ZIG_CHECKSUM_EXPECTED = ($ZIG_CHECKSUMS -split "`n" | Where-Object { $_ -like "*$ZIG_URL*" }) -split ' ' | Select-Object -Last 1 + +# Returns $true if the given file exists and its SHA-256 checksum matches the expected value. +function checksum_valid($file, $expected) { + if (-not (Test-Path "$file")) { return $false } + $actual = (Get-FileHash "$file").Hash + return $actual -eq $expected +} + +if (checksum_valid "$ZIG_ARCHIVE" "$ZIG_CHECKSUM_EXPECTED") { # Caching for CI. + Write-Output "Skip downloading Zig $ZIG_RELEASE." +} else { + Write-Output "Downloading Zig $ZIG_RELEASE ..." + New-Item -ItemType Directory -Path ./zig/cache -Force | Out-Null + Invoke-WebRequest -Uri "$ZIG_URL" -OutFile "$ZIG_ARCHIVE" + + # Verify the checksum. + if (-not (checksum_valid "$ZIG_ARCHIVE" "$ZIG_CHECKSUM_EXPECTED")) { + Write-Error "Checksum mismatch." + exit 1 + } +} + +Write-Output "Extracting $ZIG_ARCHIVE ..." +Expand-Archive -Path "$ZIG_ARCHIVE" -DestinationPath . +# NB: Keep archive for caching. + +# Replace these existing directories and files so that we can install or upgrade: +Remove-Item -Recurse -Force -ErrorAction SilentlyContinue zig/doc, zig/lib +Move-Item "$ZIG_DIRECTORY/LICENSE" zig/ +Move-Item "$ZIG_DIRECTORY/README.md" zig/ +Move-Item "$ZIG_DIRECTORY/doc" zig/ +Move-Item "$ZIG_DIRECTORY/lib" zig/ +Move-Item "$ZIG_DIRECTORY/zig.exe" zig/ + +# We expect to have now moved all directories and files out of the extracted directory. +# Do not force remove so that we can get an error if the above list of files ever changes: +Remove-Item "$ZIG_DIRECTORY" + +# It's up to the user to add this to their path if they want to: +$ZIG_BIN = Join-Path (Get-Location) "zig\zig.exe" +Write-Output "Downloading completed ($ZIG_BIN)! Enjoy!"